Save modular source projects with conflict checks and recoverable transactions
This commit is contained in:
@@ -10,3 +10,4 @@
|
||||
*.exe
|
||||
/model-store/
|
||||
/.local/
|
||||
.arcadia-save-*/
|
||||
|
||||
Generated
+1
@@ -2143,6 +2143,7 @@ dependencies = [
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -11,3 +11,4 @@ serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user