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,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
|
||||
Reference in New Issue
Block a user