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
@@ -0,0 +1,1011 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::Metadata,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use lanspread_db::db::{GameCatalog, GameFileDescription};
|
||||
|
||||
/// A remote manifest may describe at most this many filesystem entries.
|
||||
pub(crate) const MAX_DOWNLOAD_MANIFEST_ENTRIES: usize = 100_000;
|
||||
/// A single remotely described file may be at most one tebibyte.
|
||||
pub(crate) const MAX_DOWNLOAD_FILE_BYTES: u64 = 1024 * 1024 * 1024 * 1024;
|
||||
/// A complete remotely described game may be at most sixteen tebibytes.
|
||||
pub(crate) const MAX_DOWNLOAD_MANIFEST_BYTES: u64 = 16 * MAX_DOWNLOAD_FILE_BYTES;
|
||||
/// The root sentinel is parsed in memory and should contain only a version value.
|
||||
pub(crate) const MAX_VERSION_INI_BYTES: u64 = 64 * 1024;
|
||||
/// Portable filesystems support at least 255 bytes per ordinary component.
|
||||
pub(crate) const MAX_DOWNLOAD_COMPONENT_BYTES: usize = 255;
|
||||
/// Leave room for the configured game root under conservative 1,024-unit paths.
|
||||
pub(crate) const MAX_DOWNLOAD_RELATIVE_PATH_BYTES: usize = 900;
|
||||
const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000;
|
||||
|
||||
const VERSION_INI: &str = "version.ini";
|
||||
|
||||
/// One entry whose path and shape were validated as part of a complete manifest.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ValidatedDownloadEntry {
|
||||
canonical_path: String,
|
||||
protocol_path: String,
|
||||
is_dir: bool,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
impl ValidatedDownloadEntry {
|
||||
pub(crate) fn canonical_path(&self) -> &str {
|
||||
&self.canonical_path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn protocol_path(&self) -> &str {
|
||||
&self.protocol_path
|
||||
}
|
||||
|
||||
pub(crate) const fn is_dir(&self) -> bool {
|
||||
self.is_dir
|
||||
}
|
||||
|
||||
pub(crate) const fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
pub(crate) fn is_version_ini(&self) -> bool {
|
||||
self.canonical_path == VERSION_INI
|
||||
}
|
||||
|
||||
pub(crate) fn protocol_description(&self, game_id: &str) -> GameFileDescription {
|
||||
GameFileDescription {
|
||||
game_id: game_id.to_owned(),
|
||||
relative_path: self.protocol_path.clone(),
|
||||
is_dir: self.is_dir,
|
||||
size: self.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete download description validated before any filesystem mutation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ValidatedDownloadManifest {
|
||||
game_id: String,
|
||||
games_folder: PathBuf,
|
||||
game_root: PathBuf,
|
||||
entries: Vec<ValidatedDownloadEntry>,
|
||||
}
|
||||
|
||||
impl ValidatedDownloadManifest {
|
||||
/// Contains current protocol-7 descriptions within one known catalog game root.
|
||||
pub(crate) fn from_protocol_v7(
|
||||
games_folder: &Path,
|
||||
game_id: &str,
|
||||
descriptions: Vec<GameFileDescription>,
|
||||
catalog: &GameCatalog,
|
||||
) -> eyre::Result<Self> {
|
||||
if !catalog.contains(game_id) {
|
||||
eyre::bail!("cannot download unknown catalog game {game_id}");
|
||||
}
|
||||
if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
|
||||
eyre::bail!(
|
||||
"download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}",
|
||||
descriptions.len()
|
||||
);
|
||||
}
|
||||
|
||||
validate_game_id(game_id)?;
|
||||
let games_folder = canonical_games_folder(games_folder)?;
|
||||
let game_root = games_folder.join(game_id);
|
||||
validate_game_root(&game_root)?;
|
||||
let mut builder =
|
||||
ProtocolV7ManifestBuilder::new(game_id, Some(&game_root), descriptions.len())?;
|
||||
for description in descriptions {
|
||||
builder.push(description)?;
|
||||
}
|
||||
let entries = builder.finish()?;
|
||||
|
||||
Ok(Self {
|
||||
game_id: game_id.to_owned(),
|
||||
games_folder,
|
||||
game_root,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn game_id(&self) -> &str {
|
||||
&self.game_id
|
||||
}
|
||||
|
||||
pub(crate) fn games_folder(&self) -> &Path {
|
||||
&self.games_folder
|
||||
}
|
||||
|
||||
pub(crate) fn game_root(&self) -> &Path {
|
||||
&self.game_root
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn entries(&self) -> &[ValidatedDownloadEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub(crate) fn transfer_entries(&self) -> impl Iterator<Item = &ValidatedDownloadEntry> {
|
||||
self.entries.iter().filter(|entry| !entry.is_version_ini())
|
||||
}
|
||||
|
||||
pub(crate) fn protocol_descriptions(&self) -> Vec<GameFileDescription> {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|entry| entry.protocol_description(&self.game_id))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates one peer's complete current-wire description before aggregation.
|
||||
pub(crate) fn validate_protocol_v7_descriptions(
|
||||
game_id: &str,
|
||||
descriptions: Vec<GameFileDescription>,
|
||||
) -> eyre::Result<Vec<GameFileDescription>> {
|
||||
if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
|
||||
eyre::bail!(
|
||||
"download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}",
|
||||
descriptions.len()
|
||||
);
|
||||
}
|
||||
validate_game_id(game_id)?;
|
||||
let mut builder = ProtocolV7ManifestBuilder::new(game_id, None, descriptions.len())?;
|
||||
for description in descriptions {
|
||||
builder.push(description)?;
|
||||
}
|
||||
Ok(builder
|
||||
.finish()?
|
||||
.into_iter()
|
||||
.map(|entry| entry.protocol_description(game_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
struct ProtocolV7ManifestBuilder<'a> {
|
||||
game_id: &'a str,
|
||||
game_root: Option<&'a Path>,
|
||||
prefix: String,
|
||||
game_alias: String,
|
||||
entries: Vec<ValidatedDownloadEntry>,
|
||||
shapes: BTreeMap<String, EntryShape>,
|
||||
total_bytes: u64,
|
||||
saw_protocol_root: bool,
|
||||
}
|
||||
|
||||
impl<'a> ProtocolV7ManifestBuilder<'a> {
|
||||
fn new(game_id: &'a str, game_root: Option<&'a Path>, capacity: usize) -> eyre::Result<Self> {
|
||||
Ok(Self {
|
||||
game_id,
|
||||
game_root,
|
||||
prefix: format!("{game_id}/"),
|
||||
game_alias: windows_alias_component(game_id)?,
|
||||
entries: Vec::with_capacity(capacity),
|
||||
shapes: BTreeMap::new(),
|
||||
total_bytes: 0,
|
||||
saw_protocol_root: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn push(&mut self, description: GameFileDescription) -> eyre::Result<()> {
|
||||
validate_protocol_game_id(self.game_id, &description)?;
|
||||
if self.take_redundant_root(&description)? {
|
||||
return Ok(());
|
||||
}
|
||||
if description.relative_path.contains('\\') {
|
||||
eyre::bail!(
|
||||
"download path must use forward slashes: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
|
||||
let canonical_path = description
|
||||
.relative_path
|
||||
.strip_prefix(&self.prefix)
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"download path must start with exactly {}: {}",
|
||||
self.prefix,
|
||||
description.relative_path
|
||||
)
|
||||
})?;
|
||||
let (alias_path, components) = validate_canonical_path(canonical_path)?;
|
||||
self.validate_root_component(&components, &description.relative_path)?;
|
||||
validate_entry_shape(
|
||||
&mut self.shapes,
|
||||
&alias_path,
|
||||
description.is_dir,
|
||||
&description.relative_path,
|
||||
)?;
|
||||
self.account_size(canonical_path, &description)?;
|
||||
if let Some(game_root) = self.game_root {
|
||||
validate_existing_destination(
|
||||
game_root,
|
||||
&components,
|
||||
description.is_dir,
|
||||
&description.relative_path,
|
||||
)?;
|
||||
}
|
||||
self.entries.push(ValidatedDownloadEntry {
|
||||
canonical_path: canonical_path.to_owned(),
|
||||
protocol_path: description.relative_path,
|
||||
is_dir: description.is_dir,
|
||||
size: description.size,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn take_redundant_root(&mut self, description: &GameFileDescription) -> eyre::Result<bool> {
|
||||
if description.relative_path != self.game_id {
|
||||
return Ok(false);
|
||||
}
|
||||
if description.is_dir && description.size == 0 {
|
||||
if self.saw_protocol_root {
|
||||
eyre::bail!("duplicate protocol game-root entry for {}", self.game_id);
|
||||
}
|
||||
self.saw_protocol_root = true;
|
||||
return Ok(true);
|
||||
}
|
||||
eyre::bail!(
|
||||
"the protocol game-root entry for {} must be a zero-sized directory",
|
||||
self.game_id
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_root_component(&self, components: &[&str], display_path: &str) -> eyre::Result<()> {
|
||||
let root_component = components
|
||||
.first()
|
||||
.expect("validated canonical paths have one component");
|
||||
if windows_alias_component(root_component)? == self.game_alias {
|
||||
eyre::bail!("download path contains a doubled game prefix: {display_path}");
|
||||
}
|
||||
if is_protected_root_component(root_component) {
|
||||
eyre::bail!("download path targets install or recovery state: {display_path}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn account_size(
|
||||
&mut self,
|
||||
canonical_path: &str,
|
||||
description: &GameFileDescription,
|
||||
) -> eyre::Result<()> {
|
||||
if description.is_dir {
|
||||
if description.size != 0 {
|
||||
eyre::bail!(
|
||||
"directory entry has a non-zero size: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if description.size > MAX_DOWNLOAD_FILE_BYTES {
|
||||
eyre::bail!(
|
||||
"download file exceeds the {MAX_DOWNLOAD_FILE_BYTES}-byte limit: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
if canonical_path == VERSION_INI && description.size > MAX_VERSION_INI_BYTES {
|
||||
eyre::bail!(
|
||||
"root version.ini exceeds the {MAX_VERSION_INI_BYTES}-byte limit: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
self.total_bytes = self
|
||||
.total_bytes
|
||||
.checked_add(description.size)
|
||||
.ok_or_else(|| eyre::eyre!("download manifest byte count overflow"))?;
|
||||
if self.total_bytes > MAX_DOWNLOAD_MANIFEST_BYTES {
|
||||
eyre::bail!("download manifest exceeds the {MAX_DOWNLOAD_MANIFEST_BYTES}-byte limit");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self) -> eyre::Result<Vec<ValidatedDownloadEntry>> {
|
||||
self.entries
|
||||
.sort_by(|left, right| left.canonical_path.cmp(&right.canonical_path));
|
||||
let versions = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.is_version_ini())
|
||||
.collect::<Vec<_>>();
|
||||
let [version_ini] = versions.as_slice() else {
|
||||
eyre::bail!(
|
||||
"expected exactly one regular root version.ini for {}, found {}",
|
||||
self.game_id,
|
||||
versions.len()
|
||||
);
|
||||
};
|
||||
if version_ini.is_dir {
|
||||
eyre::bail!(
|
||||
"root version.ini for {} must be a regular file",
|
||||
self.game_id
|
||||
);
|
||||
}
|
||||
Ok(self.entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum EntryShape {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
fn validate_game_id(game_id: &str) -> eyre::Result<()> {
|
||||
if game_id.contains('/') || game_id.contains('\\') {
|
||||
eyre::bail!("catalog game ID must be one path component: {game_id}");
|
||||
}
|
||||
validate_component(game_id)?;
|
||||
if is_protected_root_component(game_id) {
|
||||
eyre::bail!("catalog game ID is reserved for application state: {game_id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_protocol_game_id(
|
||||
requested_game_id: &str,
|
||||
description: &GameFileDescription,
|
||||
) -> eyre::Result<()> {
|
||||
if description.game_id != requested_game_id {
|
||||
eyre::bail!(
|
||||
"description for {} cannot be used to download {requested_game_id}",
|
||||
description.game_id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_games_folder(games_folder: &Path) -> eyre::Result<PathBuf> {
|
||||
if !games_folder.is_absolute() {
|
||||
eyre::bail!(
|
||||
"configured games directory must be absolute: {}",
|
||||
games_folder.display()
|
||||
);
|
||||
}
|
||||
let canonical = std::fs::canonicalize(games_folder).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to resolve configured games directory {}",
|
||||
games_folder.display()
|
||||
)
|
||||
})?;
|
||||
let metadata = std::fs::metadata(&canonical)?;
|
||||
if !metadata.is_dir() {
|
||||
eyre::bail!(
|
||||
"configured games directory is not a directory: {}",
|
||||
canonical.display()
|
||||
);
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn validate_game_root(game_root: &Path) -> eyre::Result<()> {
|
||||
let Some(metadata) = symlink_metadata_if_exists(game_root)? else {
|
||||
return Ok(());
|
||||
};
|
||||
if is_link_or_reparse(&metadata) {
|
||||
eyre::bail!(
|
||||
"game root must not be a symlink or reparse point: {}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
eyre::bail!("game root is not a directory: {}", game_root.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_canonical_path(path: &str) -> eyre::Result<(String, Vec<&str>)> {
|
||||
if path.is_empty() {
|
||||
eyre::bail!("download path cannot be empty");
|
||||
}
|
||||
if path.starts_with('/') || path.ends_with('/') {
|
||||
eyre::bail!("download path is not canonical: {path}");
|
||||
}
|
||||
if path.contains('\0') {
|
||||
eyre::bail!("download path contains a NUL byte");
|
||||
}
|
||||
if path.len() > MAX_DOWNLOAD_RELATIVE_PATH_BYTES {
|
||||
eyre::bail!("download path exceeds the {MAX_DOWNLOAD_RELATIVE_PATH_BYTES}-byte limit");
|
||||
}
|
||||
|
||||
let components = path.split('/').collect::<Vec<_>>();
|
||||
let mut aliases = Vec::with_capacity(components.len());
|
||||
for component in &components {
|
||||
validate_component(component)?;
|
||||
aliases.push(windows_alias_component(component)?);
|
||||
}
|
||||
Ok((aliases.join("/"), components))
|
||||
}
|
||||
|
||||
fn validate_component(component: &str) -> eyre::Result<()> {
|
||||
if component.is_empty() || matches!(component, "." | "..") {
|
||||
eyre::bail!("download path contains a non-canonical component: {component:?}");
|
||||
}
|
||||
if component.ends_with([' ', '.']) {
|
||||
eyre::bail!("download path component has a trailing dot or space: {component}");
|
||||
}
|
||||
if component.len() > MAX_DOWNLOAD_COMPONENT_BYTES {
|
||||
eyre::bail!(
|
||||
"download path component exceeds the {MAX_DOWNLOAD_COMPONENT_BYTES}-byte limit"
|
||||
);
|
||||
}
|
||||
if component.chars().any(|character| {
|
||||
character <= '\u{1f}' || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*')
|
||||
}) {
|
||||
eyre::bail!("download path component is not portable: {component}");
|
||||
}
|
||||
let device_stem = component.split('.').next().unwrap_or_default();
|
||||
if is_windows_device_name(device_stem) {
|
||||
eyre::bail!("download path uses a Windows device name: {component}");
|
||||
}
|
||||
if looks_like_dos_short_name(component) {
|
||||
eyre::bail!("download path resembles a Windows short-name alias: {component}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn windows_alias_component(component: &str) -> eyre::Result<String> {
|
||||
validate_component(component)?;
|
||||
Ok(component.to_uppercase())
|
||||
}
|
||||
|
||||
fn is_windows_device_name(stem: &str) -> bool {
|
||||
let upper = stem.to_ascii_uppercase();
|
||||
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| upper
|
||||
.strip_prefix("COM")
|
||||
.or_else(|| upper.strip_prefix("LPT"))
|
||||
.is_some_and(|number| {
|
||||
(number.len() == 1 && matches!(number.as_bytes()[0], b'1'..=b'9'))
|
||||
|| matches!(number, "¹" | "²" | "³")
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_dos_short_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or_default();
|
||||
stem.rsplit_once('~').is_some_and(|(prefix, suffix)| {
|
||||
!prefix.is_empty()
|
||||
&& !suffix.is_empty()
|
||||
&& suffix.len() <= 6
|
||||
&& suffix.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_protected_root_component(component: &str) -> bool {
|
||||
let alias = component.to_uppercase();
|
||||
alias == "LOCAL"
|
||||
|| alias.starts_with(".LOCAL.")
|
||||
|| alias.starts_with(".VERSION.INI.")
|
||||
|| matches!(
|
||||
alias.as_str(),
|
||||
".SYNC"
|
||||
| ".LANSPREAD"
|
||||
| ".LANSPREAD.JSON"
|
||||
| ".LANSPREAD.JSON.TMP"
|
||||
| ".LANSPREAD_OWNED"
|
||||
| ".SOFTLAN_FIRST_START_DONE"
|
||||
| ".SOFTLAN_GAME_INSTALLED"
|
||||
| "INSTALL_INTENT.JSON"
|
||||
| "INSTALL_INTENT.JSON.TMP"
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_entry_shape(
|
||||
shapes: &mut BTreeMap<String, EntryShape>,
|
||||
alias_path: &str,
|
||||
is_dir: bool,
|
||||
display_path: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let shape = if is_dir {
|
||||
EntryShape::Directory
|
||||
} else {
|
||||
EntryShape::File
|
||||
};
|
||||
if shapes.insert(alias_path.to_owned(), shape).is_some() {
|
||||
eyre::bail!("duplicate or platform-alias download path: {display_path}");
|
||||
}
|
||||
|
||||
let mut parent = alias_path;
|
||||
while let Some((prefix, _)) = parent.rsplit_once('/') {
|
||||
if shapes.get(prefix) == Some(&EntryShape::File) {
|
||||
eyre::bail!("download path descends through a file: {display_path}");
|
||||
}
|
||||
parent = prefix;
|
||||
}
|
||||
|
||||
if shape == EntryShape::File {
|
||||
let descendant_prefix = format!("{alias_path}/");
|
||||
if shapes
|
||||
.range(descendant_prefix.clone()..)
|
||||
.next()
|
||||
.is_some_and(|(candidate, _)| candidate.starts_with(&descendant_prefix))
|
||||
{
|
||||
eyre::bail!("download file conflicts with a described child: {display_path}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_existing_destination(
|
||||
game_root: &Path,
|
||||
components: &[&str],
|
||||
is_dir: bool,
|
||||
display_path: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let mut destination = game_root.to_path_buf();
|
||||
for component in components {
|
||||
destination.push(component);
|
||||
}
|
||||
if platform_path_units(&destination) > MAX_DOWNLOAD_DESTINATION_UNITS {
|
||||
eyre::bail!(
|
||||
"download destination exceeds the {MAX_DOWNLOAD_DESTINATION_UNITS}-unit limit: {display_path}"
|
||||
);
|
||||
}
|
||||
|
||||
destination = game_root.to_path_buf();
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
destination.push(component);
|
||||
let Some(metadata) = symlink_metadata_if_exists(&destination)? else {
|
||||
continue;
|
||||
};
|
||||
if is_link_or_reparse(&metadata) {
|
||||
eyre::bail!("download destination contains a symlink or reparse point: {display_path}");
|
||||
}
|
||||
let is_final = index + 1 == components.len();
|
||||
if !is_final && !metadata.is_dir() {
|
||||
eyre::bail!("download destination descends through a file: {display_path}");
|
||||
}
|
||||
if is_final && ((is_dir && !metadata.is_dir()) || (!is_dir && !metadata.is_file())) {
|
||||
eyre::bail!("download destination has the wrong filesystem type: {display_path}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn platform_path_units(path: &Path) -> usize {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
path.as_os_str().as_bytes().len()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn platform_path_units(path: &Path) -> usize {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
path.as_os_str().encode_wide().count()
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn platform_path_units(path: &Path) -> usize {
|
||||
path.as_os_str().to_string_lossy().len()
|
||||
}
|
||||
|
||||
fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<Metadata>> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(metadata) => Ok(Some(metadata)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_link_or_reparse(metadata: &Metadata) -> bool {
|
||||
metadata.file_type().is_symlink() || is_windows_reparse_point(metadata)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_windows_reparse_point(metadata: &Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
const fn is_windows_reparse_point(_metadata: &Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum TreeEntry {
|
||||
Directory,
|
||||
File(Vec<u8>),
|
||||
Symlink(PathBuf),
|
||||
Other,
|
||||
}
|
||||
|
||||
fn catalog() -> GameCatalog {
|
||||
GameCatalog::from_ids(["game".to_owned()])
|
||||
}
|
||||
|
||||
fn file(path: &str, size: u64) -> GameFileDescription {
|
||||
GameFileDescription {
|
||||
game_id: "game".to_owned(),
|
||||
relative_path: path.to_owned(),
|
||||
is_dir: false,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory(path: &str) -> GameFileDescription {
|
||||
GameFileDescription {
|
||||
game_id: "game".to_owned(),
|
||||
relative_path: path.to_owned(),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_descriptions() -> Vec<GameFileDescription> {
|
||||
vec![
|
||||
directory("game"),
|
||||
file("game/archive.eti", 10),
|
||||
file("game/version.ini", 8),
|
||||
]
|
||||
}
|
||||
|
||||
fn validate(
|
||||
temp: &TempDir,
|
||||
descriptions: Vec<GameFileDescription>,
|
||||
) -> eyre::Result<ValidatedDownloadManifest> {
|
||||
ValidatedDownloadManifest::from_protocol_v7(temp.path(), "game", descriptions, &catalog())
|
||||
}
|
||||
|
||||
fn snapshot_tree(root: &Path) -> BTreeMap<PathBuf, TreeEntry> {
|
||||
walkdir::WalkDir::new(root)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.map(|entry| entry.expect("test tree should be readable"))
|
||||
.filter(|entry| entry.path() != root)
|
||||
.map(|entry| {
|
||||
let relative = entry
|
||||
.path()
|
||||
.strip_prefix(root)
|
||||
.expect("entry should be below root")
|
||||
.to_path_buf();
|
||||
let file_type = entry.file_type();
|
||||
let value = if file_type.is_dir() {
|
||||
TreeEntry::Directory
|
||||
} else if file_type.is_file() {
|
||||
TreeEntry::File(
|
||||
std::fs::read(entry.path()).expect("test file should be readable"),
|
||||
)
|
||||
} else if file_type.is_symlink() {
|
||||
TreeEntry::Symlink(
|
||||
std::fs::read_link(entry.path()).expect("test link should be readable"),
|
||||
)
|
||||
} else {
|
||||
TreeEntry::Other
|
||||
};
|
||||
(relative, value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("parent should be created");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("test file should be written");
|
||||
}
|
||||
|
||||
fn assert_rejected_without_mutation(descriptions: Vec<GameFileDescription>) {
|
||||
let temp = TempDir::new("lanspread-manifest-unchanged");
|
||||
write_file(&temp.game_root().join("archive.eti"), b"original");
|
||||
write_file(&temp.game_root().join("version.ini"), b"20250101");
|
||||
write_file(&temp.game_root().join("local/save.dat"), b"save");
|
||||
write_file(&temp.path().join("sibling/local/save.dat"), b"sibling");
|
||||
let before = snapshot_tree(temp.path());
|
||||
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
assert_eq!(snapshot_tree(temp.path()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_v7_adapter_strips_exact_game_prefix() {
|
||||
let temp = TempDir::new("lanspread-manifest-valid");
|
||||
let manifest = validate(&temp, valid_descriptions()).expect("manifest should validate");
|
||||
|
||||
let paths = manifest
|
||||
.entries()
|
||||
.iter()
|
||||
.map(ValidatedDownloadEntry::canonical_path)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(paths, ["archive.eti", "version.ini"]);
|
||||
let version_ini = manifest
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| entry.is_version_ini())
|
||||
.expect("version.ini should exist");
|
||||
assert_eq!(version_ini.protocol_path(), "game/version.ini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_catalog_game() {
|
||||
let temp = TempDir::new("lanspread-manifest-unknown");
|
||||
let error = ValidatedDownloadManifest::from_protocol_v7(
|
||||
temp.path(),
|
||||
"unknown",
|
||||
Vec::new(),
|
||||
&catalog(),
|
||||
)
|
||||
.expect_err("unknown game should fail");
|
||||
assert!(error.to_string().contains("unknown catalog game"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_different_and_doubled_game_prefixes() {
|
||||
let temp = TempDir::new("lanspread-manifest-prefix");
|
||||
for path in ["version.ini", "other/version.ini", "game/game/version.ini"] {
|
||||
let descriptions = vec![file("game/archive.eti", 10), file(path, 8)];
|
||||
assert!(validate(&temp, descriptions).is_err(), "accepted {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_game_description_identity() {
|
||||
let temp = TempDir::new("lanspread-manifest-cross-game");
|
||||
let mut descriptions = valid_descriptions();
|
||||
descriptions[1].game_id = "other".to_owned();
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_noncanonical_and_nonportable_paths() {
|
||||
let temp = TempDir::new("lanspread-manifest-noncanonical");
|
||||
for path in [
|
||||
"game/a\\b.eti",
|
||||
"game//archive.eti",
|
||||
"game/./archive.eti",
|
||||
"game/../archive.eti",
|
||||
"game/archive.eti/",
|
||||
"game/C:/archive.eti",
|
||||
"game/archive?.eti",
|
||||
"game/archive.eti\0suffix",
|
||||
] {
|
||||
let descriptions = vec![file(path, 10), file("game/version.ini", 8)];
|
||||
assert!(validate(&temp, descriptions).is_err(), "accepted {path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_portability_aliases_and_path_length_overflows() {
|
||||
let temp = TempDir::new("lanspread-manifest-portability");
|
||||
for path in [
|
||||
"game/.ſync/state",
|
||||
"game/COM¹.txt",
|
||||
"game/LPT³.log",
|
||||
"game/LOCAL~1/save.dat",
|
||||
] {
|
||||
let descriptions = vec![file(path, 1), file("game/version.ini", 8)];
|
||||
assert!(validate(&temp, descriptions).is_err(), "accepted {path}");
|
||||
}
|
||||
|
||||
let long_component = format!("game/{}", "a".repeat(MAX_DOWNLOAD_COMPONENT_BYTES + 1));
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![file(&long_component, 1), file("game/version.ini", 8)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let long_relative = std::iter::repeat_n("a".repeat(200), 5)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let long_path = format!("game/{long_relative}");
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![file(&long_path, 1), file("game/version.ini", 8)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_install_and_recovery_owned_roots() {
|
||||
let temp = TempDir::new("lanspread-manifest-reserved");
|
||||
for component in [
|
||||
"local",
|
||||
"LOCAL",
|
||||
".local.installing",
|
||||
".local.backup",
|
||||
".sync",
|
||||
".lanspread",
|
||||
".lanspread.json",
|
||||
".lanspread.json.tmp",
|
||||
".lanspread_owned",
|
||||
".softlan_first_start_done",
|
||||
".softlan_game_installed",
|
||||
".version.ini.tmp",
|
||||
".version.ini.discarded",
|
||||
"install_intent.json",
|
||||
"install_intent.json.tmp",
|
||||
] {
|
||||
let path = format!("game/{component}/payload.bin");
|
||||
let descriptions = vec![file(&path, 10), file("game/version.ini", 8)];
|
||||
assert!(
|
||||
validate(&temp, descriptions).is_err(),
|
||||
"accepted protected path {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicates_platform_aliases_and_shape_conflicts() {
|
||||
let temp = TempDir::new("lanspread-manifest-alias");
|
||||
let cases = [
|
||||
vec![file("game/A.eti", 1), file("game/a.eti", 1)],
|
||||
vec![file("game/archive.eti", 1), file("game/archive.eti", 1)],
|
||||
vec![file("game/con.txt", 1)],
|
||||
vec![file("game/archive.eti.", 1)],
|
||||
vec![file("game/archive.eti ", 1)],
|
||||
vec![file("game/dir", 1), file("game/dir/child", 1)],
|
||||
vec![file("game/dir/child", 1), file("game/dir", 1)],
|
||||
vec![directory("game/dir"), file("game/dir", 1)],
|
||||
vec![directory("game"), directory("game")],
|
||||
];
|
||||
for mut descriptions in cases {
|
||||
descriptions.push(file("game/version.ini", 8));
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_peer_validation_rejects_duplicates_before_consensus() {
|
||||
let descriptions = vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/archive.eti", 1),
|
||||
file("game/archive.eti", 1),
|
||||
];
|
||||
assert!(validate_protocol_v7_descriptions("game", descriptions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_exactly_one_regular_root_version_ini() {
|
||||
let temp = TempDir::new("lanspread-manifest-version");
|
||||
assert!(validate(&temp, vec![file("game/archive.eti", 1)]).is_err());
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![file("game/version.ini", 8), file("game/version.ini", 8)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate(&temp, vec![directory("game/version.ini")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nonzero_directory_and_size_limits() {
|
||||
let temp = TempDir::new("lanspread-manifest-limits");
|
||||
let mut bad_dir = directory("game/data");
|
||||
bad_dir.size = 1;
|
||||
assert!(validate(&temp, vec![bad_dir, file("game/version.ini", 8)]).is_err());
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![
|
||||
file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1),
|
||||
file("game/version.ini", 8),
|
||||
]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![
|
||||
file("game/version.ini", MAX_VERSION_INI_BYTES + 1),
|
||||
file("game/archive.eti", 1),
|
||||
]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let descriptions = vec![file("game/version.ini", 8); MAX_DOWNLOAD_MANIFEST_ENTRIES + 1];
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_late_descriptor_leaves_filesystem_unchanged() {
|
||||
let temp = TempDir::new("lanspread-manifest-zero-mutation");
|
||||
let game_root = temp.game_root();
|
||||
std::fs::create_dir_all(&game_root).expect("game root should be created");
|
||||
std::fs::write(game_root.join("archive.eti"), b"original")
|
||||
.expect("existing archive should be written");
|
||||
std::fs::write(game_root.join("version.ini"), b"20250101")
|
||||
.expect("existing version should be written");
|
||||
|
||||
let descriptions = vec![
|
||||
file("game/archive.eti", 1),
|
||||
file("game/version.ini", 8),
|
||||
file("game/local/save.dat", 1),
|
||||
];
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(game_root.join("archive.eti")).expect("archive should remain"),
|
||||
b"original"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(game_root.join("version.ini")).expect("version should remain"),
|
||||
b"20250101"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&game_root)
|
||||
.expect("game root should remain readable")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejection_categories_leave_the_complete_tree_unchanged() {
|
||||
let cases = [
|
||||
vec![file("game/version.ini", 8), file("other/local/save.dat", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/local/save.dat", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/.sync/state", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/../escape", 1)],
|
||||
vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/A.eti", 1),
|
||||
file("game/a.eti", 1),
|
||||
],
|
||||
vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/dir", 1),
|
||||
file("game/dir/child", 1),
|
||||
],
|
||||
vec![file("game/archive.eti", 1)],
|
||||
vec![directory("game/version.ini")],
|
||||
vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1),
|
||||
],
|
||||
vec![
|
||||
directory("game"),
|
||||
directory("game"),
|
||||
file("game/version.ini", 8),
|
||||
],
|
||||
];
|
||||
for descriptions in cases {
|
||||
assert_rejected_without_mutation(descriptions);
|
||||
}
|
||||
|
||||
assert_rejected_without_mutation(vec![
|
||||
file("game/version.ini", 8);
|
||||
MAX_DOWNLOAD_MANIFEST_ENTRIES + 1
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlink_game_roots_and_destination_components() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root_link = TempDir::new("lanspread-manifest-root-link");
|
||||
let outside = TempDir::new("lanspread-manifest-outside");
|
||||
symlink(outside.path(), root_link.path().join("game"))
|
||||
.expect("game root symlink should be created");
|
||||
assert!(validate(&root_link, valid_descriptions()).is_err());
|
||||
|
||||
let child_link = TempDir::new("lanspread-manifest-child-link");
|
||||
std::fs::create_dir_all(child_link.game_root()).expect("game root should be created");
|
||||
symlink(outside.path(), child_link.game_root().join("payload"))
|
||||
.expect("child symlink should be created");
|
||||
let descriptions = vec![
|
||||
file("game/payload/file.bin", 1),
|
||||
file("game/version.ini", 8),
|
||||
];
|
||||
assert!(validate(&child_link, descriptions).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user