Files
vdm/core/tests/rules/match_test.cpp
T
samiandClaude Sonnet 5 d4ad48d494 core: stage 9 — rules/ (filename sanitization, collision policy, rule matching)
Pure functions only, per AGENT-CORE.md's build order: no I/O, no JSON, no SQL,
no notion of the wire Rule type — DAEMON decodes its own stored/wire
representation into these plain structs and calls in.

- rules/filename.hpp: sanitize_filename() turns a raw candidate (from
  net::parse_content_disposition or net::url_filename — neither is
  filesystem-safe by design; both headers say so and point here) into one
  safe to create on ext4/APFS/NTFS: strips separators and control bytes,
  folds NTFS-illegal characters, neutralizes reserved Windows device names,
  clamps length on a UTF-8 boundary. Total on hostile input; never empty.
  Not the path-traversal security boundary — that's daemon/fs/safepath,
  downstream of this and the one that actually matters adversarially.
- rules/collision.hpp: resolve_collision() finds the next free name
  Explorer/Finder-style ("name (1).ext", ...) given an existence predicate,
  or returns the desired name unchanged under an overwrite policy. Never
  fabricates a guaranteed-unique name past its attempt bound — hands back
  the last candidate tried rather than hiding a persistent collision.
- rules/match.hpp: match_rules() is the evaluation half of
  contracts/schema/types/Rule.schema.json — priority order, first rule
  whose present match clauses (extensions/mimeTypes/host & url glob/size
  bounds) all hold, wins; a size clause never matches speculatively before
  the probe fills in size_bytes. glob_match() is the iterative (not
  recursive — bounded work on an all-'*' pattern) matcher both host_pattern
  and url_pattern use.

Every header compiles standalone; tests (39 cases) pass under ASan+UBSan and
TSan. core/include/vdm/README.md documents the new public surface.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q3QrF7rCt21bkAjt9BCDFQ
2026-09-11 13:25:15 +04:00

193 lines
6.4 KiB
C++

