feat(peer): validate manifests before download mutation

Why:
- Remote and UI-echoed file descriptions could reach transaction and storage
  code one entry at a time, so a hostile late path could mutate earlier files.
- Per-file consensus also accepted malformed peer lists and let duplicate rows
  inflate a source's vote.

What:
- Add a complete protocol-7 manifest adapter with catalog-root confinement,
  portable path and alias rules, reserved-path protection, shape and size caps,
  symlink/reparse inspection, and zero-mutation tests.
- Keep download selection in the peer core, validate every peer manifest before
  consensus, and pass only the validated manifest into storage/orchestration.
- Canonicalize locally advertised paths, cap exact chunk receives, and preserve
  the local-only install fast path.
- Record the chosen safety limits and follow-up ownership/catalog decisions.

Test Plan:
- just clippy
- just test
- just frontend-test
- just build
- just fmt (Rust/TOML/Prettier completed; rumdl reports 39 pre-existing issues)
- git diff --cached --check
This commit is contained in:
ddidderr committed 2026-08-09 17:51:11 +02:00
1 parent 9268de2371
commit a6ed60a538
12 files changed
+1376 -137

No files matched your search

+34 -2
View File
@@ -4,7 +4,7 @@ use std::{
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
io::ErrorKind,
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
sync::LazyLock,
time::{SystemTime, UNIX_EPOCH},
};
@@ -353,6 +353,29 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
false
}
fn canonical_protocol_path(path: &Path) -> eyre::Result<String> {
let mut components = Vec::new();
for component in path.components() {
let Component::Normal(component) = component else {
eyre::bail!("local game path is not canonical: {}", path.display());
};
let component = component
.to_str()
.ok_or_else(|| eyre::eyre!("local game path is not valid UTF-8: {}", path.display()))?;
if component.contains('\\') {
eyre::bail!(
"local game path cannot be represented portably: {}",
path.display()
);
}
components.push(component);
}
if components.is_empty() {
eyre::bail!("local game path cannot be empty");
}
Ok(components.join("/"))
}
async fn scan_game_descriptions(
game_id: &str,
game_dir: &Path,
@@ -375,7 +398,7 @@ async fn scan_game_descriptions(
.filter_map(std::result::Result::ok)
{
let relative_path = match entry.path().strip_prefix(base_dir) {
Ok(path) => path.to_string_lossy().to_string(),
Ok(path) => canonical_protocol_path(path).map_err(PeerError::Other)?,
Err(e) => {
log::error!(
"Failed to get relative path for {}: {}",
@@ -730,6 +753,15 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written");
}
#[test]
fn protocol_paths_always_use_forward_slashes() {
let native = PathBuf::from("game").join("nested").join("archive.eti");
assert_eq!(
canonical_protocol_path(&native).expect("native path should convert"),
"game/nested/archive.eti"
);
}
fn test_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex {
LibraryIndex {
revision,