Skip to content

Commit 2137480

Browse files
committed
fix: separate headless editor reports from document and script paths
1 parent 1ca6110 commit 2137480

8 files changed

Lines changed: 183 additions & 18 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/editor/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ thiserror.workspace = true
5757
tracing = "0.1"
5858

5959
[dev-dependencies]
60+
tempfile.workspace = true
6061
masonry_testing = { version = "0.4.0", git = "https://github.com/refpath/xilem.git", rev = "1b96eb8db3f88f85db1a3594d80d3480b29392fb" }
6162
nuif-testing = { version = "0.0.1", path = "../../crates/nuif-testing" }
6263

apps/editor/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,10 @@ File saves and exports use the shared
3939
[output replacement contract](../../crates/nuif-cli/README.md#output-replacement).
4040
A failed staged write preserves the existing destination. The document and its
4141
fidelity report remain separate file replacements.
42+
43+
The headless editor rejects a report path that resolves to a document input,
44+
output, script or expected document. Document output may replace its document
45+
input, but cannot replace the script or expected document. Path checks resolve
46+
existing ancestors and normalize missing descendants before creating report
47+
directories. Reports use staged single-file replacement. These checks do not
48+
coordinate concurrent directory changes.

apps/editor/src/main.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,44 @@ fn load_initial_document(options: &HeadlessOptions) -> Result<EditorFile, String
111111
}
112112
}
113113

