diff --git a/.gitignore b/.gitignore index 46704baa..7b884746 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ *.exe /model-store/ /.local/ +.arcadia-save-*/ diff --git a/Cargo.lock b/Cargo.lock index bca46d35..fe76f504 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2143,6 +2143,7 @@ dependencies = [ name = "oa-core" version = "0.2.0" dependencies = [ + "same-file", "serde", "serde_json", "tempfile", diff --git a/crates/oa-core/Cargo.toml b/crates/oa-core/Cargo.toml index 47f41272..0ab48cda 100644 --- a/crates/oa-core/Cargo.toml +++ b/crates/oa-core/Cargo.toml @@ -11,3 +11,4 @@ serde_json.workspace = true uuid.workspace = true thiserror.workspace = true tempfile = "3" +same-file = "1" diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 8ee2f260..8236da2f 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod projection; pub mod references; pub mod routing; pub mod session; +pub mod source_files; pub mod source_project; pub mod transitions; pub mod validation; diff --git a/crates/oa-core/src/source_files.rs b/crates/oa-core/src/source_files.rs new file mode 100644 index 00000000..00c21247 --- /dev/null +++ b/crates/oa-core/src/source_files.rs @@ -0,0 +1,1214 @@ +//! Bounded disk IO for source projects. Compilation stays in `source_project`. +//! +//! Saving checks every captured dependency, stages exact bytes, and publishes a +//! recovery journal before replacing files (the entry is last). This provides +//! recoverability, not multi-file atomic visibility or a cross-process CAS. +//! Symbolic links/junctions are rejected inside the project root. +//! Recovery metadata admits at most 4096 files and 32 MiB of JSON; source bytes +//! obey the caller's aggregate limits. Reserved `.arcadia-save-*` directories +//! may remain after interruption during staging/completed cleanup. They contain +//! no pending transaction unless named `.arcadia-save-ENTRY.journal`. Windows +//! has no portable directory fsync; this is not a power-loss durability promise. +use crate::{language, source_project::*}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{self, Read, Write}, + path::{Path, PathBuf}, +}; +use thiserror::Error; + +const MAX_PATH_BYTES: usize = 4096; +const MAX_JOURNAL_BYTES: usize = 32 * 1024 * 1024; +const MAX_JOURNAL_FILES: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiskExpectation { + Missing, + Bytes(Vec), +} + +/// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`; +/// use `diagnostics` to explain files that discovery could not read or parse. +/// Expectations always describe disk bytes before UUID stabilization. +#[derive(Debug, Clone)] +pub struct DiskProject { + pub root: PathBuf, + pub bundle: SourceBundle, + pub expectations: BTreeMap, + pub reachable: BTreeSet, + pub diagnostics: Vec, +} + +impl DiskProject { + pub fn reachable_bundle(&self) -> SourceBundle { + SourceBundle { + entry: self.bundle.entry.clone(), + files: self + .bundle + .files + .iter() + .filter(|(path, _)| self.reachable.contains(*path)) + .map(|(path, text)| (path.clone(), text.clone())) + .collect(), + } + } +} + +#[derive(Debug, Error)] +pub enum SourceFileError { + #[error("Source file operation failed at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("Unsafe source path: {0}")] + Path(String), + #[error("Source project admission limit: {0}")] + Limit(String), + #[error("Source files changed outside Arcadia Studio: {0:?}")] + Conflict(Vec), + #[error("No disk expectation was established for {0}. Review the destination before saving.")] + UnknownExpectation(LogicalPath), + #[error("Invalid source project: {0}")] + Invalid(String), + #[error("A source save needs recovery at {0}. Recover it before saving again.")] + RecoveryRequired(PathBuf), + #[error( + "Save failed ({save}); recovery could not finish ({recovery}). The journal remains at {journal}." + )] + RecoveryFailed { + save: String, + recovery: String, + journal: PathBuf, + }, +} + +fn io_at(path: &Path, source: io::Error) -> SourceFileError { + SourceFileError::Io { + path: path.into(), + source, + } +} + +fn root_entry(entry: &Path) -> Result<(PathBuf, String), SourceFileError> { + let name = entry + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| SourceFileError::Path("Entry needs a UTF-8 file name.".into()))?; + check_logical(name)?; + let parent = entry + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let root = parent + .canonicalize() + .map_err(|error| io_at(parent, error))?; + if !root.is_dir() { + return Err(SourceFileError::Path( + "Project root is not a directory.".into(), + )); + } + Ok((root, name.into())) +} + +fn check_logical(path: &str) -> Result<(), SourceFileError> { + if path.len() > MAX_PATH_BYTES + || resolve_include("", path).as_deref() != Ok(path) + || path + .split('/') + .any(|part| part.to_ascii_lowercase().starts_with(".arcadia-save-")) + { + return Err(SourceFileError::Path(format!( + "Non-canonical, reserved or overlong path '{path}'." + ))); + } + Ok(()) +} + +/// Reject links rather than quietly replacing a link instead of its target. +/// Existing parents are checked even for a new destination. +fn disk_path(root: &Path, logical: &str) -> Result { + check_logical(logical)?; + let canonical_root = root.canonicalize().map_err(|error| io_at(root, error))?; + if canonical_root != root { + return Err(SourceFileError::Path( + "Project root changed physical location.".into(), + )); + } + let mut current = root.to_path_buf(); + for component in logical.split('/') { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(SourceFileError::Path(format!( + "Links are not source project files: '{logical}'." + ))); + } + let physical = current + .canonicalize() + .map_err(|error| io_at(¤t, error))?; + if !physical.starts_with(root) { + return Err(SourceFileError::Path(format!( + "'{logical}' escapes the project root." + ))); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_at(¤t, error)), + } + } + Ok(current) +} + +// Check before opening: opening a FIFO for reading can wait forever. The +// post-open check below still verifies the opened object. As with disk_path, +// this does not promise a CAS against concurrent path replacement. +fn regular_file_metadata(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| io_at(path, error))?; + if !metadata.is_file() { + return Err(SourceFileError::Path(format!( + "{} is not a regular file.", + path.display() + ))); + } + Ok(metadata) +} + +fn read_bounded(path: &Path, maximum: usize) -> Result, SourceFileError> { + regular_file_metadata(path)?; + let file = File::open(path).map_err(|error| io_at(path, error))?; + if !file + .metadata() + .map_err(|error| io_at(path, error))? + .is_file() + { + return Err(SourceFileError::Path(format!( + "{} is not a regular file.", + path.display() + ))); + } + let mut bytes = Vec::new(); + file.take((maximum as u64).saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| io_at(path, error))?; + if bytes.len() > maximum { + return Err(SourceFileError::Limit(format!( + "{} exceeds the remaining {maximum} byte budget.", + path.display() + ))); + } + Ok(bytes) +} + +fn located( + bundle: &SourceBundle, + path: &str, + span: language::Span, + code: &'static str, + message: impl Into, +) -> SourceDiagnostic { + let source = bundle.files.get(path).map_or("", String::as_str); + let mut diagnostic = language::diagnostic( + source, + language::Error { + code, + offset: span.start.min(source.len()), + message: message.into(), + }, + ); + diagnostic.span.end = span.end.min(source.len()).max(diagnostic.span.start); + SourceDiagnostic { + path: path.into(), + diagnostic, + related: vec![], + } +} + +fn check_snapshot(snapshot: &DiskProject, limits: ProjectLimits) -> Result<(), SourceFileError> { + if snapshot.bundle.entry.contains('/') { + return Err(SourceFileError::Path( + "Entry must be at the source root.".into(), + )); + } + let mut paths = BTreeSet::new(); + let mut folded = BTreeMap::new(); + for path in std::iter::once(&snapshot.bundle.entry) + .chain(snapshot.bundle.files.keys()) + .chain(snapshot.expectations.keys()) + { + check_logical(path)?; + paths.insert(path); + if let Some(previous) = folded.insert(path.to_lowercase(), path) + && previous != path + { + return Err(SourceFileError::Path(format!( + "Case-colliding paths '{previous}' and '{path}'." + ))); + } + } + if paths.len() > limits.max_files { + return Err(SourceFileError::Limit(format!( + "More than {} files.", + limits.max_files + ))); + } + for total in [ + snapshot + .bundle + .files + .values() + .fold(0usize, |sum, text| sum.saturating_add(text.len())), + snapshot + .expectations + .values() + .fold(0usize, |sum, expected| { + sum.saturating_add(match expected { + DiskExpectation::Missing => 0, + DiskExpectation::Bytes(bytes) => bytes.len(), + }) + }), + ] { + if total > limits.model.max_source_bytes { + return Err(SourceFileError::Limit( + "Combined source bytes exceed the project budget.".into(), + )); + } + } + Ok(()) +} + +fn check_physical_aliases<'a>( + root: &Path, + paths: impl IntoIterator, +) -> Result<(), SourceFileError> { + let mut handles: Vec<(&str, same_file::Handle)> = Vec::new(); + let mut visited = BTreeSet::new(); + for logical in paths { + if !visited.insert(logical) { + continue; + } + let path = disk_path(root, logical)?; + match regular_file_metadata(&path) { + Ok(_) => {} + Err(SourceFileError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => { + continue; + } + Err(error) => return Err(error), + } + match same_file::Handle::from_path(&path) { + Ok(handle) => { + if let Some((previous, _)) = handles.iter().find(|(_, old)| *old == handle) { + return Err(SourceFileError::Path(format!( + "'{previous}' and '{logical}' refer to the same physical file." + ))); + } + handles.push((logical, handle)); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_at(&path, error)), + } + } + Ok(()) +} + +/// Load original bytes, including invalid UTF-8/syntax diagnostics. Missing +/// includes are diagnostics, not a reason to discard editable files. +pub fn load_project(entry: &Path, limits: ProjectLimits) -> Result { + let (root, entry) = root_entry(entry)?; + let snapshot = DiskProject { + root, + bundle: SourceBundle { + entry, + files: BTreeMap::new(), + }, + expectations: BTreeMap::new(), + reachable: BTreeSet::new(), + diagnostics: vec![], + }; + discover_missing(&snapshot, &BTreeMap::new(), limits) +} + +/// Establish an unsaved project destination. An existing entry is a collision, +/// never implicit permission to overwrite it. Parent directory must exist. +pub fn new_project(entry: &Path, limits: ProjectLimits) -> Result { + let (root, entry) = root_entry(entry)?; + let mut snapshot = DiskProject { + root, + bundle: SourceBundle { + entry: entry.clone(), + files: BTreeMap::new(), + }, + expectations: BTreeMap::new(), + reachable: BTreeSet::from([entry.clone()]), + diagnostics: vec![], + }; + expect_new_file(&mut snapshot, &entry, limits)?; + Ok(snapshot) +} + +pub fn expect_new_file( + snapshot: &mut DiskProject, + path: &str, + limits: ProjectLimits, +) -> Result<(), SourceFileError> { + let destination = disk_path(&snapshot.root, path)?; + match fs::symlink_metadata(&destination) { + Ok(_) => return Err(SourceFileError::Conflict(vec![path.into()])), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_at(&destination, error)), + } + let mut candidate = snapshot.clone(); + candidate + .expectations + .insert(path.into(), DiskExpectation::Missing); + check_snapshot(&candidate, limits)?; + snapshot.expectations = candidate.expectations; + Ok(()) +} + +/// Merge editor overlays and discover only files absent from retained buffers. +/// Existing drafts (including detached ones) and their disk baselines survive. +pub fn discover_missing( + snapshot: &DiskProject, + overlays: &BTreeMap, + limits: ProjectLimits, +) -> Result { + let mut result = snapshot.clone(); + result.bundle.files.extend(overlays.clone()); + result.reachable.clear(); + result.diagnostics.clear(); + check_snapshot(&result, limits)?; + let mut stack = vec![(result.bundle.entry.clone(), 0usize, None::)]; + let mut edges = 0usize; + let mut attempted = BTreeSet::new(); + while let Some((path, depth, origin)) = stack.pop() { + let at = origin.unwrap_or(SourceLocation { + path: path.clone(), + span: language::Span::default(), + }); + if depth > limits.max_include_depth { + result.diagnostics.push(located( + &result.bundle, + &at.path, + at.span, + "project_limit", + "Include depth exceeds the project budget.", + )); + continue; + } + result.reachable.insert(path.clone()); + if !attempted.insert(path.clone()) { + continue; + } + if !result.bundle.files.contains_key(&path) { + let unique = result + .expectations + .keys() + .chain(result.bundle.files.keys()) + .chain(std::iter::once(&path)) + .collect::>(); + if unique.len() > limits.max_files { + result.diagnostics.push(located( + &result.bundle, + &at.path, + at.span, + "project_limit", + "File count exceeds the project budget.", + )); + continue; + } + let used = result + .bundle + .files + .values() + .fold(0usize, |sum, text| sum.saturating_add(text.len())) + .max(result.expectations.values().fold(0usize, |sum, expected| { + sum.saturating_add(match expected { + DiskExpectation::Missing => 0, + DiskExpectation::Bytes(bytes) => bytes.len(), + }) + })); + let loaded = disk_path(&result.root, &path).and_then(|physical| { + read_bounded( + &physical, + limits.model.max_source_bytes.saturating_sub(used), + ) + }); + match loaded { + Ok(bytes) => { + result + .expectations + .insert(path.clone(), DiskExpectation::Bytes(bytes.clone())); + match String::from_utf8(bytes) { + Ok(text) => { + result.bundle.files.insert(path.clone(), text); + } + Err(_) => { + result.diagnostics.push(located( + &result.bundle, + &at.path, + at.span, + "source_encoding", + format!("'{path}' is not UTF-8 source."), + )); + continue; + } + } + } + Err(error) => { + if matches!(&error, SourceFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound) + { + result + .expectations + .entry(path.clone()) + .or_insert(DiskExpectation::Missing); + } + let code = match &error { + SourceFileError::Limit(_) => "source_limit", + SourceFileError::Path(_) => "invalid_path", + _ => "missing_include", + }; + result.diagnostics.push(located( + &result.bundle, + &at.path, + at.span, + code, + format!("Cannot load '{path}': {error}"), + )); + continue; + } + } + } + match inspect_includes( + &path, + &result.bundle.files[&path], + path == result.bundle.entry, + limits, + ) { + Ok(includes) => { + edges = edges.saturating_add(includes.len()); + if edges > limits.max_include_edges { + result.diagnostics.push(located( + &result.bundle, + &path, + language::Span::default(), + "project_limit", + "Include edges exceed the project budget.", + )); + continue; + } + stack.extend( + includes + .into_iter() + .rev() + .map(|include| (include.target, depth + 1, Some(include.location))), + ); + } + Err(diagnostics) => result.diagnostics.extend(diagnostics), + } + } + check_snapshot(&result, limits)?; + check_physical_aliases( + &result.root, + result.expectations.keys().chain(result.bundle.files.keys()), + )?; + if result.diagnostics.is_empty() { + match compile_bundle(&result.reachable_bundle(), limits) { + Ok(compiled) => result.diagnostics = compiled.diagnostics, + Err(diagnostics) => result.diagnostics = diagnostics, + } + } + let pending = journal_path(&result.root, &result.bundle.entry); + if fs::symlink_metadata(&pending).is_ok() { + result.diagnostics.push(located( + &result.bundle, + &result.bundle.entry, + language::Span::default(), + "pending_save", + format!( + "A save was interrupted. Review and recover {} before saving this project.", + pending.display() + ), + )); + } + Ok(result) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Journal { + version: u8, + entry: String, + #[serde(deserialize_with = "journal_files")] + files: Vec, +} + +fn journal_files<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct Files; + impl<'de> serde::de::Visitor<'de> for Files { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("at most 4096 recovery entries") + } + fn visit_seq>( + self, + mut sequence: A, + ) -> Result { + let mut files = Vec::new(); + while let Some(file) = sequence.next_element()? { + if files.len() >= MAX_JOURNAL_FILES { + return Err(serde::de::Error::custom("Recovery entry budget exceeded.")); + } + files.push(file); + } + Ok(files) + } + } + deserializer.deserialize_seq(Files) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct JournalFile { + path: String, + before: bool, + write: bool, +} + +fn journal_path(root: &Path, entry: &str) -> PathBuf { + root.join(format!(".arcadia-save-{entry}.journal")) +} +fn staged_path(journal: &Path, index: usize, suffix: &str) -> PathBuf { + journal.join(format!("{index}.{suffix}")) +} + +fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), SourceFileError> { + let mut file = File::options() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| io_at(path, error))?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| io_at(path, error)) +} + +fn sync_directory(path: &Path) -> Result<(), SourceFileError> { + #[cfg(unix)] + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|error| io_at(path, error))?; + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +fn atomic_write(root: &Path, logical: &str, bytes: &[u8]) -> Result<(), SourceFileError> { + let destination = disk_path(root, logical)?; + let parent = destination.parent().unwrap(); + fs::create_dir_all(parent).map_err(|error| io_at(parent, error))?; + // Recheck after directory creation; an external process may replace parents. + disk_path(root, logical)?; + let permissions = match regular_file_metadata(&destination) { + Ok(metadata) => Some(metadata.permissions()), + Err(SourceFileError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let mut staged = + tempfile::NamedTempFile::new_in(parent).map_err(|error| io_at(parent, error))?; + staged + .write_all(bytes) + .map_err(|error| io_at(&destination, error))?; + // Preserve the existing file's portable permissions through replacement + // and recovery. New files retain the private temporary-file default. + if let Some(permissions) = permissions { + staged + .as_file() + .set_permissions(permissions) + .map_err(|error| io_at(&destination, error))?; + } + staged + .as_file() + .sync_all() + .map_err(|error| io_at(&destination, error))?; + staged + .persist(&destination) + .map_err(|error| io_at(&destination, error.error))?; + sync_directory(parent) +} + +fn matches_disk( + root: &Path, + logical: &str, + expected: &DiskExpectation, +) -> Result { + let path = disk_path(root, logical)?; + match expected { + DiskExpectation::Missing => match fs::symlink_metadata(&path) { + Ok(_) => Ok(false), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(io_at(&path, error)), + }, + DiskExpectation::Bytes(expected) => match read_bounded(&path, expected.len()) { + Ok(actual) => Ok(actual == *expected), + Err(SourceFileError::Limit(_)) => Ok(false), + Err(SourceFileError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => { + Ok(false) + } + Err(error) => Err(error), + }, + } +} + +fn check_conflicts( + root: &Path, + expectations: &BTreeMap, +) -> Result<(), SourceFileError> { + check_physical_aliases(root, expectations.keys())?; + let mut conflicts = Vec::new(); + for (path, expected) in expectations { + if !matches_disk(root, path, expected)? { + conflicts.push(path.clone()); + } + } + if conflicts.is_empty() { + Ok(()) + } else { + Err(SourceFileError::Conflict(conflicts)) + } +} + +/// Save an already accepted, identity-stabilized bundle. Every destination needs +/// an explicit original/Missing expectation. Detached files are never deleted. +pub fn save_project( + snapshot: &DiskProject, + compiled: &CompiledSourceProject, + limits: ProjectLimits, +) -> Result { + save_with_hook(snapshot, compiled, limits, |_| Ok(())) +} + +fn save_with_hook( + snapshot: &DiskProject, + compiled: &CompiledSourceProject, + limits: ProjectLimits, + mut after_replace: impl FnMut(usize) -> io::Result<()>, +) -> Result { + check_snapshot(snapshot, limits)?; + let checked = compile_bundle(&compiled.bundle, limits).map_err(|diagnostics| { + SourceFileError::Invalid( + diagnostics + .iter() + .map(|d| d.diagnostic.to_string()) + .collect::>() + .join("\n"), + ) + })?; + if checked.bundle != compiled.bundle + || checked.decoded.project != compiled.decoded.project + || checked.index != compiled.index + || snapshot.bundle.entry != compiled.bundle.entry + { + return Err(SourceFileError::Invalid( + "Compiled model, source, index or entry is stale.".into(), + )); + } + let compiled = &checked; + for path in compiled.bundle.files.keys() { + disk_path(&snapshot.root, path)?; + if !snapshot.expectations.contains_key(path) { + return Err(SourceFileError::UnknownExpectation(path.clone())); + } + } + check_snapshot(&saved_snapshot(snapshot, compiled), limits)?; + let journal_dir = journal_path(&snapshot.root, &snapshot.bundle.entry); + if fs::symlink_metadata(&journal_dir).is_ok() { + return Err(SourceFileError::RecoveryRequired(journal_dir)); + } + check_conflicts(&snapshot.root, &snapshot.expectations)?; + let mut entries = snapshot.expectations.iter().map(|(path, before)| JournalFile { + path: path.clone(), before: matches!(before, DiskExpectation::Bytes(_)), + write: compiled.bundle.files.get(path).is_some_and(|after| !matches!(before, DiskExpectation::Bytes(bytes) if bytes == after.as_bytes())), + }).collect::>(); + // Stable dependency order, with the project entry committed last. + entries.sort_by_key(|file| (file.path == snapshot.bundle.entry, file.path.clone())); + let journal = Journal { + version: 1, + entry: snapshot.bundle.entry.clone(), + files: entries, + }; + if journal.files.len() > MAX_JOURNAL_FILES { + return Err(SourceFileError::Limit( + "Recovery is limited to 4096 files.".into(), + )); + } + if !journal.files.iter().any(|file| file.write) { + return Ok(saved_snapshot(snapshot, compiled)); + } + let metadata = serde_json::to_vec(&journal) + .map_err(|error| SourceFileError::Invalid(error.to_string()))?; + if metadata.len() > MAX_JOURNAL_BYTES { + return Err(SourceFileError::Limit( + "Recovery metadata is too large.".into(), + )); + } + let stage = tempfile::Builder::new() + .prefix(".arcadia-save-stage-") + .tempdir_in(&snapshot.root) + .map_err(|error| io_at(&snapshot.root, error))?; + for (index, file) in journal.files.iter().enumerate() { + if let DiskExpectation::Bytes(bytes) = &snapshot.expectations[&file.path] { + write_synced(&staged_path(stage.path(), index, "before"), bytes)?; + } + if file.write { + write_synced( + &staged_path(stage.path(), index, "after"), + compiled.bundle.files[&file.path].as_bytes(), + )?; + } + } + write_synced(&stage.path().join("journal.json"), &metadata)?; + sync_directory(stage.path())?; + // Publishing a complete directory makes interrupted staging harmless. + fs::rename(stage.path(), &journal_dir).map_err(|error| io_at(&journal_dir, error))?; + let _ = stage.keep(); // The renamed directory now belongs to recovery. + sync_directory(&snapshot.root)?; + let result = (|| { + let mut current = snapshot.expectations.clone(); + let mut replacements = 0; + for file in journal.files.iter().filter(|file| file.write) { + check_conflicts(&snapshot.root, ¤t)?; + let bytes = compiled.bundle.files[&file.path].as_bytes(); + atomic_write(&snapshot.root, &file.path, bytes)?; + current.insert(file.path.clone(), DiskExpectation::Bytes(bytes.to_vec())); + replacements += 1; + after_replace(replacements) + .map_err(|error| io_at(&snapshot.root.join(&file.path), error))?; + } + check_conflicts(&snapshot.root, ¤t)?; + Ok(()) + })(); + if let Err(error) = result { + return match recover_project(&snapshot.root.join(&snapshot.bundle.entry), limits) { + Ok(_) => Err(error), + Err(recovery) => Err(SourceFileError::RecoveryFailed { + save: error.to_string(), + recovery: recovery.to_string(), + journal: journal_dir, + }), + }; + } + retire_journal(&snapshot.root, &journal_dir, &journal)?; + Ok(saved_snapshot(snapshot, compiled)) +} + +fn saved_snapshot(snapshot: &DiskProject, compiled: &CompiledSourceProject) -> DiskProject { + let mut saved = snapshot.clone(); + for (path, text) in &compiled.bundle.files { + saved.bundle.files.insert(path.clone(), text.clone()); + saved.expectations.insert( + path.clone(), + DiskExpectation::Bytes(text.as_bytes().to_vec()), + ); + } + saved.reachable = compiled.bundle.files.keys().cloned().collect(); + saved.diagnostics = compiled.diagnostics.clone(); + saved +} + +fn remove_journal(directory: &Path, journal: &Journal) -> Result<(), SourceFileError> { + // Never recursively delete an untrusted journal directory. Only validated, + // fixed-name files are removed; unexpected content keeps the directory. + for (index, file) in journal.files.iter().enumerate() { + for (present, suffix) in [(file.before, "before"), (file.write, "after")] { + if present { + let path = staged_path(directory, index, suffix); + fs::remove_file(&path).map_err(|error| io_at(&path, error))?; + } + } + } + let metadata = directory.join("journal.json"); + fs::remove_file(&metadata).map_err(|error| io_at(&metadata, error))?; + fs::remove_dir(directory).map_err(|error| io_at(directory, error)) +} + +fn retire_journal(root: &Path, directory: &Path, journal: &Journal) -> Result<(), SourceFileError> { + // Atomic retirement precedes cleanup: interruption during cleanup cannot + // leave a pending journal with half its recovery information missing. + let completed = root.join(format!(".arcadia-save-completed-{}", crate::Id::new_v4())); + fs::rename(directory, &completed).map_err(|error| io_at(directory, error))?; + // Cleanup failures leave a uniquely named completed directory, never a + // misleading pending transaction. It is safe to remove that directory later. + let _ = sync_directory(root); + let _ = remove_journal(&completed, journal); + Ok(()) +} + +/// Explicit interrupted-save recovery. All original/written bytes are checked +/// before rollback, and each restoration rechecks its target. External changes +/// are never knowingly overwritten. `false` means there was no pending journal. +pub fn recover_project(entry: &Path, limits: ProjectLimits) -> Result { + recover_with_hook(entry, limits, |_| Ok(())) +} + +fn recover_with_hook( + entry: &Path, + limits: ProjectLimits, + mut after_restore: impl FnMut(usize) -> io::Result<()>, +) -> Result { + let (root, entry) = root_entry(entry)?; + let directory = journal_path(&root, &entry); + match fs::symlink_metadata(&directory) { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(io_at(&directory, error)), + Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => { + return Err(SourceFileError::Path( + "Recovery journal is not a real directory.".into(), + )); + } + Ok(_) => {} + } + if directory + .canonicalize() + .map_err(|error| io_at(&directory, error))? + .parent() + != Some(root.as_path()) + { + return Err(SourceFileError::Path( + "Recovery directory escapes project root.".into(), + )); + } + let metadata_path = directory.join("journal.json"); + let read_journal_file = |path: &Path, budget| { + if fs::symlink_metadata(path) + .map_err(|error| io_at(path, error))? + .file_type() + .is_symlink() + { + return Err(SourceFileError::Path("Recovery file is a link.".into())); + } + read_bounded(path, budget) + }; + let bytes = read_journal_file(&metadata_path, MAX_JOURNAL_BYTES)?; + let journal: Journal = serde_json::from_slice(&bytes) + .map_err(|error| SourceFileError::Invalid(format!("Invalid recovery journal: {error}")))?; + if journal.version != 1 || journal.entry != entry || journal.files.len() > limits.max_files { + return Err(SourceFileError::Invalid( + "Recovery journal version, entry or file budget is invalid.".into(), + )); + } + let mut original = BTreeMap::new(); + let mut written = BTreeMap::new(); + let mut folded = BTreeSet::new(); + let (mut before_bytes, mut after_bytes) = (0usize, 0usize); + let mut allowed = BTreeSet::from(["journal.json".to_string()]); + for (index, file) in journal.files.iter().enumerate() { + disk_path(&root, &file.path)?; + if !folded.insert(file.path.to_lowercase()) { + return Err(SourceFileError::Path("Duplicate recovery paths.".into())); + } + let before = if file.before { + let bytes = read_journal_file( + &staged_path(&directory, index, "before"), + limits.model.max_source_bytes.saturating_sub(before_bytes), + )?; + before_bytes += bytes.len(); + allowed.insert(format!("{index}.before")); + DiskExpectation::Bytes(bytes) + } else { + DiskExpectation::Missing + }; + if file.write { + let bytes = read_journal_file( + &staged_path(&directory, index, "after"), + limits.model.max_source_bytes.saturating_sub(after_bytes), + )?; + after_bytes += bytes.len(); + allowed.insert(format!("{index}.after")); + written.insert(file.path.clone(), DiskExpectation::Bytes(bytes)); + } + original.insert(file.path.clone(), before); + } + if !original.contains_key(&entry) { + return Err(SourceFileError::Invalid( + "Recovery journal has no entry expectation.".into(), + )); + } + for file in fs::read_dir(&directory).map_err(|error| io_at(&directory, error))? { + let file = file.map_err(|error| io_at(&directory, error))?; + if !file + .file_name() + .to_str() + .is_some_and(|name| allowed.contains(name)) + { + return Err(SourceFileError::Invalid( + "Recovery directory contains unexpected files.".into(), + )); + } + } + let mut current = BTreeMap::new(); + let mut conflicts = Vec::new(); + for (path, before) in &original { + if matches_disk(&root, path, before)? { + current.insert(path.clone(), before.clone()); + } else if let Some(after) = written.get(path) + && matches_disk(&root, path, after)? + { + current.insert(path.clone(), after.clone()); + } else { + conflicts.push(path.clone()); + } + } + if !conflicts.is_empty() { + return Err(SourceFileError::Conflict(conflicts)); + } + // Reverse save order; a second recovery can continue after interruption. + let mut restored = 0; + for file in journal.files.iter().rev().filter(|file| file.write) { + check_conflicts(&root, ¤t)?; + if current[&file.path] == original[&file.path] { + continue; + } + match &original[&file.path] { + DiskExpectation::Bytes(bytes) => atomic_write(&root, &file.path, bytes)?, + DiskExpectation::Missing => { + let path = disk_path(&root, &file.path)?; + fs::remove_file(&path).map_err(|error| io_at(&path, error))?; + sync_directory(path.parent().unwrap())?; + } + } + current.insert(file.path.clone(), original[&file.path].clone()); + restored += 1; + after_restore(restored).map_err(|error| io_at(&directory, error))?; + } + retire_journal(&root, &directory, &journal)?; + Ok(true) +} + +#[cfg(test)] +mod transaction_tests { + use super::*; + + fn fixture() -> (tempfile::TempDir, DiskProject, CompiledSourceProject) { + let directory = tempfile::tempdir().unwrap(); + fs::write( + directory.path().join("project.arcadia"), + "arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", + ) + .unwrap(); + fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap(); + fs::write(directory.path().join("b.arcadia"), "oa_activity b \"B\" {}").unwrap(); + let snapshot = load_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default(), + ) + .unwrap(); + let compiled = + compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap(); + (directory, snapshot, compiled) + } + + #[test] + fn partial_save_failures_restore_every_original_and_write_entry_last() { + for stop in 1..=3 { + let (directory, snapshot, compiled) = fixture(); + let error = save_with_hook(&snapshot, &compiled, ProjectLimits::default(), |count| { + if count < 3 { + assert!( + matches_disk( + &snapshot.root, + &snapshot.bundle.entry, + &snapshot.expectations[&snapshot.bundle.entry] + ) + .unwrap() + ); + } + if count == stop { + Err(io::Error::other("injected replacement failure")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert!(matches!(error, SourceFileError::Io { .. }), "{error}"); + check_conflicts(&snapshot.root, &snapshot.expectations).unwrap(); + assert!( + !recover_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default() + ) + .unwrap() + ); + } + } + + #[test] + fn interrupted_save_and_interrupted_recovery_can_both_be_resumed() { + let (directory, snapshot, compiled) = fixture(); + let interrupted = std::panic::catch_unwind(|| { + let _ = save_with_hook(&snapshot, &compiled, ProjectLimits::default(), |count| { + assert!(count < 3, "simulated process interruption"); + Ok(()) + }); + }); + assert!(interrupted.is_err()); + assert!( + load_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default() + ) + .unwrap() + .diagnostics + .iter() + .any(|d| d.diagnostic.code == "pending_save") + ); + assert!(matches!( + save_project(&snapshot, &compiled, ProjectLimits::default()), + Err(SourceFileError::RecoveryRequired(_)) + )); + let entry = directory.path().join("project.arcadia"); + let interrupted = recover_with_hook(&entry, ProjectLimits::default(), |_| { + Err(io::Error::other("recovery interrupted")) + }); + assert!(interrupted.is_err()); + assert!(recover_project(&entry, ProjectLimits::default()).unwrap()); + check_conflicts(&snapshot.root, &snapshot.expectations).unwrap(); + assert!(!recover_project(&entry, ProjectLimits::default()).unwrap()); + } + + #[test] + fn rollback_refuses_external_edits_even_in_an_unchanged_dependency() { + let (directory, mut snapshot, initial) = fixture(); + snapshot = save_project(&snapshot, &initial, ProjectLimits::default()).unwrap(); + let mut bundle = initial.bundle.clone(); + bundle + .files + .get_mut("a.arcadia") + .unwrap() + .push_str("\n// accepted change\n"); + let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let error = save_with_hook(&snapshot, &compiled, ProjectLimits::default(), |_| { + fs::write(directory.path().join("b.arcadia"), "// external edit").unwrap(); + Err(io::Error::other("injected failure after external edit")) + }) + .unwrap_err(); + assert!(matches!(error, SourceFileError::RecoveryFailed { .. })); + assert_eq!( + fs::read_to_string(directory.path().join("b.arcadia")).unwrap(), + "// external edit" + ); + assert_eq!( + fs::read_to_string(directory.path().join("a.arcadia")).unwrap(), + compiled.bundle.files["a.arcadia"] + ); + fs::write( + directory.path().join("b.arcadia"), + &initial.bundle.files["b.arcadia"], + ) + .unwrap(); + assert!( + recover_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default() + ) + .unwrap() + ); + check_conflicts(&snapshot.root, &snapshot.expectations).unwrap(); + } + + #[test] + fn recovery_rechecks_external_changes_between_restorations() { + let (directory, snapshot, compiled) = fixture(); + let _ = std::panic::catch_unwind(|| { + let _ = save_with_hook(&snapshot, &compiled, ProjectLimits::default(), |count| { + assert!(count < 3, "interrupted after all replacements"); + Ok(()) + }); + }); + let entry = directory.path().join("project.arcadia"); + let error = recover_with_hook(&entry, ProjectLimits::default(), |_| { + fs::write( + directory.path().join("b.arcadia"), + "// external edit during recovery", + ) + .unwrap(); + Ok(()) + }) + .unwrap_err(); + assert!(matches!(error, SourceFileError::Conflict(_))); + assert_eq!( + fs::read_to_string(directory.path().join("b.arcadia")).unwrap(), + "// external edit during recovery" + ); + assert!(journal_path(&snapshot.root, &snapshot.bundle.entry).exists()); + fs::write( + directory.path().join("b.arcadia"), + &compiled.bundle.files["b.arcadia"], + ) + .unwrap(); + assert!(recover_project(&entry, ProjectLimits::default()).unwrap()); + check_conflicts(&snapshot.root, &snapshot.expectations).unwrap(); + } + + #[test] + fn rollback_removes_only_our_new_file_and_retains_detached_files() { + let (directory, mut snapshot, _) = fixture(); + fs::write(directory.path().join("detached.arcadia"), "// retained").unwrap(); + let mut bundle = snapshot.reachable_bundle(); + let source = bundle.files.get_mut("project.arcadia").unwrap(); + *source = source.replacen( + "include \"a.arcadia\";", + "include \"new/first.arcadia\"; include \"a.arcadia\";", + 1, + ); + bundle + .files + .insert("new/first.arcadia".into(), "oa_activity c \"C\" {}".into()); + expect_new_file(&mut snapshot, "new/first.arcadia", ProjectLimits::default()).unwrap(); + let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let error = save_with_hook(&snapshot, &compiled, ProjectLimits::default(), |count| { + if count == 3 { + Err(io::Error::other("after new file")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert!(matches!(error, SourceFileError::Io { .. })); + assert!(!directory.path().join("new/first.arcadia").exists()); + assert_eq!( + fs::read_to_string(directory.path().join("detached.arcadia")).unwrap(), + "// retained" + ); + check_conflicts(&snapshot.root, &snapshot.expectations).unwrap(); + } + + #[test] + fn recovery_rejects_untrusted_escape_paths_before_touching_files() { + let (directory, snapshot, compiled) = fixture(); + let _ = std::panic::catch_unwind(|| { + let _ = save_with_hook(&snapshot, &compiled, ProjectLimits::default(), |_| { + panic!("stop") + }); + }); + let journal_dir = journal_path(&snapshot.root, &snapshot.bundle.entry); + let metadata = journal_dir.join("journal.json"); + let mut journal: Journal = serde_json::from_slice(&fs::read(&metadata).unwrap()).unwrap(); + journal.files[0].path = "../outside.arcadia".into(); + fs::write(&metadata, serde_json::to_vec(&journal).unwrap()).unwrap(); + assert!(matches!( + recover_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default() + ), + Err(SourceFileError::Path(_)) + )); + assert!(journal_dir.exists()); + } +} diff --git a/crates/oa-core/tests/source_files.rs b/crates/oa-core/tests/source_files.rs new file mode 100644 index 00000000..d7f02260 --- /dev/null +++ b/crates/oa-core/tests/source_files.rs @@ -0,0 +1,385 @@ +use oa_core::{source_files::*, source_project::*}; +use std::{collections::BTreeMap, fs}; + +fn setup() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + fs::write( + directory.path().join("project.arcadia"), + "arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n", + ) + .unwrap(); + fs::create_dir(directory.path().join("parts")).unwrap(); + fs::write( + directory.path().join("parts/a.arcadia"), + "// rationale\noa_activity a \"A\" {}\n", + ) + .unwrap(); + directory +} + +#[test] +fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() { + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + let snapshot = load_project(&entry, ProjectLimits::default()).unwrap(); + assert!( + snapshot + .diagnostics + .iter() + .all(|d| d.diagnostic.severity != oa_core::validation::Severity::Error) + ); + let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap(); + assert_ne!(compiled.bundle, snapshot.bundle); + for (path, expected) in &snapshot.expectations { + assert_eq!( + *expected, + DiskExpectation::Bytes(fs::read(directory.path().join(path)).unwrap()) + ); + } + let saved = save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap(); + assert_eq!(saved.bundle, compiled.bundle); + let reopened = load_project(&entry, ProjectLimits::default()).unwrap(); + assert_eq!(reopened.bundle, compiled.bundle); + assert_eq!( + compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default()) + .unwrap() + .decoded + .project, + compiled.decoded.project + ); + assert_eq!( + save_project(&saved, &compiled, ProjectLimits::default()) + .unwrap() + .bundle, + compiled.bundle + ); +} + +#[test] +fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() { + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap(); + fs::write( + directory.path().join("parts/a.arcadia"), + "oa_activity a \"A\" { invalid", + ) + .unwrap(); + let snapshot = load_project(&entry, ProjectLimits::default()).unwrap(); + assert_eq!(snapshot.bundle.files.len(), 2); + assert_eq!( + snapshot.expectations["missing.arcadia"], + DiskExpectation::Missing + ); + assert!( + snapshot + .diagnostics + .iter() + .any(|d| d.path == "project.arcadia" + && d.diagnostic.line == 2 + && d.diagnostic.code == "missing_include") + ); + assert!( + snapshot + .diagnostics + .iter() + .any(|d| d.path == "parts/a.arcadia") + ); + assert!(snapshot.bundle.files["parts/a.arcadia"].ends_with("invalid")); +} + +#[test] +fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() { + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + let original = load_project(&entry, ProjectLimits::default()).unwrap(); + fs::write(directory.path().join("parts/a.arcadia"), "// external edit").unwrap(); + fs::write( + directory.path().join("new.arcadia"), + "oa_activity b \"B\" {}", + ) + .unwrap(); + let overlays = BTreeMap::from([ + ( + "project.arcadia".into(), + "arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(), + ), + ( + "parts/a.arcadia".into(), + "// unfinished detached draft\noa_activity".into(), + ), + ]); + let discovered = discover_missing(&original, &overlays, ProjectLimits::default()).unwrap(); + assert_eq!( + discovered.bundle.files["parts/a.arcadia"], + overlays["parts/a.arcadia"] + ); + assert_eq!( + discovered.expectations["parts/a.arcadia"], + original.expectations["parts/a.arcadia"] + ); + assert!(discovered.bundle.files.contains_key("new.arcadia")); + assert!( + !discovered + .reachable_bundle() + .files + .contains_key("parts/a.arcadia") + ); + assert!( + discovered + .diagnostics + .iter() + .all(|d| d.diagnostic.severity != oa_core::validation::Severity::Error) + ); +} + +#[test] +fn unchanged_dependency_edits_and_deletions_block_save_before_any_write() { + for deletion in [false, true] { + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + let snapshot = load_project(&entry, ProjectLimits::default()).unwrap(); + let compiled = + compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap(); + let snapshot = save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap(); + let mut bundle = compiled.bundle.clone(); + bundle + .files + .get_mut("project.arcadia") + .unwrap() + .push_str("// changed\n"); + let changed = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let child = directory.path().join("parts/a.arcadia"); + if deletion { + fs::remove_file(&child).unwrap(); + } else { + fs::write(&child, "// another editor").unwrap(); + } + assert!(matches!( + save_project(&snapshot, &changed, ProjectLimits::default()), + Err(SourceFileError::Conflict(_)) + )); + assert_eq!( + fs::read_to_string(&entry).unwrap(), + compiled.bundle.files["project.arcadia"] + ); + } +} + +#[test] +fn new_destinations_require_explicit_missing_expectations_and_reject_collisions() { + let directory = tempfile::tempdir().unwrap(); + let entry = directory.path().join("project.arcadia"); + let mut snapshot = new_project(&entry, ProjectLimits::default()).unwrap(); + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ( + "project.arcadia".into(), + "arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(), + ), + ("child.arcadia".into(), "oa_activity a \"A\" {}".into()), + ]), + }; + let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + assert!(matches!( + save_project(&snapshot, &compiled, ProjectLimits::default()), + Err(SourceFileError::UnknownExpectation(_)) + )); + expect_new_file(&mut snapshot, "child.arcadia", ProjectLimits::default()).unwrap(); + fs::write( + directory.path().join("child.arcadia"), + "// pre-existing work", + ) + .unwrap(); + assert!(matches!( + save_project(&snapshot, &compiled, ProjectLimits::default()), + Err(SourceFileError::Conflict(_)) + )); + assert!(!entry.exists()); + assert!(expect_new_file(&mut snapshot, "child.arcadia", ProjectLimits::default()).is_err()); +} + +#[test] +fn aggregate_budgets_and_portable_paths_are_checked_before_loading_excess_data() { + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + let mut limits = ProjectLimits { + max_files: 1, + ..ProjectLimits::default() + }; + let snapshot = load_project(&entry, limits).unwrap(); + assert_eq!(snapshot.bundle.files.len(), 1); + assert!( + snapshot + .diagnostics + .iter() + .any(|d| d.diagnostic.code == "project_limit") + ); + limits.max_files = 256; + limits.model.max_source_bytes = fs::metadata(&entry).unwrap().len() as usize + 5; + let snapshot = load_project(&entry, limits).unwrap(); + assert_eq!(snapshot.bundle.files.len(), 1); + assert!( + snapshot + .diagnostics + .iter() + .any(|d| d.diagnostic.code == "source_limit") + ); + let mut snapshot = new_project( + &directory.path().join("new.arcadia"), + ProjectLimits::default(), + ) + .unwrap(); + for invalid in [ + "../escape.arcadia", + "a\\b.arcadia", + "CON.arcadia", + "/absolute.arcadia", + "a/../b.arcadia", + ".arcadia-save-project.arcadia.journal/intrusion.arcadia", + ] { + assert!( + expect_new_file(&mut snapshot, invalid, ProjectLimits::default()).is_err(), + "{invalid}" + ); + } +} + +#[test] +fn physical_hard_link_aliases_are_rejected() { + let directory = setup(); + fs::hard_link( + directory.path().join("parts/a.arcadia"), + directory.path().join("alias.arcadia"), + ) + .unwrap(); + fs::write( + directory.path().join("project.arcadia"), + "arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }", + ) + .unwrap(); + assert!(matches!( + load_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default() + ), + Err(SourceFileError::Path(_)) + )); +} + +#[cfg(unix)] +#[test] +fn symlinks_to_outside_or_inside_project_are_rejected() { + let directory = setup(); + let outside = tempfile::tempdir().unwrap(); + fs::write( + outside.path().join("outside.arcadia"), + "oa_activity outside \"Outside\" {}", + ) + .unwrap(); + for destination in [ + outside.path().join("outside.arcadia"), + directory.path().join("parts/a.arcadia"), + ] { + let link = directory.path().join("alias.arcadia"); + std::os::unix::fs::symlink(&destination, &link).unwrap(); + fs::write( + directory.path().join("project.arcadia"), + "arcadia 3; project p \"P\" { include \"alias.arcadia\"; }", + ) + .unwrap(); + let loaded = load_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default(), + ) + .unwrap(); + assert!( + loaded + .diagnostics + .iter() + .any(|d| d.diagnostic.code == "invalid_path") + ); + assert!(!loaded.bundle.files.contains_key("alias.arcadia")); + fs::remove_file(&link).unwrap(); + } +} + +#[test] +fn forged_compiled_model_or_index_cannot_be_saved() { + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + let snapshot = load_project(&entry, ProjectLimits::default()).unwrap(); + let mut compiled = + compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap(); + compiled.decoded.project.name = "forged".into(); + assert!(matches!( + save_project(&snapshot, &compiled, ProjectLimits::default()), + Err(SourceFileError::Invalid(_)) + )); + assert_eq!( + fs::read(&entry).unwrap(), + snapshot.bundle.files["project.arcadia"].as_bytes() + ); +} + +#[cfg(unix)] +#[test] +fn named_pipes_are_rejected_before_opening_on_load_discovery_and_save() { + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + let child = directory.path().join("parts/a.arcadia"); + let snapshot = load_project(&entry, ProjectLimits::default()).unwrap(); + let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap(); + fs::remove_file(&child).unwrap(); + assert!( + std::process::Command::new("mkfifo") + .arg(&child) + .status() + .unwrap() + .success() + ); + let loaded = load_project(&entry, ProjectLimits::default()).unwrap(); + assert!( + loaded + .diagnostics + .iter() + .any(|d| d.diagnostic.code == "invalid_path" + && d.diagnostic.message.contains("not a regular file")) + ); + assert!(!loaded.bundle.files.contains_key("parts/a.arcadia")); + assert!(matches!( + discover_missing(&snapshot, &BTreeMap::new(), ProjectLimits::default()), + Err(SourceFileError::Path(_)) + )); + assert!(matches!( + save_project(&snapshot, &compiled, ProjectLimits::default()), + Err(SourceFileError::Path(_)) + )); + assert_eq!( + fs::read_to_string(&entry).unwrap(), + snapshot.bundle.files["project.arcadia"] + ); +} + +#[cfg(unix)] +#[test] +fn atomic_save_preserves_existing_unix_permissions() { + use std::os::unix::fs::PermissionsExt; + let directory = setup(); + let entry = directory.path().join("project.arcadia"); + let child = directory.path().join("parts/a.arcadia"); + fs::set_permissions(&entry, fs::Permissions::from_mode(0o664)).unwrap(); + fs::set_permissions(&child, fs::Permissions::from_mode(0o640)).unwrap(); + let snapshot = load_project(&entry, ProjectLimits::default()).unwrap(); + let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap(); + save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap(); + assert_eq!( + fs::metadata(&entry).unwrap().permissions().mode() & 0o777, + 0o664 + ); + assert_eq!( + fs::metadata(&child).unwrap().permissions().mode() & 0o777, + 0o640 + ); +}