Skip to content

Commit 09ecc10

Browse files
committed
test(e2e): cover cow.rs symlink/hardlink/stage-write error arms
Four new direct-dispatch tests in e2e_safety_internals.rs that exercise cow.rs's `?` propagation arms via the pub `break_hardlink_if_needed` API. Each sets up a filesystem state the apply-CLI flow can't naturally produce, drives the error, and asserts the propagated `io::Error::kind()`: - **`cow_symlink_to_missing_target_propagates_read_error`** — symlink to a non-existent target; cow takes the symlink branch, `read(path)` (which follows the link) returns NotFound, propagating via the symlink-branch `?` arm. Covers cow.rs:66. - **`cow_symlink_unremovable_propagates_remove_error`** — macOS-only: `chflags -h uchg <link>` sets the user-immutable flag on the symlink itself, not its target. `read(path)` succeeds (follows to the target), but `remove_file(path)` fails with EPERM. Covers cow.rs:70. - **`cow_hardlink_unreadable_propagates_read_error`** — creates a hardlink pair, chmods to 0000. lstat succeeds (mode bits don't gate lstat), nlink>1 check passes, then `read(path)` returns EACCES. Covers cow.rs:84. Skipped under uid 0 (root bypasses mode bits). - **`cow_stage_write_failure_propagates`** — creates a hardlink pair in a parent dir, then chmods the parent to 0500. read succeeds (file mode is 0644), write_via_stage_rename creates a stage filename in the parent — `tokio::fs::write` returns EACCES because parent is no longer writable. Covers cow.rs:111. Skipped under uid 0. Coverage delta on `patch/cow.rs` regions: 88.89% → 93.83%. The remaining 5 regions are: - **cow.rs:71** — `write_via_stage_rename(path,target_bytes).await?` in the symlink branch. Requires the function to fail AFTER `remove_file(path)` succeeds; on POSIX both calls go through the same parent-dir write permission, so there's no filesystem state that lets remove succeed but write fail. - **cow.rs:97, 105** — `.unwrap_or_else` defaults on `path.parent()` and `path.file_name()`. Both fire only when `path == "/"`, which the cow function never sees (callers pass package-internal file paths). - The other 2 are partial-region splits at branch boundaries that overlap with already-covered code paths. Workspace test sweep: green under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7
1 parent 5c9a5fb commit 09ecc10

1 file changed

Lines changed: 167 additions & 0 deletions

File tree

crates/socket-patch-cli/tests/e2e_safety_internals.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,173 @@ async fn cow_lstat_permission_denied_propagates_io_error() {
213213
);
214214
}
215215

