386 lines
13 KiB
Rust
386 lines
13 KiB
Rust
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 5; 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 5; 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 5; 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 5; 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 5; 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 5; 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
|
|
);
|
|
}
|