114+
fn validate_output_paths(options: &HeadlessOptions) -> Result<(), String> {
115+
let identity = |path: &Path| {
116+
nuif_codec::filesystem::output_path_identity(path).map_err(|error| error.to_string())
117+
};
118+
let inputs = [
119+
Some(&options.script),
120+
options.document.as_ref(),
121+
options.expected_document.as_ref(),
122+
];
123+
if let Some(report) = &options.report {
124+
let report = identity(report)?;
125+
for path in inputs.into_iter().flatten().chain(options.output.iter()) {
126+
if report == identity(path)? {
127+
return Err(
128+
"--report must be separate from document output and input files".to_owned(),
129+
);
130+
}
131+
}
132+
}
133+
if let Some(output) = &options.output {
134+
let output = identity(output)?;
135+
for path in [Some(&options.script), options.expected_document.as_ref()]
136+
.into_iter()
137+
.flatten()
138+
{
139+
if output == identity(path)? {
140+
return Err(
141+
"--output must be separate from the script and expected document".to_owned(),
142+
);
143+
}
144+
}
145+
}
146+
Ok(())
147+
}
148+
114149
fn run() -> Result<(), String> {
115150
let options = parse_options()?;
151+
validate_output_paths(&options)?;
116152
let opened = load_initial_document(&options)?;
117153
let document = opened.document;
118154
let mut package = opened.package;
@@ -304,9 +340,9 @@ fn write_json(path: &Path, value: &impl Serialize) -> Result<(), String> {
304340
if let Some(parent) = path.parent() {
305341
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
306342
}
307-
fs::write(
343+
nuif_codec::filesystem::write_atomic(
308344
path,
309-
serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?,
345+
&serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?,
310346
)
311347
.map_err(|error| error.to_string())
312348
}

apps/editor/tests/output_paths.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use std::{fs, process::Command};
2+
3+
#[test]
4+
fn headless_report_aliases_leave_document_and_script_bytes_unchanged() {
5+
let directory = tempfile::tempdir().unwrap();
6+
let input = directory.path().join("input.nuif");
7+
let output = directory.path().join("output.nuif");
8+
let script = directory.path().join("script.jsonl");
9+
let expected = directory.path().join("expected.nuif");
10+
for path in [&input, &output, &script, &expected] {
11+
fs::write(path, b"sentinel").unwrap();
12+
}
13+
let indirect = directory
14+
.path()
15+
.join("missing")
16+
.join("..")
17+
.join("output.nuif");
18+
for report in [&input, &output, &script, &expected, &indirect] {
19+
let result = Command::new(env!("CARGO_BIN_EXE_nuif-editor"))
20+
.arg("--headless")
21+
.arg("--document")
22+
.arg(&input)
23+
.arg("--script")
24+
.arg(&script)
25+
.arg("--expect-document")
26+
.arg(&expected)
27+
.arg("--output")
28+
.arg(&output)
29+
.arg("--report")
30+
.arg(report)
31+
.output()
32+
.unwrap();
33+
assert!(!result.status.success());
34+
assert!(String::from_utf8_lossy(&result.stderr).contains("--report must be separate"));
35+
for path in [&input, &output, &script, &expected] {
36+
assert_eq!(fs::read(path).unwrap(), b"sentinel");
37+
}
38+
assert!(!directory.path().join("missing").exists());
39+
}
40+
}
41+
42+
#[test]
43+
fn headless_document_output_cannot_overwrite_the_script_or_expectation() {
44+
let directory = tempfile::tempdir().unwrap();
45+
let script = directory.path().join("script.jsonl");
46+
let expected = directory.path().join("expected.nuif");
47+
for path in [&script, &expected] {
48+
fs::write(path, b"sentinel").unwrap();
49+
}
50+
for output in [&script, &expected] {
51+
let result = Command::new(env!("CARGO_BIN_EXE_nuif-editor"))
52+
.arg("--headless")
53+
.args(["--new-document", "00000000000000000000000000000001"])
54+
.arg("--script")
55+
.arg(&script)
56+
.arg("--expect-document")
57+
.arg(&expected)
58+
.arg("--output")
59+
.arg(output)
60+
.output()
61+
.unwrap();
62+
assert!(!result.status.success());
63+
assert!(String::from_utf8_lossy(&result.stderr).contains("--output must be separate"));
64+
for path in [&script, &expected] {
65+
assert_eq!(fs::read(path).unwrap(), b"sentinel");
66+
}
67+
}
68+
}

crates/nuif-cli/src/main.rs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -631,20 +631,8 @@ fn output_identity(path: &str) -> Result<PathBuf, CliError> {
631631
if path == "-" {
632632
return Ok(PathBuf::from("-"));
633633
}
634-
let path = Path::new(path);
635-
if let Ok(canonical) = fs::canonicalize(path) {
636-
return Ok(canonical);
637-
}
638-
let parent = path
639-
.parent()
640-
.filter(|parent| !parent.as_os_str().is_empty())
641-
.unwrap_or(Path::new("."));
642-
let parent = fs::canonicalize(parent)
643-
.map_err(|error| CliError::new(2, "ARGUMENT_INVALID", error.to_string()))?;
644-
let name = path
645-
.file_name()
646-
.ok_or_else(|| CliError::new(2, "ARGUMENT_INVALID", "output requires a filename"))?;
647-
Ok(parent.join(name))
634+
nuif_codec::filesystem::output_path_identity(Path::new(path))
635+
.map_err(|error| CliError::new(2, "ARGUMENT_INVALID", error.to_string()))
648636
}
649637

650638
fn export_arguments(args: &[String]) -> (&str, &str, Option<&str>) {

crates/nuif-codec/src/filesystem.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,49 @@
22
33
use std::fs::{self, File};
44
use std::io::{self, Write as _};
5-
use std::path::Path;
5+
use std::path::{Component, Path, PathBuf};
6+
7+
/// Resolves an output path through its nearest existing ancestor.
8+
///
9+
/// Missing descendants are normalized lexically so a report path can be checked
10+
/// before its parent directory is created. This does not lock directory entries.
11+
///
12+
/// # Errors
13+
///
14+
/// Returns an error when the current directory or an existing ancestor cannot
15+
/// be resolved.
16+
pub fn output_path_identity(path: &Path) -> io::Result<PathBuf> {
17+
let absolute = std::path::absolute(path)?;
18+
let mut ancestor = absolute.as_path();
19+
let mut suffix = Vec::new();
20+
loop {
21+
match fs::canonicalize(ancestor) {
22+
Ok(mut resolved) => {
23+
for component in suffix.into_iter().rev() {
24+
match component {
25+
Component::ParentDir => {
26+
resolved.pop();
27+
}
28+
Component::CurDir => {}
29+
other => resolved.push(other.as_os_str()),
30+
}
31+
}
32+
return Ok(resolved);
33+
}
34+
Err(error) if error.kind() == io::ErrorKind::NotFound => {
35+
let component = ancestor
36+
.components()
37+
.next_back()
38+
.ok_or_else(|| io::Error::other("output path has no existing ancestor"))?;
39+
suffix.push(component);
40+
ancestor = ancestor
41+
.parent()
42+
.ok_or_else(|| io::Error::other("output path has no existing ancestor"))?;
43+
}
44+
Err(error) => return Err(error),
45+
}
46+
}
47+
}
648

749
/// Writes bytes to a sibling temporary file, then replaces the destination.
850
///
@@ -64,6 +106,27 @@ fn write_atomic_with(
64106
mod tests {
65107
use super::*;
66108

109+
#[test]
110+
fn output_identity_resolves_missing_parent_aliases_without_creating_them() {
111+
let directory = tempfile::tempdir().unwrap();
112+
let direct = directory.path().join("document.nuif");
113+
let indirect = directory
114+
.path()
115+
.join("missing")
116+
.join("..")
117+
.join("document.nuif");
118+
assert_eq!(
119+
output_path_identity(&direct).unwrap(),
120+
output_path_identity(&indirect).unwrap()
121+
);
122+
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 0);
123+
fs::write(&direct, b"existing").unwrap();
124+
assert_eq!(
125+
output_path_identity(&direct).unwrap(),
126+
output_path_identity(&indirect).unwrap()
127+
);
128+
}
129+
67130
#[test]
68131
fn failed_staged_write_preserves_existing_file_and_cleans_temporary_file() {
69132
let directory = tempfile::tempdir().unwrap();

docs/BETA.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ planning, a post-import fixpoint, a second edit, byte locality, structural-edit
6363
refusal and stale-span refusal. The report records case dimensions, document
6464
hashes, source size, checks, revision and environment. Separate CLI subprocess
6565
tests check in-place synchronization, unsupported-edit preservation and
66-
report-path alias rejection. Results are written to
66+
report-path alias rejection. Headless editor subprocess tests reject report,
67+
script and expected-document collisions before reading or writing documents. Results are written to
6768
`target/source-workflow-report.json` and archived by CI.
6869

6970
This is a generated regression corpus. Its dimensions are selected by the

0 commit comments

Comments
 (0)