// AI-authored (Claude). #include "mirror.hpp" #include "json.hpp" #include #include #include #include #include #include #include #include #include #include #include namespace packutils::mirror { bool path_less(std::string_view a, std::string_view b) { std::size_t i = 0; std::size_t j = 0; while (true) { const bool a_done = i > a.size(); const bool b_done = j > b.size(); if (a_done || b_done) { return a_done && !b_done; } const std::size_t a_end = std::min(a.find('/', i), a.size()); const std::size_t b_end = std::min(b.find('/', j), b.size()); const std::string_view x = a.substr(i, a_end - i); const std::string_view y = b.substr(j, b_end - j); if (x != y) { return x < y; } i = a_end + 1; j = b_end + 1; } } namespace { bool reserved_windows_name(std::string_view component) { std::string_view stem = component.substr(0, component.find('.')); while (!stem.empty() && (stem.back() == ' ' || stem.back() == '.')) { stem.remove_suffix(1); } if (stem.size() > 7) { return false; } std::string upper(stem); for (char &c : upper) { if (c >= 'a' && c <= 'z') { c = static_cast(c - 32); } } for (const std::string_view device : {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}) { if (upper == device) { return true; } } return upper.size() == 4 && (upper.starts_with("COM") || upper.starts_with("LPT")) && upper.at(3) >= '1' && upper.at(3) <= '9'; } bool parent_skipped(const std::unordered_set &skipped, std::string_view path) { std::size_t end = path.size(); while (true) { const std::size_t slash = path.substr(0, end).rfind('/'); if (slash == std::string_view::npos) { return false; } if (skipped.contains(std::string(path.substr(0, slash)))) { return true; } end = slash; } } Failure invalid(std::string_view what) { return Failure::of(PU_KIND_INVALID_REQUEST, "MirrorManifestInvalid", std::string(what) + " is not a copy manifest this build writes"); } bool is_relative(std::string_view path) { return !path.empty() && is_safe(path); } } // namespace bool is_safe(std::string_view relative) { if (relative.find_first_of(std::string_view(":\\\0", 3)) != std::string_view::npos || relative.starts_with('/')) { return false; } if (relative.empty()) { return true; } std::size_t start = 0; while (start <= relative.size()) { const std::size_t end = std::min(relative.find('/', start), relative.size()); const std::string_view part = relative.substr(start, end - start); if (part == "." || part == ".." || reserved_windows_name(part)) { return false; } start = end + 1; } return true; } namespace { /// A placed file's record once it is known to hold `sha512`, its two sides at these stamps. Placed settled(const Entry &source, const Entry &target, std::string sha512) { return Placed{.source = source.stamp, .target = target.stamp, .sha512 = std::move(sha512), .legacy = false}; } /// Whether the copy still holds what was placed: its stamp is the one recorded, or (a stamp that /// moved, as a touch or a rounding file system moves it) its bytes still hash the same. bool copy_unchanged(const Entry &target, const Placed &placed, const Hasher &hash) { if (target.stamp == placed.target) { return true; } const auto now = hash(Side::target, target.path); return now && *now == placed.sha512; } } // namespace Classified classify(const Entry &source, const Entry &target, const Placed *placed, const Hasher &hash) { if (placed == nullptr || placed->legacy || placed->unknown()) { // Nothing recorded to compare with: the two sides' bytes decide. const auto ours = hash(Side::source, source.path); const auto theirs = hash(Side::target, target.path); if (ours && theirs && *ours == *theirs) { return {.status = Status::same, .record = settled(source, target, *ours)}; } if (placed == nullptr) { return {.status = Status::instance_only, .record = std::nullopt}; } // A version 1 entry is recorded as unknown, so the next pass does not hash it as new. Placed unknown = *placed; unknown.legacy = false; unknown.sha512.clear(); unknown.target = target.stamp; return {.status = Status::unknown, .record = placed->legacy ? std::optional(unknown) : std::nullopt}; } const bool instance = !copy_unchanged(target, *placed, hash); const bool pack = source.stamp != placed->source; if (instance && pack) { // Both moved, perhaps to the same bytes (a harvest took the instance's into the pack). const auto ours = hash(Side::source, source.path); const auto theirs = hash(Side::target, target.path); if (ours && theirs && *ours == *theirs) { return {.status = Status::same, .record = settled(source, target, *ours)}; } return {.status = Status::both_changed, .record = std::nullopt}; } if (instance) { return {.status = Status::instance_changed, .record = std::nullopt}; } if (pack) { return {.status = Status::source_changed, .record = std::nullopt}; } if (target.stamp != placed->target) { Placed refreshed = *placed; refreshed.target = target.stamp; return {.status = Status::same, .record = refreshed}; } return {.status = Status::same, .record = std::nullopt}; } Plan plan(const std::vector &source, const std::vector &target, const Manifest &manifest, const Hasher &hash) { std::unordered_map targets; for (const Entry &entry : target) { targets.insert_or_assign(entry.path, &entry); } std::unordered_map source_kinds; for (const Entry &entry : source) { source_kinds.insert_or_assign(entry.path, entry.kind); } std::vector ordered = source; std::ranges::stable_sort( ordered, [](const Entry &a, const Entry &b) { return path_less(a.path, b.path); }); Plan p; for (const Entry &entry : ordered) { if (entry.kind == Kind::other) { continue; } if (parent_skipped(p.skipped, entry.path)) { p.skipped.insert(entry.path); continue; } const auto placed = manifest.files.find(entry.path); const Placed *record = placed == manifest.files.end() ? nullptr : &placed->second; const auto found = targets.find(entry.path); const Entry *there = found == targets.end() ? nullptr : found->second; bool ok = true; if (entry.kind == Kind::directory) { if (there == nullptr) { p.make_directories.push_back(entry.path); } else if (there->kind == Kind::file) { ok = record != nullptr; if (ok) { p.make_directories.push_back(entry.path); } } else { ok = there->kind == Kind::directory; } } else if (there != nullptr) { ok = there->kind == Kind::file || (there->kind == Kind::directory && manifest.directories.contains(entry.path)); } if (!ok || !is_safe(entry.path)) { p.skipped.insert(entry.path); p.blocked.push_back(entry.path); continue; } if (entry.kind != Kind::file) { continue; } if (there == nullptr || there->kind != Kind::file) { p.copies.push_back(entry); continue; } const Classified verdict = classify(entry, *there, record, hash); if (verdict.record) { p.refreshed.insert_or_assign(entry.path, *verdict.record); } switch (verdict.status) { case Status::same: break; case Status::source_changed: p.copies.push_back(entry); break; case Status::instance_changed: case Status::instance_only: p.kept.push_back(entry.path); break; case Status::both_changed: p.conflicts.push_back(entry.path); break; case Status::unknown: p.unknown.push_back(entry.path); break; } } for (const auto &[path, record] : manifest.files) { const auto in_source = source_kinds.find(path); if (in_source != source_kinds.end() && in_source->second == Kind::file && !p.skipped.contains(path)) { continue; } const auto found = targets.find(path); if (found == targets.end() || found->second->kind != Kind::file) { continue; } // The source dropped it: delete the copy only while it is what was placed. if (record.legacy || record.unknown()) { p.unknown.push_back(path); } else if (copy_unchanged(*found->second, record, hash)) { p.delete_files.push_back(path); } else { p.kept.push_back(path); } } for (const std::string &path : manifest.directories) { const auto in_source = source_kinds.find(path); if (in_source != source_kinds.end() && in_source->second == Kind::directory && !p.skipped.contains(path)) { continue; } const auto found = targets.find(path); if (found != targets.end() && found->second->kind == Kind::directory) { p.remove_directories.push_back(path); } } std::ranges::stable_sort(p.delete_files, path_less); std::ranges::stable_sort(p.kept, path_less); std::ranges::stable_sort(p.unknown, path_less); std::ranges::stable_sort(p.remove_directories, [](const std::string &a, const std::string &b) { return path_less(b, a); }); return p; } Manifest next(const Manifest &old, const std::vector &source, const Plan &p, const std::map &copied, const std::unordered_set &failed) { std::unordered_set made; for (const std::string &path : p.make_directories) { if (!failed.contains(path)) { made.insert(path); } } Manifest result; for (const Entry &entry : source) { if (p.skipped.contains(entry.path)) { continue; } if (entry.kind == Kind::file) { if (const auto now = copied.find(entry.path); now != copied.end()) { result.files.insert_or_assign(entry.path, now->second); } else if (const auto settled_now = p.refreshed.find(entry.path); settled_now != p.refreshed.end()) { result.files.insert_or_assign(entry.path, settled_now->second); } else if (const auto kept = old.files.find(entry.path); kept != old.files.end()) { result.files.insert_or_assign(entry.path, kept->second); } } else if (entry.kind == Kind::directory) { if (old.directories.contains(entry.path) || made.contains(entry.path)) { result.directories.insert(entry.path); } } } for (const std::string &path : p.delete_files) { if (failed.contains(path) && !result.files.contains(path)) { result.files.insert_or_assign(path, old.files.at(path)); } } // An unknown entry the source dropped stays recorded, so the instance's copy is still not // taken for one it added. for (const std::string &path : p.unknown) { if (!result.files.contains(path)) { if (const auto kept = old.files.find(path); kept != old.files.end()) { result.files.insert_or_assign(path, kept->second); } } } return result; } std::vector findings(const std::vector &source, const std::vector &target, const Manifest &manifest, const Hasher &hash) { std::unordered_map sources; for (const Entry &entry : source) { sources.insert_or_assign(entry.path, &entry); } std::unordered_set present; std::vector out; for (const Entry &entry : target) { if (entry.kind != Kind::file) { continue; } present.insert(entry.path); const auto placed = manifest.files.find(entry.path); const Placed *record = placed == manifest.files.end() ? nullptr : &placed->second; const auto found = sources.find(entry.path); const Entry *ours = found == sources.end() || found->second->kind != Kind::file ? nullptr : found->second; if (ours == nullptr) { // The pack has no such file: the instance added it, or the pack dropped one the // instance changed. A placed file still as placed is the pack's to delete, not news. if (record == nullptr) { out.push_back({.what = Finding::What::added, .path = entry.path}); } else if (record->legacy || record->unknown()) { out.push_back({.what = Finding::What::unknown, .path = entry.path}); } else if (!copy_unchanged(entry, *record, hash)) { out.push_back({.what = Finding::What::edited, .path = entry.path}); } continue; } switch (classify(*ours, entry, record, hash).status) { case Status::same: case Status::source_changed: break; case Status::instance_changed: out.push_back({.what = Finding::What::edited, .path = entry.path}); break; case Status::both_changed: case Status::instance_only: out.push_back({.what = Finding::What::conflict, .path = entry.path}); break; case Status::unknown: out.push_back({.what = Finding::What::unknown, .path = entry.path}); break; } } for (const auto &[path, record] : manifest.files) { const auto found = sources.find(path); if (!present.contains(path) && found != sources.end() && found->second->kind == Kind::file) { out.push_back({.what = Finding::What::deleted, .path = path}); } } std::ranges::stable_sort( out, [](const Finding &a, const Finding &b) { return path_less(a.path, b.path); }); return out; } namespace { Result stamp_of(const json::Value &pair, std::string_view what) { if (!pair.is(json::Value::Type::array) || pair.items.size() != 2) { return std::unexpected(invalid(what)); } const auto size = pair.items.at(0).integer(0, INT64_MAX); const auto mtime = pair.items.at(1).integer(INT64_MIN, INT64_MAX); if (!size || !mtime) { return std::unexpected(invalid(what)); } return fs::Stamp{.size = static_cast(*size), .mtime_ns = *mtime}; } bool is_hex512(std::string_view text) { return text.size() == 128 && text.find_first_not_of("0123456789abcdef") == std::string_view::npos; } json::Value pair_of(const fs::Stamp &stamp) { json::Value pair = json::Value::array(); json::Value size; size.type = json::Value::Type::number; size.text = std::to_string(stamp.size); pair.push(std::move(size)); pair.push(json::Value::of_integer(stamp.mtime_ns)); return pair; } } // namespace Result parse_manifest(std::string_view bytes, std::string_view what) { auto value = json::parse(bytes); if (!value || !value->is(json::Value::Type::object) || value->members.size() != 3) { return std::unexpected(invalid(what)); } const json::Value *version_node = value->get("version"); const auto version = version_node == nullptr ? std::nullopt : version_node->integer(INT64_MIN, INT64_MAX); if (!version) { return std::unexpected(invalid(what)); } if (*version > schema_version) { Failure failure = Failure::of( PU_KIND_INVALID_REQUEST, "MirrorManifestTooNew", std::string(what) + " is version " + std::to_string(*version) + ", newer than this build reads (" + std::to_string(schema_version) + " at most)"); failure.upgrade = true; return std::unexpected(std::move(failure)); } if (*version != 1 && *version != 2) { return std::unexpected(invalid(what)); } const json::Value *files = value->get("files"); const json::Value *directories = value->get("directories"); if (files == nullptr || directories == nullptr || !files->is(json::Value::Type::object) || !directories->is(json::Value::Type::array)) { return std::unexpected(invalid(what)); } Manifest manifest; for (const json::Member &member : files->members) { if (!is_relative(member.key)) { return std::unexpected(invalid(what)); } Placed placed; if (*version == 1) { auto source = stamp_of(member.value, what); if (!source) { return std::unexpected(source.error()); } placed.source = *source; placed.legacy = true; } else { const json::Value &entry = member.value; const json::Value *source = entry.get("source"); const json::Value *target = entry.get("target"); const json::Value *sha512 = entry.get("sha512"); if (!entry.is(json::Value::Type::object) || entry.members.size() != 3 || source == nullptr || target == nullptr || sha512 == nullptr) { return std::unexpected(invalid(what)); } auto source_stamp = stamp_of(*source, what); auto target_stamp = stamp_of(*target, what); if (!source_stamp || !target_stamp) { return std::unexpected(invalid(what)); } placed.source = *source_stamp; placed.target = *target_stamp; if (sha512->is(json::Value::Type::string) && is_hex512(sha512->text)) { placed.sha512 = sha512->text; } else if (!sha512->is(json::Value::Type::null)) { return std::unexpected(invalid(what)); } } manifest.files.insert_or_assign(member.key, std::move(placed)); } for (const json::Value &item : directories->items) { if (!item.is(json::Value::Type::string) || !is_relative(item.text) || !manifest.directories.insert(item.text).second) { return std::unexpected(invalid(what)); } } return manifest; } std::string encode_manifest(const Manifest &manifest) { json::Value files = json::Value::object(); for (const auto &[path, placed] : manifest.files) { json::Value entry = json::Value::object(); entry.put("source", pair_of(placed.source)); entry.put("target", pair_of(placed.target)); entry.put("sha512", placed.sha512.empty() ? json::Value{} : json::Value::of_string(placed.sha512)); files.put(path, std::move(entry)); } json::Value directories = json::Value::array(); for (const std::string &path : manifest.directories) { directories.push(json::Value::of_string(path)); } json::Value root = json::Value::object(); root.put("version", json::Value::of_integer(schema_version)); root.put("files", std::move(files)); root.put("directories", std::move(directories)); return json::canonical(root); } } // namespace packutils::mirror