Reject unreopenable source before checked save stages files
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
//! Containment while the complete source8 adapter remains unavailable.
|
||||
use super::*;
|
||||
use crate::analysis::{Definition, DefinitionBody, QuantityAlgebra, QuantityKindDefinition};
|
||||
use crate::analysis_numeric::Dimension;
|
||||
use std::fs;
|
||||
|
||||
const ORDINARY_SOURCE7: &str = concat!(
|
||||
"arcadia 7;\n",
|
||||
"project saved \"Saved model\" id \"4210b9f2-7046-47f0-aa3b-153f626a579d\" {\n",
|
||||
" description \"Retain café and exact identity\";\n",
|
||||
"}",
|
||||
);
|
||||
|
||||
fn ordinary() -> Project {
|
||||
let decoded = decode_with_report(ORDINARY_SOURCE7.as_bytes()).unwrap();
|
||||
assert!(decoded.migration.is_some());
|
||||
decoded.project
|
||||
}
|
||||
|
||||
fn directory_entries(path: &Path) -> Vec<std::ffi::OsString> {
|
||||
let mut names = fs::read_dir(path)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
// A missing parent makes a mistakenly early NamedTempFile creation observable
|
||||
// as Io, even when the temp would otherwise be removed again on error.
|
||||
fn rejected_output(
|
||||
project: &Project,
|
||||
output: &[u8],
|
||||
profile: json_codec::Profile,
|
||||
check: impl Fn(&FileError),
|
||||
) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let existing = root.path().join("existing.arcadia");
|
||||
let sentinel = b"external bytes must survive\r\n";
|
||||
fs::write(&existing, sentinel).unwrap();
|
||||
for path in [
|
||||
existing.clone(),
|
||||
root.path().join("new.arcadia"),
|
||||
root.path().join("missing-parent/new.arcadia"),
|
||||
] {
|
||||
let before = directory_entries(root.path());
|
||||
let expected = (path == existing).then_some(sentinel.as_slice());
|
||||
let error =
|
||||
save_checked_profile_with_source(&path, project, expected, profile, false, |_| {
|
||||
output.to_vec()
|
||||
})
|
||||
.unwrap_err();
|
||||
check(&error);
|
||||
assert_eq!(directory_entries(root.path()), before);
|
||||
assert_eq!(fs::read(&existing).unwrap(), sentinel);
|
||||
if path != existing {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_generated_source_rejects_before_any_filesystem_staging() {
|
||||
rejected_output(
|
||||
&ordinary(),
|
||||
b"arcadia",
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| assert!(matches!(error, FileError::Text(_)), "{error}"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parseable_source_omitting_a_definition_cannot_replace_the_whole_project() {
|
||||
let mut project = ordinary();
|
||||
let id = Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap();
|
||||
project.definitions.insert(
|
||||
id,
|
||||
Definition {
|
||||
id,
|
||||
alias: "duration".into(),
|
||||
name: "Duration".into(),
|
||||
description: "Unused definitions are still authored model data.".into(),
|
||||
body: DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
|
||||
algebra: QuantityAlgebra::Linear,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_project(&project).unwrap();
|
||||
let decoded = decode(ORDINARY_SOURCE7.as_bytes()).unwrap();
|
||||
assert_eq!(decoded.id, project.id);
|
||||
assert_eq!(decoded.name, project.name);
|
||||
assert_eq!(decoded.elements, project.elements);
|
||||
assert!(decoded.definitions.is_empty());
|
||||
assert_ne!(decoded, project);
|
||||
rejected_output(
|
||||
&project,
|
||||
ORDINARY_SOURCE7.as_bytes(),
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source does not reproduce the complete requested project."),
|
||||
"{error}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_project_from_generated_legacy_source_still_rejects_migration() {
|
||||
rejected_output(
|
||||
&ordinary(),
|
||||
ORDINARY_SOURCE7.as_bytes(),
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source unexpectedly requires migration."),
|
||||
"{error}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_source_admission_checks_final_newline_under_the_effective_byte_limit() {
|
||||
let project = ordinary();
|
||||
let profile = json_codec::Profile::with_byte_limit(ORDINARY_SOURCE7.len());
|
||||
rejected_output(&project, ORDINARY_SOURCE7.as_bytes(), profile, |error| {
|
||||
assert!(matches!(error, FileError::Limit(_)), "{error}");
|
||||
});
|
||||
let mut terminated = ORDINARY_SOURCE7.as_bytes().to_vec();
|
||||
terminated.push(b'\n');
|
||||
let profile = json_codec::Profile::with_byte_limit(terminated.len());
|
||||
// Existing final newline is not added twice: decoding now reaches the
|
||||
// independent migration refusal, rather than exceeding this exact limit.
|
||||
rejected_output(&project, &terminated, profile, |error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source unexpectedly requires migration."),
|
||||
"{error}"
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user