Skip to content

cp/rm/mv/ftw Updates - #732

Merged
jgarzik merged 71 commits into
mainfrom
updates
Oct 10, 2026
Merged

jgarzik merged 71 commits into
mainfrom
updates

Conversation

@jgarzik

@jgarzik jgarzik commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

No description provided.

jgarzik and others added 9 commits October 9, 2026 18:53
… 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>
@jgarzik
jgarzik requested a balanced review from Copilot October 9, 2026 19:36
@jgarzik jgarzik self-assigned this Oct 9, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 to OsString, quote all names via quote_bytes, and add a LeftBehind tracker plus DirAction::Declined/Gone and Walked enums to reproduce GNU's rules for when an emptied directory is re-prompted and when Directory not empty is reported.
  • mv/remove_moved: refuse D/., D/.., and link/ cross-filesystem sources before any copy, mirroring rename(2)'s EBUSY/ENOTDIR.
  • ftw: handle trailing-slash symlink operands in traverse_directory_at, gather long-path component identities lazily, and expose the shared is_empty_dir_fd; cp -v now 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.

Comment thread tree/common/pinned.rs Outdated
jgarzik and others added 17 commits October 9, 2026 19:52
…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>
jgarzik and others added 28 commits October 9, 2026 20:46
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>
@jgarzik
jgarzik merged commit 3a3d8fc into main Oct 10, 2026
20 checks passed
@jgarzik
jgarzik deleted the updates branch October 10, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants