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
84 lines
2.9 KiB
C++
84 lines
2.9 KiB
C++
#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"));
|
|
}
|