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
128 lines
4.6 KiB
C++
128 lines
4.6 KiB
C++
#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
|