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
193 lines
6.4 KiB
C++
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());
|
|
}
|