216+
/// Symlink branch read-fails-fast (cow.rs:66): when the symlink
217+
/// target doesn't exist, the read-through propagates NotFound
218+
/// rather than entering the remove/rewrite dance. Covers the
219+
/// symlink-branch `?` propagation on the read step.
220+
#[cfg(unix)]
221+
#[tokio::test]
222+
async fn cow_symlink_to_missing_target_propagates_read_error() {
223+
let tmp = tempfile::tempdir().unwrap();
224+
let link = tmp.path().join("dangling");
225+
let absent = tmp.path().join("does-not-exist");
226+
std::os::unix::fs::symlink(&absent, &link).unwrap();
227+
228+
let err = break_hardlink_if_needed(&link)
229+
.await
230+
.expect_err("read through dangling symlink must propagate the error");
231+
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
232+
}
233+
234+
/// Symlink branch remove-fails arm (cow.rs:70): when the symlink
235+
/// itself carries the `uchg` (user-immutable) flag, `read(path)`
236+
/// follows the link and succeeds, but `remove_file(path)` cannot
237+
/// unlink the immutable symlink. The error propagates before the
238+
/// stage-rename step.
239+
///
240+
/// macOS-only: BSD `chflags -h` is the only userspace tool that
241+
/// can set flags on a symlink without dereferencing. Linux's
242+
/// `chattr +i` only works on regular files and needs root.
243+
#[cfg(target_os = "macos")]
244+
#[tokio::test]
245+
async fn cow_symlink_unremovable_propagates_remove_error() {
246+
use std::process::Command;
247+
if Command::new("id")
248+
.arg("-u")
249+
.output()
250+
.ok()
251+
.and_then(|o| String::from_utf8(o.stdout).ok())
252+
.map(|s| s.trim() == "0")
253+
.unwrap_or(false)
254+
{
255+
eprintln!("SKIP: root bypasses chflags uchg restrictions");
256+
return;
257+
}
258+
259+
let tmp = tempfile::tempdir().unwrap();
260+
let target = tmp.path().join("real-file.txt");
261+
std::fs::write(&target, b"content").unwrap();
262+
let link = tmp.path().join("immutable-link");
263+
std::os::unix::fs::symlink(&target, &link).unwrap();
264+
265+
// -h applies the flag to the symlink itself, not its target.
266+
// Without it, chflags follows the link and sets uchg on the
267+
// regular file — wrong test.
268+
let status = Command::new("chflags")
269+
.arg("-h")
270+
.arg("uchg")
271+
.arg(&link)
272+
.status()
273+
.expect("chflags");
274+
assert!(status.success());
275+
276+
let result = break_hardlink_if_needed(&link).await;
277+
278+
// Clear so tempdir cleanup can recurse.
279+
let _ = Command::new("chflags").arg("-h").arg("nouchg").arg(&link).status();
280+
281+
let err = result.expect_err("remove of immutable symlink must propagate EPERM");
282+
assert_ne!(err.kind(), std::io::ErrorKind::NotFound);
283+
}
284+
285+
/// Hardlink branch read-fails arm (cow.rs:84): a hardlinked file
286+
/// chmod'd to 0000 fails the read step. break_hardlink_if_needed
287+
/// gets past lstat (mode bits don't affect lstat results) and the
288+
/// `nlink > 1` check, then `read(path)` returns EACCES.
289+
///
290+
/// Skipped under uid 0 — root bypasses mode-bit access checks.
291+
#[cfg(unix)]
292+
#[tokio::test]
293+
async fn cow_hardlink_unreadable_propagates_read_error() {
294+
use std::os::unix::fs::PermissionsExt;
295+
use std::process::Command;
296+
if Command::new("id")
297+
.arg("-u")
298+
.output()
299+
.ok()
300+
.and_then(|o| String::from_utf8(o.stdout).ok())
301+
.map(|s| s.trim() == "0")
302+
.unwrap_or(false)
303+
{
304+
eprintln!("SKIP: root bypasses chmod 0000 restrictions");
305+
return;
306+
}
307+
308+
let tmp = tempfile::tempdir().unwrap();
309+
let a = tmp.path().join("a.txt");
310+
std::fs::write(&a, b"data").unwrap();
311+
let b = tmp.path().join("b.txt");
312+
std::fs::hard_link(&a, &b).unwrap();
313+
314+
// chmod 0000 on either link affects the inode (both fail).
315+
let mut p = std::fs::metadata(&a).unwrap().permissions();
316+
p.set_mode(0o000);
317+
std::fs::set_permissions(&a, p).unwrap();
318+
319+
let result = break_hardlink_if_needed(&b).await;
320+
321+
// Restore so tempdir cleanup can read+unlink.
322+
let mut restore = std::fs::metadata(&a).unwrap().permissions();
323+
restore.set_mode(0o644);
324+
let _ = std::fs::set_permissions(&a, restore);
325+
326+
let err = result.expect_err("read of unreadable hardlinked file must propagate");
327+
assert_ne!(err.kind(), std::io::ErrorKind::NotFound);
328+
}
329+
330+
/// `write_via_stage_rename` stage-write failure (cow.rs:111): the
331+
/// hardlink branch reads the file content successfully, then
332+
/// `tokio::fs::write(&stage, bytes)` fails because the parent
333+
/// directory is r-x-only (write permission revoked after setup).
334+
///
335+
/// Goes through the nlink>1 path so we don't touch the symlink
336+
/// branch's remove_file (which would also fail on a no-write
337+
/// parent, taking us down a different code path).
338+
///
339+
/// Skipped under uid 0.
340+
#[cfg(unix)]
341+
#[tokio::test]
342+
async fn cow_stage_write_failure_propagates() {
343+
use std::os::unix::fs::PermissionsExt;
344+
use std::process::Command;
345+
if Command::new("id")
346+
.arg("-u")
347+
.output()
348+
.ok()
349+
.and_then(|o| String::from_utf8(o.stdout).ok())
350+
.map(|s| s.trim() == "0")
351+
.unwrap_or(false)
352+
{
353+
eprintln!("SKIP: root bypasses chmod 0500 restrictions");
354+
return;
355+
}
356+
357+
let tmp = tempfile::tempdir().unwrap();
358+
let dir = tmp.path().join("pkg");
359+
std::fs::create_dir(&dir).unwrap();
360+
let a = dir.join("orig.txt");
361+
std::fs::write(&a, b"content").unwrap();
362+
let b = dir.join("link.txt");
363+
std::fs::hard_link(&a, &b).unwrap();
364+
365+
// Drop write permission on the parent so stage-file creation
366+
// (parent/.socket-cow-*) fails — keeping read+execute so
367+
// lstat, the nlink check, and `read(path)` all succeed first.
368+
let mut p = std::fs::metadata(&dir).unwrap().permissions();
369+
p.set_mode(0o500);
370+
std::fs::set_permissions(&dir, p).unwrap();
371+
372+
let result = break_hardlink_if_needed(&b).await;
373+
374+
// Restore so tempdir cleanup works.
375+
let mut restore = std::fs::metadata(&dir).unwrap().permissions();
376+
restore.set_mode(0o755);
377+
let _ = std::fs::set_permissions(&dir, restore);
378+
379+
let err = result.expect_err("stage write into read-only parent must fail");
380+
assert_ne!(err.kind(), std::io::ErrorKind::NotFound);
381+
}
382+
216383
/// `break_hardlink_if_needed` failure-cleanup arm (cow.rs:116-120):
217384
/// when `rename(stage, path)` inside `write_via_stage_rename`
218385
/// fails, the function must `remove_file(stage)` before

0 commit comments

Comments
 (0)