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:
2026-09-11 13:25:15 +04:00
co-authored by Claude Sonnet 5
parent b60d4e6f5b
commit d4ad48d494
14 changed files with 836 additions and 6 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
#include "vdm/rules/collision.hpp"
#include <string>
#include <string_view>
namespace vdm::rules {
namespace {
// Split on the last '.', unless it's a leading dot (a dotfile like ".gitignore" has no
// extension by this convention — matches sanitize_filename's own treatment). Returns
// {stem, ext} where `ext` includes the leading '.' when present.
std::pair<std::string_view, std::string_view> split_stem_ext(std::string_view name) {
auto dot = name.rfind('.');
if (dot == std::string_view::npos || dot == 0)
return {name, {}};
return {name.substr(0, dot), name.substr(dot)};
}
} // namespace
std::string resolve_collision(std::string_view desired,
const std::function<bool(std::string_view)> &exists,
CollisionPolicy policy, int max_attempts) {
if (policy == CollisionPolicy::overwrite)
return std::string(desired);
if (!exists || !exists(desired))
return std::string(desired);
auto [stem, ext] = split_stem_ext(desired);
std::string candidate;
for (int n = 1; n <= max_attempts; ++n) {
candidate = std::string(stem) + " (" + std::to_string(n) + ")" + std::string(ext);
if (!exists(candidate))
return candidate;
}
return candidate; // still colliding; see the header comment on why this isn't hidden
}
} // namespace vdm::rules
+127
View File
@@ -0,0 +1,127 @@
#include "vdm/rules/filename.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <string>
#include <string_view>
namespace vdm::rules {
namespace {
bool is_control_byte(unsigned char c) { return c < 0x20 || c == 0x7F; }
bool is_ntfs_illegal(char c) {
switch (c) {
case '<':
case '>':
case ':':
case '"':
case '|':
case '?':
case '*':
return true;
default:
return false;
}
}
// CON, PRN, AUX, NUL, COM1-9, LPT1-9 — Win32 device names, matched case-insensitively.
// `stem` is already ASCII-only by the time this runs (everything else has been filtered),
// so a byte-wise toupper is enough; no locale, no UTF-8 concerns.
bool is_reserved_device_name(std::string_view stem) {
static constexpr std::array<std::string_view, 4> kFixed = {"CON", "PRN", "AUX", "NUL"};
std::string upper(stem);
std::transform(upper.begin(), upper.end(), upper.begin(),
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
for (auto f : kFixed)
if (upper == f)
return true;
if (upper.size() == 4 && (upper.starts_with("COM") || upper.starts_with("LPT")) &&
upper[3] >= '1' && upper[3] <= '9')
return true;
return false;
}
// Backs `pos` up off any UTF-8 continuation bytes (10xxxxxx) so a byte-length cut never
// splits a multi-byte codepoint. `pos` is a candidate cut index into `s`, 0 <= pos <=
// s.size().
std::size_t utf8_safe_cut(std::string_view s, std::size_t pos) {
while (pos > 0 && (static_cast<unsigned char>(s[pos]) & 0xC0) == 0x80)
--pos;
return pos;
}
} // namespace
std::string sanitize_filename(std::string_view raw, std::size_t max_bytes) {
// Pass 1: drop control bytes and path separators outright; fold the other
// NTFS-illegal characters to '_'. Everything else (including non-ASCII UTF-8) passes
// through untouched.
std::string s;
s.reserve(raw.size());
for (char c : raw) {
auto uc = static_cast<unsigned char>(c);
if (is_control_byte(uc) || c == '/' || c == '\\')
continue;
s.push_back(is_ntfs_illegal(c) ? '_' : c);
}
// Pass 2: collapse any run of 2+ '.' down to one, so stripped separators can't
// reconstitute a ".." (or longer) traversal-looking sequence out of what's left.
{
std::string collapsed;
collapsed.reserve(s.size());
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i] == '.' && i > 0 && collapsed.size() > 0 && collapsed.back() == '.')
continue;
collapsed.push_back(s[i]);
}
s = std::move(collapsed);
}
// Pass 3: strip trailing '.' and ' ' — both are silently dropped by the Win32 API, so
// leaving them lets two different requested names collide invisibly on export.
while (!s.empty() && (s.back() == '.' || s.back() == ' '))
s.pop_back();
if (s.empty())
s = "download";
// Pass 4: reserved device name check, against the part before the first '.' (or the
// whole name if there's none). The '_' goes right after the stem, before any
// extension ("NUL.txt" -> "NUL_.txt"), so the result still looks like the same kind of
// file rather than growing a spurious trailing character after its extension.
{
auto dot = s.find('.');
std::string_view stem = dot != std::string::npos ? std::string_view(s).substr(0, dot)
: std::string_view(s);
if (is_reserved_device_name(stem))
s.insert(stem.size(), "_");
}
// Pass 5: clamp to max_bytes, UTF-8-safe, keeping a short trailing extension intact
// where possible.
if (s.size() > max_bytes) {
std::string_view ext;
if (auto dot = s.rfind('.'); dot != std::string::npos && dot > 0 && s.size() - dot <= 16)
ext = std::string_view(s).substr(dot);
if (ext.size() < max_bytes) {
std::size_t stem_budget = max_bytes - ext.size();
std::size_t cut = utf8_safe_cut(s, stem_budget);
s = s.substr(0, cut) + std::string(ext);
} else {
s = s.substr(0, utf8_safe_cut(s, max_bytes));
}
// Re-strip: truncation can expose a new trailing '.'/' ' (e.g. the byte right
// before the cut was itself a dot that pass 3 had no reason to touch).
while (!s.empty() && (s.back() == '.' || s.back() == ' '))
s.pop_back();
if (s.empty())
s = "download";
}
return s;
}
} // namespace vdm::rules
+98
View File
@@ -0,0 +1,98 @@
#include "vdm/rules/match.hpp"
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <numeric>
namespace vdm::rules {
namespace {
char lower_ascii(char c) { return static_cast<char>(std::tolower(static_cast<unsigned char>(c))); }
bool ieq(std::string_view a, std::string_view b) {
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(),
[](char x, char y) { return lower_ascii(x) == lower_ascii(y); });
}
// A rule's `extensions` entries are documented with no leading '.', but be lenient about
// one showing up anyway (a hand-edited rule table, an older client) rather than let a
// clause that never matches silently swallow a whole category.
std::string_view strip_leading_dot(std::string_view s) {
return (!s.empty() && s.front() == '.') ? s.substr(1) : s;
}
bool match_clause(const RuleMatch &m, const MatchInput &in) {
if (m.extensions) {
bool any = std::any_of(m.extensions->begin(), m.extensions->end(), [&](const auto &e) {
return ieq(strip_leading_dot(e), in.extension);
});
if (!any)
return false;
}
if (m.mime_types) {
bool any = std::any_of(m.mime_types->begin(), m.mime_types->end(),
[&](const auto &t) { return ieq(t, in.mime_type); });
if (!any)
return false;
}
if (m.host_pattern && !glob_match(*m.host_pattern, in.host))
return false;
if (m.url_pattern && !glob_match(*m.url_pattern, in.url))
return false;
if (m.min_size_bytes) {
if (!in.size_bytes || *in.size_bytes < *m.min_size_bytes)
return false;
}
if (m.max_size_bytes) {
if (!in.size_bytes || *in.size_bytes > *m.max_size_bytes)
return false;
}
return true;
}
} // namespace
bool glob_match(std::string_view pattern, std::string_view text) noexcept {
// Classic iterative wildcard match (single backtrack point at the most recent '*'), not
// the naive recursive version — bounded work on any input, including a pattern that is
// nothing but repeated '*'s against a long `text`.
std::size_t p = 0, t = 0;
std::size_t star = std::string_view::npos, mark = 0;
while (t < text.size()) {
if (p < pattern.size() && (pattern[p] == '?' || lower_ascii(pattern[p]) == lower_ascii(text[t]))) {
++p;
++t;
} else if (p < pattern.size() && pattern[p] == '*') {
star = p++;
mark = t;
} else if (star != std::string_view::npos) {
p = star + 1;
t = ++mark;
} else {
return false;
}
}
while (p < pattern.size() && pattern[p] == '*') ++p;
return p == pattern.size();
}
std::optional<RuleAction> match_rules(const std::vector<Rule> &rules, const MatchInput &input) {
std::vector<std::size_t> order(rules.size());
std::iota(order.begin(), order.end(), 0);
// Stable by construction: std::stable_sort keeps table order among equal priorities.
std::stable_sort(order.begin(), order.end(), [&](std::size_t a, std::size_t b) {
return rules[a].priority < rules[b].priority;
});
for (auto i : order) {
const Rule &r = rules[i];
if (!r.enabled)
continue;
if (match_clause(r.match, input))
return r.action;
}
return std::nullopt;
}
} // namespace vdm::rules