Repository navigation
Conversation
… target
`mv link/ /otherfs/x`, where link points to a directory D: rename(2)
fails with EXDEV, so mv fell back to copy and remove. The copy followed
the link and duplicated D, and the removal walk then unlinked
everything it had copied -- D was emptied and the link left in place.
Within one filesystem the same command fails with ENOTDIR.
- mv: in the EXDEV fallback, an operand with a trailing slash whose
bare name is a symbolic link fails with the rename's own diagnostic
("cannot move 'link/' to 'x': Not a directory") before anything is
copied. GNU coreutils 9.4 empties D here; we do not follow it.
- ftw: traverse_directory_at handled a trailing-slash name by opening
it directly, so a symbolic link was walked under its own name and
Entry::reached_through_symlink() never fired. It now dispatches the
way traverse_directory does: such a link is walked as `.` in the
directory it names.
- mv's removal walk refuses a starting point reached through a symbolic
link (ENOTDIR), as rm does, so a directory swapped for a link after
the check above still cannot be emptied.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eady reported remove_left_directory ignored ENOTEMPTY from rmdir unconditionally, to avoid repeating "Directory not empty" up the tree after a child failed. But it also hid a directory that gained an entry while rm emptied it, and one holding a file the user declined to remove under -i: the directory stayed, with no diagnostic and exit status 0. rm now tracks, per directory walked, whether anything under it was left behind, the way GNU coreutils 9.4 does (measured): - left behind: an entry already reported (a removal or walk error), or a directory kept at its first prompt (a declined descent, or a declined removal of an empty directory). A directory holding one is neither prompted for nor reported again. - not left behind: a declined file, or a declined removal of a directory already emptied. The directory is still prompted for, and rmdir's ENOTEMPTY is reported as "cannot remove 'd': Directory not empty" with exit status 1. A directory holding a reported entry is therefore no longer prompted for under -i, as with GNU rm. The new test makes the directory gain an entry deterministically: rm -ri reads its answers from a pipe, and the entry is created while rm waits for the answer to the directory's own prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er -f
Below the operand, cp refuses to write through a symbolic link at a
destination name ("not writing through symlink"), since the tree's
owner chose where it points. That refusal also caught -l, which writes
nothing: it makes a hard link and, under -f, renames it over the
existing name, never following it. So `cp -Rlf src dst` failed where
dst held a symlink, and `cp -Rl` gave the wrong diagnostic.
-l is now exempt from the refusal, as GNU coreutils 9.4 is (measured):
with -f the link is replaced by a hard link to the source; without it,
"cannot create hard link 'dst/...' to 'src/...': File exists" and exit
status 1. The link's target is untouched either way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fied
The verbose line for a directory the copy made ("'d' -> 'dst'", or mv's
"created directory 'dst'") was written as soon as mkdirat returned,
before open_made_dir opened it and checked it was the directory made.
When that failed, -v had already reported a directory the copy then
refused to copy into, followed by "cannot open directory 'dst'".
The report is now made after open_made_dir succeeds.
The test makes the open fail deterministically with RLIMIT_NOFILE,
trying each limit from 3 up until the copy succeeds: whichever open a
limit falls on, no directory named in a "cannot open directory" error
may appear in the verbose output, and at least one limit must make a
made directory fail to open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g D or its parent A source whose last component is `.` or `..` (`D/.`, `D/..`, `link/.`, `link/./`, `.`, `..`) is pinned as the name `.` or `..` in its parent directory. Within one filesystem rename(2) refuses it with EBUSY. Across filesystems Linux returns EXDEV first, so mv fell back to copy and remove: it copied the directory and then removed everything it had copied, emptying D, and for `D/..` or `..` the parent with all of D's siblings. - mv: in the EXDEV fallback such a source fails with the rename's own diagnostic, "cannot move 'D/.' to 'x': Device or resource busy", before anything is copied. (GNU coreutils 9.4 copies it and then refuses the removal; we do not copy at all.) - remove_moved_source refuses such a starting name outright, so no caller can remove through it. - PinnedEntry::names_dot_or_dotdot is the one test of the name, used by both. The target side is not affected: step 5 removes an existing target with a single unlinkat, which fails on `.` or `..`. cp never removes what it pins, and rm already refuses `.` and `..` as POSIX requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rm -v wrote each name between hand-written single quotes, from a lossy UTF-8 rendering of the path: a name with a newline split the line, one with a quote came out ambiguous, and a byte that is not a character was replaced by U+FFFD. GNU coreutils 9.4 prints, for example, removed 'd/a'$'\n''b' removed "d/it's" removed 'd/x'$'\377''y' report_removed now takes the path and quotes its bytes with quote_bytes, which cp -v and mv -v already use, reducing trailing slashes to one as before. remove_nondir_at takes the path instead of a display closure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s component by component DeferredDir::open_file_descriptor built the full list of ancestor identities on every reopen of a deferred directory, though open_long_filename reads it only when it falls back to opening the path one component at a time. Most reopens resolve in a single fstatat, so the list was walked and allocated for nothing. open_long_filename now takes a closure that yields the identities and calls it once, at the first component it opens. No check is dropped: the fallback is taken for ENAMETOOLONG and also for ELOOP, which a short path can hit, so gating the list on the path's length instead would have left a short ELOOP fallback unchecked. The new test, deferred_long_path_reopen_refuses_a_replaced_component, replaces an intermediate component of a path longer than PATH_MAX with a new directory holding the rest of the path, so only the per-component check can refuse it. It fails when the identities are not passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nostic rm parsed its operands as UTF-8 strings (to allow the empty operand), so any name that is not valid UTF-8 was refused by the argument parser with "invalid UTF-8 was detected" and status 2: such a file could not be removed by name at all. Its diagnostics and prompts also showed names through a lossy UTF-8 rendering between hand-written quotes, so a byte that is not a character became U+FFFD and a quote or newline in a name came out ambiguous. Operands are now taken as OsString (any bytes, the empty string included). Every diagnostic and prompt shows a name through display_cleaned, which now quotes its bytes with the shared quote_bytes as cp, mv and rm -v do, matching GNU coreutils 9.4, e.g. rm: cannot remove 'x'$'\377''y': No such file or directory rm: cannot remove "it's": No such file or directory Plain names come out exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eleting madefs's copy plib::madefs and ftw each had their own is_empty_dir_fd plus a private lists_nothing: the same openat of `.` relative to the descriptor, then the same readdir loop with errno reset before each call and any errno but 0 an error. Two copies of one rule. plib now depends on ftw (Unix only, as madefs is; the Windows build of plib is unchanged), and madefs's verify_made_dir calls ftw::is_empty_dir_fd. madefs's copy and its lists_nothing are deleted. ftw does not depend on plib (only its tests do), so the dependency goes this way: ftw stays small, with libc and errno only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The changes rework subtle, safety-sensitive filesystem semantics across rm/mv/cp/ftw (left-behind accounting, race-hardened reopens, cross-filesystem guards) that warrant final human review despite appearing correct and well-tested.
1 open finding
What changed in this PR
This PR refines the behavior of cp, mv, rm, and the shared ftw directory-walk library to match GNU coreutils 9.4 more closely, focused on three themes: (1) consistent GNU-style name quoting in rm diagnostics/prompts/-v output so non-UTF-8 and special-character names come out unambiguous; (2) correct handling of pathological sources — a symbolic link named with a trailing slash (link/) and ./.. sources — so mv/rm fail as rename(2)/rmdir(2) would instead of following the link and emptying the target; and (3) more faithful rm -r/-i "left behind" accounting and cp -v reporting a made directory only after it is opened and verified. It also deduplicates the empty-directory check by moving is_empty_dir_fd from plib::madefs into ftw and sharing it.
Changes:
rm: switch operands toOsString, quote all names viaquote_bytes, and add aLeftBehindtracker plusDirAction::Declined/GoneandWalkedenums to reproduce GNU's rules for when an emptied directory is re-prompted and whenDirectory not emptyis reported.mv/remove_moved: refuseD/.,D/.., andlink/cross-filesystem sources before any copy, mirroringrename(2)'s EBUSY/ENOTDIR.ftw: handle trailing-slash symlink operands intraverse_directory_at, gather long-path component identities lazily, and expose the sharedis_empty_dir_fd;cp -vnow reports a made directory only once opened and verified.
| File | Description |
|---|---|
| tree/rm.rs | Byte-accurate quoting of all messages; LeftBehind tracking and new DirAction/Walked states for correct re-prompt/report behavior. |
| tree/mv.rs | Cross-filesystem guards for ./.. and trailing-slash symlink sources; new is_symlink_with_trailing_slash helper. |
| tree/remove_moved.rs | Defensive dot/dotdot and reached_through_symlink guards in step-7 removal, with unit tests. |
| tree/common/pinned.rs | New names_dot_or_dotdot helper (minor doc nit flagged). |
| tree/common/copy.rs | Report a made directory only after it is opened/verified; skip symlink-below-operand refusal under -l. |
| plib/src/madefs.rs | Remove local emptiness check, delegate to ftw::is_empty_dir_fd. |
| plib/Cargo.toml, Cargo.lock | Add ftw as a cfg(unix) dependency of plib. |
| ftw/src/lib.rs | Trailing-slash symlink operand handling in traverse_directory_at; lazy identity gathering; shared is_empty_dir_fd. |
| ftw/src/dir.rs | Pass component identities lazily to open_long_filename. |
| tree/tests/*, ftw/tests/race.rs | Extensive new coverage for quoting, dot/dotdot and symlink sources, verbose made-directory reporting, and long-path identity checks. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…the largest value that fits A uid, gid or link count wider than an odc field (18 bits) or a binary one (16 bits) dropped the file from the archive. It is now archived with the largest value the field holds -- 262143 for odc, 65535 for binary -- and the first substitute each field takes is reported once per run, as an error, so the run exits 1. GNU cpio 2.15 masks instead, silently and with exit 0: measured under fakeroot, uid 300000 is stored as 37856 by -H odc and -H bin, and uid 70000 as 4464 by -H bin; a mask can land on root. The largest value that fits never does, so it is used rather than GNU's choice. A substitute owner is not the file's owner, so the set-user-ID or set-group-ID bit tied to a substituted id is cleared, rather than handing its privilege to whoever holds the substitute id. newc has no such case: its 32-bit fields hold any uid_t, gid_t and the link count an entry carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r_dotdot The doc comment listed `/` among the names whose last component is `.` or `..`. That held only because the pin stores `/` as `.` in the root directory: an operand left reached by its pathname (Anchor::Path) keeps the whole name, and `/` there was not matched. A name of slashes only now counts as the root directory however it is held, the comment says so, and a unit test pins the matching names, the non-matching ones, and real pins of `/`, `//`, a directory and its `/.`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng -n stop A link set whose names were all empty never counted as having its data, because none ever came. Extraction kept the descriptor pinning its file open for the rest of the run -- one per set, so an archive with more such sets than the descriptor limit failed every member past it with EMFILE -- and -n read the whole remaining archive, waiting for that data. A set now waits for data only while it can still come: none has come, its format stores the data with the last name only (newc and crc; odc and the old binary format store it with every name, as GNU cpio 2.15 measured does), and fewer than c_nlink of its names have been read. LinkSets counts every name read, extracted or not. The extract loop closes a set's pin as soon as it is settled, and -n stops once every set is. A newc set whose archive holds fewer names than its c_nlink, all empty, still waits to the end of the archive, pinned: nothing tells it from one whose data is still to come. Tests: 200 empty linked sets in odc and in newc extracted with a limit of 64 descriptors (before: EMFILE from e58a on); -n e1 e2 sel over an empty linked set and a member, followed by a damaged header that must not be read (before: "Invalid header: invalid hex", exit 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow id 65534 f03f9da stored an owner too wide for its field as the largest value the field holds: 262143 for odc, 65535 for binary. That is still an arbitrary real id on many systems, and root extracting with -pe hands the file to whoever holds it. An owner is now stored as 65534, the Linux kernel's own substitute for an id that does not fit a narrow field: the default of /proc/sys/fs/overflowuid and overflowgid, which the 16-bit uid system calls report, and which NFS and user namespaces show for an id they cannot map. It is "nobody" on most systems and fits both formats. It is a constant rather than the host's setting, so that an archive does not depend on the host that wrote it. A link count too wide for its field is still stored as the largest value that fits: it only says the file has many names. The set-ID bit of a substituted owner is still cleared, and each field's first substitute is still reported once, with exit status 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is still reading A directory that maps onto itself under -s or -i is walked while its entries are copied into it under their new names. Its readdir then met those new files, and pax copied each onto itself: "file would overwrite itself", or under -l "Unable to link file to itself", exit 1. A directory of 2000 files renamed by -s ',^d/f,d/g,' met 504 of its own copies. The copy now records what it makes, with the existing dest_ids: - dest_ids says what each destination directory is: one that was there already (the destination operand, one entered, and now also one that maps onto itself), which met as a source is still "cannot copy directory into itself"; or one this copy made, which is its own output and is passed over. - Under -s or -i, each file made is recorded by the identity of the directory it was made in and its name, and the walk passes over a source found there. A name rather than the file's identity: under -l the file made is the source itself, and a name the source already had must still be copied. Without -s or -i every member lands at its source's own name, below a destination the walk never enters, so nothing is recorded or looked up. Test: d with 2000 files, a hard link and a subdirectory, copied with -rw and -rw -l and -s ',^d/f,d/g,' d . (before: "d/g1071: file would overwrite itself; not copied" and 503 more, exit 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nto itself
The directories pax makes to hold a member below them (DirTree's
implicit ones) were recorded neither in dest_ids nor in made_files. Under
-s, when such a directory is made inside a directory the walk has still
to read, the walk met it, copied it again under the same substitution one
level deeper, and so on without end:
pax -rw -s ',^[de]/\(.*\)$,e/q/\1/w,' d e .
makes e/q while visiting e, before e is read, so the walk meets e/q,
makes e/q/q, and recurses whatever the readdir order. With a directory of
2000 files, -s ',^d/\(.*\)$,d/q/\1/w,' d . made 259122 directories, 86
deep, in 5 seconds before it was killed.
copy_one now passes over a source directory this run made -- for a
member, only to hold members below it, or unverified -- by asking the
tree's own registry (DirTree::made_by_run, a new accessor over the
implicit, made and unverified sets). That registry already knows every
directory made, so the Made kind 374dcaa added to dest_ids duplicated it
and is gone: dest_ids is again the set of directories that were there
already, met as a source as "cannot copy directory into itself".
Test: the two-operand command above, under a 5 second deadline, must
finish, exit 0, and leave e two levels deep (before: killed at the
deadline).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A newc link set waits for its data until c_nlink of its names have been read, and its file stays pinned by an open descriptor meanwhile. A crafted archive can start any number of sets that never finish -- one empty name each, with c_nlink 2 or 0xffffffff -- and each held a descriptor to the end of the archive, so every member after the descriptor limit failed with EMFILE. The extract loop now keeps the pinned sets in a FIFO, at most a quarter of the RLIMIT_NOFILE soft limit and never more than 256. Past that it closes the oldest set's pin; the set falls back to the bare (st_dev, st_ino) check every set already relies on once its data is in. A set's entry leaves the FIFO when its pin closes, as it does once the set is settled. Test: 300 one-name newc sets, alternating c_nlink 2 and 0xffffffff, followed by 20 ordinary members, extracted with a limit of 64 descriptors (before: "s58: I/O error: Too many open files" onward, exit 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ectory others can write `cp -p` (and -a) gave every destination directory it found already existing the source's owner, mode and times. Where someone else can create entries in the destination -- or in any directory between the one the user named and the one found -- the directory at the source's name may be anyone's, renamed there before cp ran: the user's own private 0700 directory included, which -p would then open up to 0755. cp now follows the rule pax already does, through the same plib::madefs::ChainTrust: the destination directory the user named is the anchor (for `cp -R src new`, the directory `new` is made in; for --parents, the target), and the trust is carried down from it on the descriptors cp holds: a found directory hands on what it was handed and its own, one this run made and verified in full starts afresh. A found directory takes what -p asks only where the chain is trusted; elsewhere it keeps everything, times included, cp says so naming it, and the exit status is 1. Its contents are still copied. Without -p nothing changes. A directory this run made, found again at the same path by a later operand (`cp -pR s1/x s2/x dir`, or --parents walking through what an earlier operand made), is still the run's own, as GNU cp treats it. One found at another path is not: whoever renamed it there could have renamed it to a name it does not stand for. What a directory is given is now recorded on its own level of the walk when it is entered, instead of being looked up by inode number when it is finished: a directory this copy made, renamed by someone else to a sibling's name, was taken for made there and given that sibling's mode. cp_n_recursive_merges_without_overwriting now sets its destination 0755: under a umask of 002 it was group-writable, and its found directory was rightly left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
To decide whether a directory cp -p found existing may take the source's attributes, cp opens the directory it is in, through the found one's `..`. That open was O_RDONLY, which needs read permission: under a destination the user may search and write but not list (0300), `cp -pR s2 r` with r/s2 already there failed and copied nothing, where it used to copy. Copying into the found directory needs search permission only, and so does judging it: the open is now O_PATH (Linux) or O_SEARCH (macOS, the BSDs). pax and cp each had their own copy of that flag; it now lives once in plib::madefs::SEARCH_ONLY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t directory cp -p decided whether a destination operand it found existing may take the source's attributes by reading the found directory's own `..`. But the operand is opened by its full path, and a trailing slash follows a symbolic link despite O_NOFOLLOW: with `cp -pR src /shared/new/`, someone who can write /shared could plant new -> /home/victim/.ssh after cp looked, and `..` was then the victim's own home directory, which only the victim can write. .ssh was given src's mode, owner and times. The anchor is now the directory the operand is named in: the held directory, or the operand's path less its last component, opened for search only. Its last component, read there without following links, must be the very directory found -- a directory has no other name -- or the found directory hands and takes no trust (plib::madefs::ChainTrust::unlocated): it keeps its attributes and that is reported. The copy itself still follows the link, as GNU cp's does; only the attributes are withheld. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g in .. `cp -R --parents . ../t` copied into t/t: the destination was built as `../t/.`, whose last component Path takes to be `t`, and the copy was made under that name in t. GNU cp copies into t itself, and so does cp now: a source that is `.` (or `/`) is copied into the target as `cp -R src/. target` is, the target being the directory the user named. A source ending in `..` gave a destination ending in `..`, which named no entry of the directory --parents had walked to; the copy then fell back to resolving it from the working directory, dropping the trust carried down from the target -- under a target others can write, a found directory there was given the source's mode. Such a destination is no name inside the target but whatever directory the `..` reaches (for `..` itself, outside it), so it is refused. GNU cp copies onto that directory; in the probes it then mostly fails with "into itself". TargetRoot::of_path is split into in_cwd and in_dir, and in_dir refuses an operand with no last component instead of re-resolving it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hanged it The run-wide record of the directories cp made was keyed by inode number and path. An inode number is reused -- ext4 hands a freed one straight back -- so whoever could remove an empty directory cp made (from a parent they can write that is not sticky) could make one of their own at its path under its number, and a later operand found it and gave it the source's attributes as the run's own. The record now also holds the directory's status-change time as cp last left it, and a directory found counts as made only with that time unchanged. A directory made and replaced has its making for a status- change time, after cp's last change. cp notes the time again after it fills and finishes a directory it holds as its own, from the descriptor held throughout (a directory held open keeps its number), so an honest later operand -- `cp -pR s1/x s2/x s3/x dir`, or --parents walking through what earlier operands made -- still finds it unchanged. The residual is the grain of the timestamp clock: a directory removed and remade under the same number within the tick of cp's last change passes. The birth time would not narrow that, being stamped from the same clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e user's own cp -p and pax -p e leave a directory found existing alone where someone else could have created its name, and counted any group-writable directory on the way as such a place. Under a umask of 002 with user private groups (Debian's adduser default) every directory the user makes is group-writable, so the user's own destinations were refused: cp -a into one exited 1 with a diagnostic per directory found. By the user's decision, plib::madefs::nobody_else_can_create -- which cp and pax both reach through ChainTrust -- now counts group write permission as the user's own when the directory's group is the user's private group: the user's primary group, listing no member but the user, and nobody else's primary group (is_private_group). The databases are read through NSS, the whole passwd database through getpwent (new: plib::user::load, which reports an error rather than returning a shorter list), once per group in a process. Anything that cannot be read counts as not private. The doc comment records the residuals: NSS sources that do not enumerate, members known only to getgrouplist, group passwords, and changes made after the answer is read. The rule itself (group_is_private) and the mode test around it (nobody_else_can_create_with) are unit-tested without the databases. New cp and pax tests check that a destination of the user's private group under umask 002 is stamped; they skip with a note on a host without user private groups. The tests that need a group-writable destination others can write now give it a group the user shares (plib::testing::shared_group), or are world-writable where only the chain was under test. debhelper's cp_n_recursive_merges_without_overwriting no longer needs its 0755 workaround. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r raw names is_private_group compared each member name the group lists with the user's name, both through to_string_lossy: two different non-UTF-8 names could compare equal, and a second passwd name for the user's own uid counted as someone else. Each member is now resolved with getpwnam on the name's bytes as the group database holds them and compared by uid; a name that resolves to nobody means not private. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…local files is_private_group decides that nobody else is in a group from what getpwent and gr_mem list. A directory service that does not enumerate (sssd or LDAP with enumerate=false, the usual setting) hands getpwent only its local users and lists a domain group's members nowhere, so a group shared across a domain could pass for private: the test failed open. Now, each failing closed: - the passwd and group lines of /etc/nsswitch.conf must name nothing but files and systemd, or compat while /etc/passwd and /etc/group hold no '+'/'-' lines. A missing line takes glibc's default, which is files, or "compat [NOTFOUND=return] files" where glibc is built with the obsolete NSL, so it is held to compat's condition. A missing file is all defaults; an unreadable one, or a line with no source, a second line for the database, an unclosed [action] or no colon, means not private; - the user's own (uid, primary gid) must be in the enumeration, which is otherwise not the whole database; - the rule applies only on Linux with glibc. On macOS, the BSDs and musl no group is private; - getpwent returning NULL with any errno set, ENOENT included, is a failure, not the end of the list: glibc leaves errno 0 at the end. The doc comment adds the residuals no database shows: groups granted by pam_group or SupplementaryGroups=, and processes of a former member that still hold the gid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… names anyone else With an extended POSIX ACL the group bits of st_mode are the ACL's mask. A directory of the user's private group, mode 0755, given `setfacl -m u:nobody:rwx`, shows 0775, and the private-group rule took that group write permission for the user's own: `cp -pR src D` stamped the existing D/src 0777 where it had been left alone, and pax -p e did the same. nobody_else_can_create now takes the directory's descriptor and trusts group write permission through the private-group rule only when the access ACL (system.posix_acl_access) names nobody but the owner, the owning group, the mask and others. An O_PATH descriptor takes no fgetxattr, so the directory is then reopened for reading through self/fd/N under the verified procfs. A directory the user may not read, an ACL of a version or shape not known, or any error other than "no ACL" (ENODATA) or "no ACLs here" (EOPNOTSUPP), counts as naming others. The stat-only form, which could not see an ACL, is gone. cp and pax tests with setfacl skip, with a note, where setfacl is missing, the filesystem takes no ACLs, or the user has no private group (plib::testing::grant_named_acl). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ses are local files" This reverts commit 9eb9fb70, by the user's decision: parsing /etc/nsswitch.conf and walking the whole passwd database made the private-group check expensive (with LDAP enumeration on, a network sweep) and its parser could disagree with glibc's. The rule is to be replaced by the cheap user-private-group convention. The ACL check from 40da1eef, which came after it, is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user's 2026-10-08 decision, implemented by 27dbfbbf, was to be recorded here and never was: cp --parents refuses a source whose last component is `..` (diagnostic, exit 1), where GNU cp copies onto the directory the destination's trailing `..` reaches, outside the target. The entry also says that a source of `.` copies into the target itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th -p e The private-group rule (from cp-chaintrust) was tested for one directory below a group-writable destination, by its mode. This tests what a user meets: a whole tree made under umask 002, every directory group-writable and of the user's private group, extracted again with -p e under umask 002. Every directory found must take the member's times and mode, with no diagnostic and exit 0. pax built from 394299f, before the rule landed, fails it: "not applying owner, mode or times ... others can create entries beside it" for t, t/a and t/a/b, their times left as they were, exit 1. A shared-group destination is still refused, as test_reextract_into_a_group_writable_ destination_with_pe and cp's tests already check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under -p o as root, apply_dir_attrs gives a directory pax made the member's owner, and note_left_as then recorded that owner as what pax left it as. The new owner, bob, can remove it and make another at its name -- ext4 can hand back the same inode number -- with the recorded mode. A second pending apply for that name (an archive holding d twice with different owners, or -rw -s mapping two sources onto one name; the pending list does not dedupe) then met Standing::Made, skipped the existing-directory rule, and root gave bob's planted directory the second member's owner and mode. note_left_as now forgets a directory it finds owned by someone other than the user pax runs as and than the owner it was made with: it is no longer a directory this run made, and a later apply judges it as found existing (found_dir_with_mode). The owner it was made with still counts, rather than the effective user alone: on a filesystem that stores no owners (vfat, exfat, classic ntfs) made_by_us accepts a directory reporting the mount's owner in full, and pax gives it nothing else, so such directories keep their standing. Not ctime: nothing short of the owner check distinguishes bob's directory from pax's once bob owns it, and while pax still owns it only the user pax runs as could replace it with one owned the same. Test: a made and an implicit directory, given another owner by an apply, are Ordinary at the same id, mode and path afterwards (before: Made); one left with the user's owner or the mount's stays Made. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… silently make_dir_at matched Standing::Unverified -- a directory this run made with an owner it could not verify, on NFS or FUSE -- before the -k arms, so pax -r -k (and -rw -k) withheld its attributes and reported "not applying owner, mode or times", exit 1, where -k leaves any directory found existing alone and says nothing. -k now keeps it first, as it keeps a made or ordinary one. Nothing is weakened: under -k it takes no attributes either way, and without -k its attributes are still withheld and diagnosed. Test: an Unverified directory (through the registry, as the existing standing tests reach it) is Keep under -k (before: Withheld) and still Withheld without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inned inode When link_replacing_with found the destination name taken, it decided "the destination already is the source" -- leave it, and count the member as linked to itself -- by resolving the source's name again with fstatat. Once the source is pinned, someone who can rename in the source directory can point that name at the destination's file: the member was then skipped, and its destination name left holding another file. The source's identity now comes from the pinned inode (Linux, with a verified procfs), or from the identity the caller examined (`expected`) for a source linked by name. Only a source known by neither -- callers that pass no `expected` -- is resolved by name again. Under -H/-L the symbolic link itself still counts as the source, as before. Test: a test-only hook point (race_hook Point::LinkExists) runs when the destination is found taken; there the source's name is made a link to the destination's file. The destination must end up holding the pinned source (before: skipped as already linked to itself). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
join_link_set (a later name linked to a surviving earlier one) and move_names_to (the earlier names moved over to the file just made for the set's data) called link_replacing, which links by name with no `expected` identity. The holder name was checked to hold the set's file first, but between that check and linkat anyone who can rename in that directory could put another file at the name, and the set's names became links to it. Both now call link_replacing_with with the identity they checked -- the set's file, or the file just made -- so on Linux with a verified procfs the link is made to the pinned inode itself, and elsewhere a link to any other file is removed again and the member fails. The test seam race_hook Point::Linking moves from copy mode's -l call site into link_replacing_with itself, at the same point (just before the source is pinned), so it now covers every caller. Test: move_names_to with another file renamed over the made file's name at Point::Linking must fail and leave the earlier name alone (before: it linked the earlier name to the planted file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
procfs_dir opened /proc by name and fstatfs'd it on every call, and its callers -- pax's pinned link source (per file under -rwl), the search-only attribute path, chmod_pinned, the blocking reopen of a regular file, and cp's pinned copies -- call it per file. It now opens and verifies /proc on the first call and keeps the verified descriptor (or the failure) in a OnceLock for the rest of the process, returning a BorrowedFd<'static>. Holding it is also safer than reopening /proc by name each time: what was verified is what every later self/fd/N lookup goes through, whatever is mounted at /proc since. self resolves to the process doing the lookup, so a forked child still reaches its own descriptors, and the descriptor stays O_CLOEXEC. pax -rwl over 200,000 empty files (200 directories of 1000), three runs each: 1.65, 1.67, 1.65 s before; 1.55, 1.54, 1.54 s after. Over 1000 files, openat + fstatfs calls fall from 2031 + 1001 to 1032 + 2. Test: two calls, both held, return the same descriptor, and it is procfs (the old function gave descriptors 3 and 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both said a directory this run made "takes attributes wherever it is". standing() returns Made only when the directory is met at the member path it was made at, and -- since LeftAs -- only while it is still owned, grouped and moded as this run left it, which it no longer is once this run has given it to someone else. Met anywhere else, or changed, it is Ordinary, judged by the existing-directory rule. The comments now say that, and why the path matters: otherwise someone who can rename in the destination could rename a directory this run made to another member's name and have that member's mode or owner given to it without the existing-directory rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same few wrappers existed four times over: plib::madefs had private fstat and lstat_at (fstat a copy of the public plib::tty::fstat); pax's made.rs its own fstat, cvt and a lstat_at in its non-Linux module; and pax's anchored.rs fstat, fstatat and stat_at, returning Option rather than io::Result. plib::madefs now exports the one set, all returning io::Result: fstat (re-exporting plib::tty::fstat, so plib keeps one implementation), fstatat, lstat_at and cvt. pax's copies are gone; its callers use these, with .ok() where they want an Option, and the three places that turned None back into io::Error::last_os_error now pass the error the call left. No behaviour change: release tests of plib, pax and tree pass the same 1450, the pax golden gate is identical, cpmvsec 29 of 29, and pax checks clean for x86_64-apple-darwin, which builds the non-Linux module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…file this run made Two hard-link sites still linked by name with no expected identity: - copy mode linked a later name of an already-copied file to the copy's destination path, from HardLinkTracker; - read mode linked a tar or pax link member (typeflag 1) to its target member's path. In a destination directory others can write, someone can rename their own file over that path after pax makes it -- root's `pax -rw src /dest` with /dest/pub 0777, not sticky -- and pax then linked their file into a directory only root writes, under the later name. HardLinkTracker is now generic over what it remembers of a first name. Write mode keeps the member path; copy mode keeps the destination path and the identity of the copy, taken by fstat of the descriptor that made it. Read mode keeps the identity of each regular file it extracts, by member path, and forgets it when a later member puts anything else at that name. Both sites now call link_replacing_with with that identity, so on Linux with a verified procfs the link is made to the pinned inode and anything else fails the member. Where this run made nothing at the name -- -k kept a file already there, or a link member's target was not extracted by this run -- there is no identity, and the link is made by name as before: what is there is what the member refers to. link_replacing, left with no callers but its own tests, is gone; those tests call link_replacing_with with None. Tests (both through the race_hook Linking point, as 066c786 did): a later name copied from a hard-linked pair, and a tar link member, each with another file renamed over the target just before the link -- the later name must not end up holding it (before: it held the planted file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its name Under -H/-L, copy mode's -l links the file a symbolic link refers to, and the link itself counts as the source too: a destination that already is that link is left alone (pax -rwl -H link .). link_replacing_with recognised it by resolving the source name again with fstatat (AT_SYMLINK_NOFOLLOW), and copy mode's is_source did the same. Once the link's target is pinned, someone who can rename in the source directory can point that name at the destination's file, and the member was skipped as already linked. ftw now keeps the identity of each symbolic link from its own lstat of it, the link it examined, on the entry (Entry::symlink_id; carried with directories it descends through like is_symlink). Nothing about the walk changes: it only exposes what the walk already read. link_replacing_with's `follow` is now that identity, Some when the link is to be followed, and it is what the destination is compared with; is_source compares with it too. A followed link the walk gave no identity for fails the member rather than be resolved by name. Test: a followed link whose source name, once the target is pinned, is made a hard link to the destination's file: the destination must end up holding the link's target (before: skipped as already the link itself). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pass d50ebd7 kept whatever the first procfs_dir call got for the rest of the process, failures included: a first call that met a full descriptor table (EMFILE, ENFILE) left every later pinned chmod, link and reopen failing, though descriptors were free again. Only a verified answer is kept now: the procfs descriptor, or a /proc opened and found not to be procfs. Any other failure is returned and the next call tries again. The doc comment also overclaimed: read_xattr still names /proc by path, since no *at form of getxattr takes the held descriptor; it now says so. And the test d50ebd7 added had been placed between link_times_by_name_reach_only_the_pinned_link and its doc comment; the comment is back on its test. Test: in a child process of its own, so its first call is the process's first, procfs_dir under a full descriptor table fails with EMFILE, and succeeds once descriptors are free (before: the EMFILE was kept). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st the budget d17adf7 recorded a file this run made only by its (st_dev, st_ino), after the descriptor that made it was closed. In a directory others can write, someone could remove the file and make theirs at its name, and ext4 can hand the freed inode number straight back: the later link member, or the later name of a copied file, pinned the name, found the recorded number, and linked their file. A file this run makes is now a MadeFile (new pax/modes/pins.rs): its identity and the ctime it was left with, and an O_PATH pin reopened from the creating descriptor through the verified procfs -- the inode itself, never a name -- which keeps its number from being reused. A later name is linked through that pin directly (LinkSource links a caller's pin without opening the name at all). Pins are held within one budget, a quarter of RLIMIT_NOFILE and at most 256: the PinBudget read mode already kept for cpio link sets, now generic over its key and shared. Read mode keeps link sets and made files in one Links struct under one budget; copy mode keeps its copies' pins under its own. Past the budget, or without a procfs, a file is known by identity and ctime, and a source opened or checked by name must show both. ctime is refreshed when an unpinned file takes another link, and taken from the pin when the pin is closed. The residual of that fallback: the kernel stamps ctime from a coarse clock, so a file made again within the same tick (a few milliseconds) is not told apart. The pins cost read mode about 15% on tiny files: extracting 50,000 one-byte files went from 0.51 s to 0.59 s (three runs each). Tests: a pinned file is linked through its pin though another file was renamed over its name (before: refused, "source file changed"); an unpinned file whose number was "reused" -- the record given the planted file's number -- is refused on ctime (before: the planted file was linked); the budget closes the oldest pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…renames too A tar link member is linked to the file this run made at its target's name (294d550), but only regular files that extracted completely were recorded; anything else was linked by name, whatever the name held by then. Now recorded too: - symbolic links, FIFOs and devices: set_made_node_attrs_recording hands back the node it pinned and checked (MadeNode::made_file, a duplicate of its O_PATH pin on Linux), even when its attributes then fail; - a regular member whose data or attributes fail after its file is created: write_file_data records the file from its descriptor first and refreshes its ctime on every path; extract_regular records it whatever the outcome; - -i renames: select_member reports the name a member had before the prompt, and a later link member naming it is resolved to the name -i gave it (Links::link_target), where this run made the file. A symbolic link cannot be linked through its self/fd/N entry -- AT_SYMLINK_FOLLOW goes on to what the link names -- so LinkSource links one by name, checked by identity and by the ctime the caller's pin shows now, before and after; the pin keeps its inode number from being reused meanwhile. A pin a caller holds but that is not linked through must still be the file it records. Tests (race_tests): a link member naming a symbolic link member, with a file renamed over the link first, is never linked to that file (before: it was; mutation check: recording nothing fails it); the same link member is linked to the symbolic link made when nothing interferes; a link member naming a regular member whose data could not be read is not linked to a planted file (before: it was); a link member naming a member -i renamed is linked to the renamed file (before: not linked at all). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inked to extract_hardlink recorded its own name as holding the target's file after link_replacing_with returned, but under -k a name already taken is left alone and the call still succeeds. A later link member naming that name was then linked, through the shared pin, to the earlier target rather than to the file the name holds. The name is now recorded only when it holds the target's file. Where -k kept another file there, a later link member naming it is linked to that file by name, as GNU tar and cp -n link to what is there; the comment says so. Test: under -k, a pre-existing g, a link member g naming f, and a link member h naming g: h must be the file g holds (before: h was f). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c51a031's comment said "/proc not mounted yet" is retried. Not quite: where procfs is not mounted, /proc is usually an empty directory on the root filesystem (a chroot or a container), which opens, fails the procfs check, and is kept as "not procfs" for the rest of the process. Only a failure to open or fstatfs it -- a full descriptor table, no /proc at all -- is retried. The comment now says that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to a failed target extract_entry forgot what an earlier member had made at a name at the start of every member, before it was known whether the member would replace that name. A target member whose file could not be made (a non-empty directory in its way), whose node could not be pinned, or which -k skipped as a duplicate -- or a socket member, which makes nothing -- left no record, and a later link member naming it was linked by name, to whatever was at the name by then. Under -k a second copy of a name dropped the first one's pin. Each member now settles its own record once it is done (Links::left): - a file it made is recorded, even if the member then failed; - a member that failed without one leaves a tombstone (Record::Failed), and a link member naming it fails, "link target was not extracted; not linked", rather than link by name; - a member that succeeded without making anything -- -k kept the name, a socket was skipped -- leaves the name, and its record, as they were. A directory is never a link target: it leaves a tombstone unless -k kept the name. A node that was made always yields its MadeFile now, so "succeeded without making anything" cannot hide a node made but not known. Linking by name remains only for names the run never tried to extract (-u, -n, a pattern, absent from the archive). Tests (race_tests): a link member naming a target whose file was blocked by a non-empty directory is not linked to a file planted there (before: it was); under -k, a link member after a duplicate of its target is not linked to a planted file (before: it was). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tity and ctime A cpio link set knew its file by (st_dev, st_ino) alone. Its later names were linked to a holder name checked by that identity (Expected::id); the first name's pin -- opened by name, O_RDONLY, by pin_file -- could be closed by the budget or on settling, after which an inode number reused for someone else's file at a holder name passed. The files a newc data-carrying name or a fill put in place were never recorded. CreatedSet now holds the set's file as a MadeFile: its identity, ctime and the pin reopened from the descriptor that made it. Every link to it -- a later name joining the set, the earlier names moved over to the file carrying the data -- passes its expected(), so it is linked through the pin, or by name only where identity and ctime still match, and each link refreshes the ctime. When the set's file is replaced by a join or a fill, every name of the set is recorded as holding the new file. This also does F7: with the made file's own pin held by the set, pin_file -- an O_RDONLY open of the set file by name -- is gone, and so is its test that the open never adopted a terminal; an O_PATH reopen through procfs cannot. Test (race_tests): a later name of a set whose pin is closed, with a file at the earlier name that has the set file's number (simulated) but a later ctime, is not linked to it (before: it was). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s pin until checked LinkSource::pin returned early for a symbolic link -- which cannot be linked through its self/fd/N entry -- before checking its identity, and dropped the pin it had just opened. The by-name path then checked identity and ctime only where a ctime was known, so a caller passing Expected::id let a link to the wrong file be made and only removed again afterwards; and check_linked compared the identity alone. pin() now checks the pinned source against what is expected first, then hands a symbolic link's pin to the by-name path (Pinning::ByName), which holds it until the link is checked: check_linked requires the new name to show the identity and ctime the pin shows, or removes it and fails. Holding a pin keeps a number in use on a local filesystem only. On NFS another client can remove the file and the server give its number to another file whatever this client holds, and FUSE filesystems need not have stable inode numbers; there the identity and ctime checks are all there is. The comments that said a pin keeps the number unconditionally now say this (anchored.rs LinkSource::pin, pins.rs, CreatedSet::file). Test: a symbolic link whose identity is not the one expected is refused before any link is made -- the race_hook Linked point is never reached (before: a link was made, then removed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
User decision: a pin -- an O_PATH reopen through procfs per file, and a descriptor held -- is taken only where it is needed, not for every file made. Extracting 50,000 one-byte files from a tar, three runs each: 0.58-0.59 s with a pin for every file (314b9ff), 0.52-0.53 s now; 0.51 s before pins existed (c51a031). The remaining difference is one more fstat per file, for the file's identity and ctime. A pin is taken where a later name can be linked to the file while someone else could replace it at its name: - copy mode: a file with more than one name (nlink > 1); - read mode, cpio: a member whose file has more than one name; - read mode, tar and pax, whose later link members cannot be known ahead in a streamed archive: where anyone but the user pax runs as can create, rename or remove entries in the member's directory. Every file not pinned is still known by identity and ctime (Making carries the decision to wherever the file is made). The directory question reuses the rule ChainTrust follows, through a new plib::madefs::only_the_user_writes_in: write permission of the user's private group counts as the user's, an ACL letting anyone else write as theirs, and the directory must be the effective user's own -- root working in another user's tree does not count it as root's here, since that user could replace the file. It is asked once per directory. The simpler others_can_rename, tried first, counted every group-writable directory as others', so under umask 002 every file was still pinned. CopiedLinks::record now ignores a file with one name before touching the budget: it was counted against the pins though the tracker never keeps it (F4). Tests: a tar member is pinned in a directory others can write and not in a private one, a cpio member only when its file has other names (before: every one pinned); a copy whose file has one name takes no pin (mutation check: dropping the nlink guard fails it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inked to it -k skips a copy whose destination name is taken, and the ordinary path does so before copying, remembering nothing. But a name taken at the moment the copy was made -- after that check saw nothing there -- made do_copy_file return no copy, and CopiedLinks recorded the name with no file. The source's later names were then linked to that name by name, to whatever someone had put there. CopiedLinks::record now ignores a copy that was not made, as the ordinary -k path does; the later names are copied themselves. The tracker holds only copies actually made, so its entry is now (PathBuf, MadeFile) rather than an Option. Test: recording a -k copy that was not made leaves nothing to link later names to (before: the name was remembered with no file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The name -i gave a member was remembered for the rest of the run, keyed by the member's own name, and a link member naming that name was always resolved to it. A later member of the same name, extracted at its own name, is the one a link member then refers to -- and a later rename of it is the name to use. Links::named, called for every selected member, records the rename when -i gave one and otherwise drops any earlier rename of that name; a later rename overrides an earlier one. Test: a rename of f is resolved, ends when a later f keeps its own name, and a later rename overrides (mutation check: not dropping the earlier rename fails it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oot alone Two cp-chaintrust tests failed on macOS only: a_named_directory_reached_through_a_link_is_found_in_the_links_directory and a_named_path_is_trusted_only_through_links_the_user_vouches_for. Their temporary directories are under /var/folders, and /var is a symbolic link in /, which is root's. A named path is judged by the directories holding the links it follows, and for any effective user but root a directory not the user's own counted as one others may write: / took the whole path's trust away. So on macOS, cp -p and pax -p e by an ordinary user into an existing tree under /tmp or /var/folders always left the directories found alone and exited 1. The same held anywhere a found-directory chain or a named path went through a directory of root's -- on Linux, /lib on a merged-/usr system, or /proc/self. The rule change (trusted_owner, which dir_writers follows): while a chain has not gone into another user's tree (no above_owner), a directory may be its owner's alone when its owner is the effective user -- as before -- or root, for every effective user. Whether it is then root's alone is judged exactly as for the user's own: no other write permission, group write only for root's private group, and no ACL letting anyone else write. Nobody but root can create entries in such a directory, and root needs no trick to change anything. Unchanged: inside the tree of the one user root works for (above_owner set), every directory must still be that user's; and a root-owned directory never becomes that "other user" (link_as records the chain's owner only for a uid that is neither the effective user nor root). Test (Linux): / as the anchor for a user other than root, and the named path /proc/self -- a link in /proc, root's 0555 -- now trust what is found (before: LeaveAlone for both). The two macOS tests are correct as they stand under this rule: / is root's 0755, the only directory holding a link on their paths; plib checks clean for x86_64-apple-darwin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s link Every record of a file this run made -- the target member's, and one per link member to it (MadeFile::share), or per name of a cpio set (MadeFile::known) -- held its own copy of the file's ctime, and a link refreshed only the record it was made through. But a link changes the inode's ctime for every name. Since c352390 a file in a directory only the user writes is not pinned, and a link to it checks its ctime: tar members f, a -> f, b -> f, c -> a failed on c, "source file changed before it could be linked", unless the links fell in one clock tick -- and with multigrain ctime (Linux 6.13 and later) they never do. MadeFile's ctime is now one shared cell (Rc<Cell<_>>): share() and known() hand out the same cell, so a refresh through any record -- a link made, data written, the pin closed -- is seen by all of them. Test (race_tests): f, a -> f, b -> f, c -> a, each link in a later clock tick, are all linked to f (before: c failed as above). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er the link extract_hardlink records its name as holding the target's file only when it does once linked; under -k a name left alone keeps its record. But without -k, a name taken by someone else right after the link kept the record of whatever earlier member was there, as if that file still were. It now leaves a tombstone there (Record::Failed), as a member that leaves nothing this run can vouch for does: a later link member naming it fails rather than act on either file. Under -k the name, and its record, are left as they were. Test (race_tests): f, g, and a link member g -> f whose name is taken right after the link -- through the race_hook Linked point, now also reached by extract_hardlink once its link is made -- leaves g a tombstone (before: g's earlier record). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b601dbc ended an earlier -i rename when a later member of the same name was extracted at its own name. A later member turned away -- by a pattern, -u, or -i's skip -- is still the latest member of that name, which a link member naming it refers to; it was not extracted, so the earlier rename no longer applies either, and the link member is linked by name as for any member never extracted. Links::turned_away drops the rename. Renames are kept under a member's name after -s (and --strip-components), and a pattern or -u can turn a member away before -s is applied, so its name is taken from the archive and substituted again here. This runs only while there are renames, that is under -i. Test: a rename of f ends when a later f is turned away, and when a later member whose name -s makes f is (mutation check: not dropping it fails the test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6f58e35 found a turned-away member's name after -s with rename_member, which reports a `p` substitution's rename on standard error. A member -i's skip turned away, after -s had already renamed and reported it, reported its rename a second time; one a pattern or -u turned away, which -s never saw, reported a rename that was never made. Links::turned_away now substitutes without reporting, as a link target is (substitute_link_target), and strips --strip-components the same way. Test: -r -i -s ',x,y,p' over x1 (renamed at the prompt) and x2 (skipped) reports x2 >> y2 once (before: twice); with the pattern x1, x2 is never reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of shared ctime From the audit of 76be694..6f58e35, each where the rule it qualifies is described: - dir_writers: a root:root group-writable directory counts as root's alone when group root is root's private group, which reads the group's member list, not every account's primary gid -- on RHEL and Fedora, sync, shutdown, halt and operator have primary gid 0 and can write it, and it is now trusted for every user; - dir_writers: on macOS an ACL on a directory of root's is not read, as on the user's own; - ChainTrust::named: a link root's directory holds counts as vouched for, so a named path through it into another user's tree is judged by the link's holder alone; - MadeFile::linked: unpinned, the shared ctime is refreshed by name (lstat and an identity check); in a directory others can write, a file made again at the name with the same inode number in between would give its ctime to every record of the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

No description provided.