diff --git a/nash-observe/src/lib.rs b/nash-observe/src/lib.rs index c95aa9f..87d0a3b 100644 --- a/nash-observe/src/lib.rs +++ b/nash-observe/src/lib.rs @@ -398,6 +398,30 @@ fn read_back(record: &brush_core::gate::RedirectRecord) -> Option<(u64, Vec, Some((total, buf, total > n as u64)) } +/// Resolve a redirection target against the directory its command ran in, so the +/// reported path is meaningful to a host that never saw that cwd. Purely lexical +/// (`.`/`..` collapsed, no symlink resolution, no filesystem access): the target of a +/// truncating write may not exist yet, and observation must never stat its way into a +/// slow or hanging path. +fn absolute_path(path: &Path, cwd: &Path) -> PathBuf { + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + let mut out = PathBuf::new(); + for component in joined.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + /// Build a preview string (redacted, base64) from captured bytes. Binary data is /// previewed as a hex head rather than mangled UTF-8. fn preview_b64(data: &[u8]) -> String { @@ -502,6 +526,14 @@ impl brush_core::gate::Gate for RecordingGate { // Data-flow read-back for this command's redirects (docs/NASH.md §5.2). for record in &pending.redirects { + // The host resolves a redirect target against the session's worktree to decide + // which file was edited (and which lock that is), so report the path absolutely: + // `> out.txt` is only meaningful next to the directory the command ran in, and the + // redirect event — unlike `exec` — carries no cwd of its own. + let target = record + .path + .as_ref() + .map(|p| absolute_path(p, &pending.cwd).to_string_lossy().into_owned()); let Some((total, data, truncated)) = read_back(record) else { // Special file / unreadable — emit metadata only. obs.send(Event::Redirect { @@ -510,7 +542,7 @@ impl brush_core::gate::Gate for RecordingGate { cmd_seq: exec_seq, op: record.op.clone(), fd: record.fd, - target: record.path.as_ref().map(|p| p.to_string_lossy().into_owned()), + target, bytes: 0, truncated: false, hash: String::new(), @@ -524,7 +556,7 @@ impl brush_core::gate::Gate for RecordingGate { cmd_seq: exec_seq, op: record.op.clone(), fd: record.fd, - target: record.path.as_ref().map(|p| p.to_string_lossy().into_owned()), + target, bytes: total, truncated, hash: fnv1a_hex(&data), diff --git a/nash/tests/observe.rs b/nash/tests/observe.rs index e95978a..fc02e3d 100644 --- a/nash/tests/observe.rs +++ b/nash/tests/observe.rs @@ -384,6 +384,12 @@ fn redirect_readback_captures_each_operator() { let trunc = redirs.iter().find(|e| e["op"] == ">").expect("truncate redirect"); assert_eq!(decode_preview(trunc), "first\n"); assert_eq!(trunc["bytes"], 6); + // The target is reported ABSOLUTE (docs/NASH.md §5.2). A redirect event carries no cwd of its + // own, and the host maps the path to a worktree file — and therefore to a file lock — so a bare + // `d.txt` would be unresolvable there. `run_nash` runs in a fresh temp workspace. + let target = trunc["target"].as_str().expect("redirect target"); + assert!(target.starts_with('/'), "target not absolute: {target}"); + assert!(target.ends_with("/d.txt"), "target lost its file name: {target}"); let append = redirs.iter().find(|e| e["op"] == ">>").expect("append redirect"); // Append captures ONLY the added bytes, not the whole file.