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
56 lines
1.9 KiB
C++
56 lines
1.9 KiB
C++
#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
|
|
}
|