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
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
#include "vdm/rules/collision.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "vtest.hpp"
|
||||
|
||||
using vdm::rules::CollisionPolicy;
|
||||
using vdm::rules::resolve_collision;
|
||||
|
||||
namespace {
|
||||
std::function<bool(std::string_view)> exists_in(const std::set<std::string> &names) {
|
||||
return [&names](std::string_view s) { return names.count(std::string(s)) > 0; };
|
||||
}
|
||||
} // namespace
|
||||
|
||||
VT_TEST(collision_no_collision_returns_desired) {
|
||||
std::set<std::string> existing = {"other.txt"};
|
||||
VT_CHECK_EQ(resolve_collision("file.txt", exists_in(existing)), std::string("file.txt"));
|
||||
}
|
||||
|
||||
VT_TEST(collision_renames_on_conflict) {
|
||||
std::set<std::string> existing = {"file.txt"};
|
||||
VT_CHECK_EQ(resolve_collision("file.txt", exists_in(existing)), std::string("file (1).txt"));
|
||||
}
|
||||
|
||||
VT_TEST(collision_finds_first_free_slot) {
|
||||
std::set<std::string> existing = {"file.txt", "file (1).txt", "file (2).txt"};
|
||||
VT_CHECK_EQ(resolve_collision("file.txt", exists_in(existing)), std::string("file (3).txt"));
|
||||
}
|
||||
|
||||
VT_TEST(collision_no_extension) {
|
||||
std::set<std::string> existing = {"README"};
|
||||
VT_CHECK_EQ(resolve_collision("README", exists_in(existing)), std::string("README (1)"));
|
||||
}
|
||||
|
||||
VT_TEST(collision_dotfile_treated_as_no_extension) {
|
||||
std::set<std::string> existing = {".gitignore"};
|
||||
VT_CHECK_EQ(resolve_collision(".gitignore", exists_in(existing)),
|
||||
std::string(".gitignore (1)"));
|
||||
}
|
||||
|
||||
VT_TEST(collision_overwrite_policy_ignores_existence) {
|
||||
std::set<std::string> existing = {"file.txt"};
|
||||
VT_CHECK_EQ(resolve_collision("file.txt", exists_in(existing), CollisionPolicy::overwrite),
|
||||
std::string("file.txt"));
|
||||
}
|
||||
|
||||
VT_TEST(collision_gives_up_after_max_attempts_without_fabricating) {
|
||||
auto always_exists = [](std::string_view) { return true; };
|
||||
auto out = resolve_collision("file.txt", always_exists, CollisionPolicy::rename, 3);
|
||||
VT_CHECK_EQ(out, std::string("file (3).txt")); // last attempted, still colliding
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "vdm/rules/filename.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "vtest.hpp"
|
||||
|
||||
using vdm::rules::sanitize_filename;
|
||||
|
||||
VT_TEST(filename_passthrough_when_already_clean) {
|
||||
VT_CHECK_EQ(sanitize_filename("report.pdf"), std::string("report.pdf"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_strips_path_separators) {
|
||||
VT_CHECK_EQ(sanitize_filename("a/b\\c.txt"), std::string("abc.txt"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_collapses_dotdot_after_separator_strip) {
|
||||
// "../../etc/passwd" -> separators stripped, then the resulting ".." runs collapse to
|
||||
// a single '.', which strip-trailing-dot then removes entirely.
|
||||
auto out = sanitize_filename("../../etc/passwd");
|
||||
VT_CHECK(out.find("..") == std::string::npos);
|
||||
}
|
||||
|
||||
VT_TEST(filename_strips_control_bytes) {
|
||||
std::string raw = "bad";
|
||||
raw.push_back('\0');
|
||||
raw += "name.txt";
|
||||
auto out = sanitize_filename(raw);
|
||||
VT_CHECK_EQ(out, std::string("badname.txt"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_replaces_ntfs_illegal_chars) {
|
||||
VT_CHECK_EQ(sanitize_filename("a<b>c:d\"e|f?g*h.txt"), std::string("a_b_c_d_e_f_g_h.txt"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_strips_trailing_dot_and_space) {
|
||||
VT_CHECK_EQ(sanitize_filename("name. "), std::string("name"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_empty_falls_back_to_download) {
|
||||
VT_CHECK_EQ(sanitize_filename(""), std::string("download"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_all_stripped_falls_back_to_download) {
|
||||
VT_CHECK_EQ(sanitize_filename("/\\"), std::string("download"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_reserved_device_name_bare) {
|
||||
VT_CHECK_EQ(sanitize_filename("CON"), std::string("CON_"));
|
||||
VT_CHECK_EQ(sanitize_filename("con"), std::string("con_"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_reserved_device_name_with_extension) {
|
||||
VT_CHECK_EQ(sanitize_filename("NUL.txt"), std::string("NUL_.txt"));
|
||||
VT_CHECK_EQ(sanitize_filename("com3.tar.gz"), std::string("com3_.tar.gz"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_reserved_device_name_not_a_false_positive) {
|
||||
// "CONTEST" is not "CON" — must not get mangled.
|
||||
VT_CHECK_EQ(sanitize_filename("CONTEST.txt"), std::string("CONTEST.txt"));
|
||||
VT_CHECK_EQ(sanitize_filename("COM99.txt"), std::string("COM99.txt")); // not COM1-9
|
||||
}
|
||||
|
||||
VT_TEST(filename_truncates_long_name_keeping_extension) {
|
||||
std::string stem(500, 'a');
|
||||
auto out = sanitize_filename(stem + ".txt", 255);
|
||||
VT_CHECK(out.size() <= 255);
|
||||
VT_CHECK(out.ends_with(".txt"));
|
||||
}
|
||||
|
||||
VT_TEST(filename_truncation_is_utf8_safe) {
|
||||
// Each "é" is 2 bytes (C3 A9); a 5-byte budget can fit 2 whole codepoints (4 bytes) but
|
||||
// not a 3rd (needs 6) -- an unguarded byte-length cut at 5 would split the 3rd
|
||||
// codepoint's C3 from its A9, leaving a dangling lead byte.
|
||||
std::string stem;
|
||||
for (int i = 0; i < 20; ++i) stem += "\xC3\xA9";
|
||||
auto out = sanitize_filename(stem, 5);
|
||||
VT_CHECK_EQ(out, std::string("\xC3\xA9\xC3\xA9")); // 2 whole codepoints, 4 bytes
|
||||
}
|
||||
|
||||
VT_TEST(filename_preserves_non_ascii) {
|
||||
VT_CHECK_EQ(sanitize_filename("caf\xC3\xA9.pdf"), std::string("caf\xC3\xA9.pdf"));
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
#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());
|
||||
}
|
||||
Reference in New Issue
Block a user