#include "vdm/rules/filename.hpp" #include #include #include #include #include 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 kFixed = {"CON", "PRN", "AUX", "NUL"}; std::string upper(stem); std::transform(upper.begin(), upper.end(), upper.begin(), [](unsigned char c) { return static_cast(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(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(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