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:
12 files changed
+1376
-137
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user