#include "vdm/rules/match.hpp"
#include <string>
#include "vtest.hpp"
using namespace vdm::rules;
namespace {
Rule make_rule(std::string id, std::int64_t priority, RuleMatch m, RuleAction a,
bool enabled = true) {
Rule r;
r.rule_id = std::move(id);
r.priority = priority;
r.enabled = enabled;
r.match = std::move(m);
r.action = std::move(a);
return r;
}
// -Wmissing-field-initializers (part of -Wextra) flags a designated-initializer list that
// skips a member, even one this repo's designated-init style would normally leave
// implicit -- these small builders keep the tests below readable without tripping it.
RuleAction action_with_category(std::string id) {
RuleAction a;
a.category_id = std::move(id);
return a;
}
MatchInput input_with_extension(std::string ext) {
MatchInput in;
in.extension = std::move(ext);
return in;
}
MatchInput input_with_host(std::string host) {
MatchInput in;
in.host = std::move(host);
return in;
}
MatchInput input_with_size(std::optional<std::uint64_t> size) {
MatchInput in;
in.size_bytes = size;
return in;
}
MatchInput input_with_extension_and_host(std::string ext, std::string host) {
MatchInput in;
in.extension = std::move(ext);
in.host = std::move(host);
return in;
}
} // namespace
// --- glob_match ---------------------------------------------------------------------
VT_TEST(glob_exact_match) {
VT_CHECK(glob_match("example.com", "example.com"));
VT_CHECK(!glob_match("example.com", "example.org"));
}
VT_TEST(glob_star_suffix) {
VT_CHECK(glob_match("*.example.com", "cdn.example.com"));
VT_CHECK(glob_match("*.example.com", "a.b.example.com"));
VT_CHECK(!glob_match("*.example.com", "example.com")); // no room for the literal '.'
}
VT_TEST(glob_star_matches_empty) {
VT_CHECK(glob_match("file*.zip", "file.zip"));
VT_CHECK(glob_match("file*.zip", "file123.zip"));
}
VT_TEST(glob_question_mark) {
VT_CHECK(glob_match("file?.txt", "file1.txt"));
VT_CHECK(!glob_match("file?.txt", "file.txt"));
VT_CHECK(!glob_match("file?.txt", "file12.txt"));
}
VT_TEST(glob_case_insensitive) {
VT_CHECK(glob_match("*.EXAMPLE.com", "cdn.example.COM"));
}
VT_TEST(glob_multiple_stars) {
VT_CHECK(glob_match("*foo*bar*", "xxfooyybarzz"));
VT_CHECK(!glob_match("*foo*bar*", "xxbarzzfooyy")); // order matters
}
VT_TEST(glob_pathological_stars_terminate) {
// A pattern of nothing but '*' against a long text must not blow up (bounded work).
std::string pattern(50, '*');
std::string text(10000, 'x');
VT_CHECK(glob_match(pattern, text));
}
// --- match_rules ---------------------------------------------------------------------
VT_TEST(match_empty_table_yields_nullopt) {
VT_CHECK(!match_rules({}, input_with_extension("zip")).has_value());
}
VT_TEST(match_no_clause_matches_everything) {
auto rules = {make_rule("r1", 0, RuleMatch{}, action_with_category("default"))};
auto r = match_rules(rules, input_with_extension("anything"));
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(*r->category_id, std::string("default"));
}
VT_TEST(match_by_extension_case_insensitive) {
RuleMatch m;
m.extensions = std::vector<std::string>{"zip", "rar"};
auto rules = {make_rule("r1", 0, m, action_with_category("archives"))};
VT_CHECK(match_rules(rules, input_with_extension("ZIP")).has_value());
VT_CHECK(!match_rules(rules, input_with_extension("txt")).has_value());
}
VT_TEST(match_priority_order_lower_wins) {
std::vector<Rule> rules = {
make_rule("hi", 10, RuleMatch{}, action_with_category("first")),
make_rule("lo", 0, RuleMatch{}, action_with_category("second")),
};
auto r = match_rules(rules, MatchInput{});
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(*r->category_id, std::string("second")); // priority 0 runs first
}
VT_TEST(match_ties_keep_table_order) {
std::vector<Rule> rules = {
make_rule("a", 5, RuleMatch{}, action_with_category("first")),
make_rule("b", 5, RuleMatch{}, action_with_category("second")),
};
auto r = match_rules(rules, MatchInput{});
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(*r->category_id, std::string("first"));
}
VT_TEST(match_skips_disabled_rules) {
std::vector<Rule> rules = {
make_rule("a", 0, RuleMatch{}, action_with_category("disabled"), /*enabled=*/false),
make_rule("b", 1, RuleMatch{}, action_with_category("enabled")),
};
auto r = match_rules(rules, MatchInput{});
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(*r->category_id, std::string("enabled"));
}
VT_TEST(match_host_pattern) {
RuleMatch m;
m.host_pattern = "*.cdn.example.com";
auto rules = {make_rule("r1", 0, m, action_with_category("cdn"))};
VT_CHECK(match_rules(rules, input_with_host("a.cdn.example.com")).has_value());
VT_CHECK(!match_rules(rules, input_with_host("example.com")).has_value());
}
VT_TEST(match_size_bounds) {
RuleMatch m;
m.min_size_bytes = 1000;
m.max_size_bytes = 2000;
auto rules = {make_rule("r1", 0, m, action_with_category("midsize"))};
VT_CHECK(match_rules(rules, input_with_size(1500)).has_value());
VT_CHECK(!match_rules(rules, input_with_size(500)).has_value());
VT_CHECK(!match_rules(rules, input_with_size(5000)).has_value());
}
VT_TEST(match_size_clause_with_unknown_size_does_not_match) {
RuleMatch m;
m.min_size_bytes = 1000;
auto rules = {make_rule("r1", 0, m, action_with_category("big"))};
// size_bytes left absent (pre-probe) -- a size clause must not match speculatively.
VT_CHECK(!match_rules(rules, MatchInput{}).has_value());
}
VT_TEST(match_all_clauses_must_hold) {
RuleMatch m;
m.extensions = std::vector<std::string>{"iso"};
m.host_pattern = "*.trusted.example";
auto rules = {make_rule("r1", 0, m, action_with_category("isos"))};
VT_CHECK(match_rules(rules, input_with_extension_and_host("iso", "mirror.trusted.example"))
.has_value());
// Extension matches but host doesn't -- must not match.
VT_CHECK(!match_rules(rules, input_with_extension_and_host("iso", "evil.example"))
.has_value());
}
VT_TEST(match_falls_through_to_default_when_nothing_matches) {
RuleMatch m;
m.extensions = std::vector<std::string>{"exe"};
auto rules = {make_rule("r1", 0, m, action_with_category("installers"))};
VT_CHECK(!match_rules(rules, input_with_extension("pdf")).has_value());
}