Skip to content

libutil: delete store paths through directory handles on Windows - #16359

Open
awsmadi wants to merge 6 commits into
NixOS:masterfrom
awsmadi:pr/win-delete-readonly
Open

libutil: delete store paths through directory handles on Windows#16359
awsmadi wants to merge 6 commits into
NixOS:masterfrom
awsmadi:pr/win-delete-readonly

Conversation

@awsmadi

@awsmadi awsmadi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Rewritten to do what @Ericson2314 asked for: deletion relative to directory handles, so std::filesystem::remove_all is out of the picture entirely. Three commits, each of which builds on its own.

The previous version of this PR cleared the read-only attribute by path and kept remove_all. That is gone.

1. De-duplicate the non-accounting overload

Both platforms spelled deletePath(path) identically, so it moves to the portable file. That requires the accounting overload to be the primitive on both, which was already true on Unix and is now true on Windows. No behaviour change.

2. Handle-relative primitives on Windows

Win32 has no openat. NtCreateFile does, via OBJECT_ATTRIBUTES::RootDirectory, and windows/file-system-at.cc already wrapped it as a file-local ntOpenAt. That moves into nix::windows behind a private header so the deletion code can use it rather than growing a second copy, with a tryNtOpenAt alongside that reports absence as std::nullopt.

Also implements maybeFstatat for Windows. It has been declared in the portable file-system-at.hh all along with only a Unix definition, so any Windows caller would have failed to link; there were none.

Nothing calls this commit's code yet.

3. The walk

Mirrors unix/file-system.cc's _deletePath. One handle per entry carries classification, listing, the attribute change and the deletion — opening twice would resolve the name twice, which is the race being removed. Listing is GetFileInformationByHandleEx(FileFullDirectoryInfo) on the directory's own handle; names are collected before anything is deleted, since deleting entries while an enumeration of the same directory is in flight is not defined to visit each entry exactly once.

FILE_OPEN_REPARSE_POINT throughout, so a symlink is removed as a link and its target is untouched.

remove_all was not merely racy, it was wrong

The case that changed my mind about the shape of this. A tree containing a directory symlink cannot be deleted by remove_all at all:

remove_all(tree with a dir symlink)  -> removed=-1, ec=2 (No such file or directory), tree still present
descriptor-based walk                -> tree deleted, link removed as a link, target untouched

libstdc++'s std::filesystem on MinGW does not recognise reparse points — symlink_status calls a directory symlink a plain directory, with ec = 0. Filed separately as #16361; fixed in GCC trunk, in no released GCC.

On the read-only attribute

@xokdvium raised this, and it is worth being precise about. The Unix walk already relaxes permissions before recursing, unix/file-system.cc:201-212:

/* Make the directory accessible. */
if ((st.st_mode & PERM_MASK) != PERM_MASK)
    unix::fchmodatTryNoFollow(parentfd, name, st.st_mode | PERM_MASK);

Clearing FILE_ATTRIBUTE_READONLY is that same step written for Windows, and it is now done through the already-open handle rather than by path, so the object whose attribute changes is necessarily the one about to be deleted.

Why the attribute is there at all is a separate question I am not trying to settle here: canonicalisation chmods store contents to 0444, and chmod() on Windows is ::_wchmod, which turns a missing write bit into exactly that attribute. posix-fs-canonicalise.cc:27 sits above the #ifndef _WIN32 on line 31, so it runs on Windows. Whether it should is fair to ask, but a store path that is read-only on disk has to be removable either way.

bytesFreed

Was set to zero and discarded, so garbage collection on Windows reported freeing nothing however much it deleted — while gc.cc says "Rely on deletePath() accounting". Now reported, with the policy copied from Unix rather than invented: count a non-directory's size at one or two links, nothing at three or more.

Not using POSIX-semantics deletion, deliberately

FileDispositionInfoEx with FILE_DISPOSITION_FLAG_POSIX_SEMANTICS would unlink the name immediately rather than on last-handle-close. It needs a newer API level than the _WIN32_WINNT=0x0602 set in nix-meson-build-support/windows-version, whose comment says "We currently don't use any API which requires higher than this", so I did not reach for it. The difference does not matter here: each handle closes before its parent is deleted, so a child's name is already gone by then.

I found this the hard way — it compiled standalone and failed the real cross build, because my standalone check had not set that define.

Verification

Cross-built for x86_64-w64-mingw32 and exercised under Wine against the real nix::deletePath, not a reimplementation:

case result
read-only store tree (0444 files, 0555 dirs) deleted, bytesFreed = 30 for 3×10 bytes
tree containing a directory symlink deleted, link removed as a link, target survived
path that does not exist no-op, no throw
read-only plain file deleted

Rows two and four both fail on master.

Stated plainly: these measurements are under Wine, not on Windows hardware. The NtCreateFile, GetFileInformationByHandleEx and SetFileInformationByHandle behaviour relied on is documented rather than emulator-specific, but I have not run it on Windows.

@Ericson2314

Copy link
Copy Markdown
Member

In the second commit

void deletePath(const std::filesystem::path & path)
{
    uint64_t dummy;
    deletePath(path, dummy);
}

should go back to the portable part of the code, since it would now be duplicated.

@Ericson2314

Ericson2314 commented Aug 25, 2026

Copy link
Copy Markdown
Member

@awsmadi O bot that is nonetheless somewhat useful: I will probably not merge this PR until we can have a descriptor-based recursive deletion on Windows too. std::filesystem::remove_all is not trustworthy given TOCTOU stuff.

@xokdvium

Copy link
Copy Markdown
Contributor

Given that path canonicalisation isn't actually implemented on windows, this is not very worthwhile currently. Whatever chmod is doing is definitely not something we want to keep and carry along.

I'd suggest figuring that out first.

@awsmadi

awsmadi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks both. Taking the three points in turn, with a prototype result that I think changes the picture on the second one.

Descriptor-based deletion

Agreed, and I am implementing it. I prototyped the API sequence first to check it is actually available, cross-compiled for x86_64-w64-mingw32 and run under Wine, against a store-shaped tree (nested directories, FILE_ATTRIBUTE_READONLY files):

deleted: 3 files, 5 dirs, 0 errors
disposition: 8 via FileDispositionInfoEx (POSIX semantics), 0 via FileDispositionInfo
t gone?                     YES
outside/precious.txt alive? YES

Shape: open each child relative to the parent's HANDLE via NtCreateFile with RootDirectory set (the ntOpenAt already in windows/file-system-at.cc) and FILE_OPEN_REPARSE_POINT, enumerate with GetFileInformationByHandleEx(FileFullDirectoryInfo), clear the read-only attribute with SetFileInformationByHandle(FileBasicInfo), delete with FileDispositionInfoEx and FILE_DISPOSITION_POSIX_SEMANTICS, falling back to FileDispositionInfo for pre-1709. No path is re-resolved after the first open, so there is no window to race.

The part I did not expect: remove_all is not merely racy here, it is functionally wrong. A tree containing a directory symlink cannot be deleted by it at all — it fails with ENOENT and leaves the tree in place:

remove_all(tree with a dir symlink) -> removed=-1, ec=2 (No such file or directory), tree still present
descriptor-based walk              -> tree deleted, link removed as a link, target untouched

The cause is that libstdc++'s std::filesystem on MinGW does not recognise reparse points: symlink_status reports a directory symlink as a plain directory, with ec = 0. I filed that separately as #16361; it is fixed in GCC trunk but in no released GCC. So the descriptor-based version is not only the safer one, it is the only one that works on this input.

The duplicated overload

Agreed, will do — deletePath(path) moves to the portable file-system.cc and comes out of both platform files.

chmod, and the canonicalisation question

@xokdvium this is the part worth arguing about, and I think there is a reason to disagree.

The Unix walk already does exactly this. unix/file-system.cc:201-212, before recursing:

/* Make the directory accessible. */
const auto PERM_MASK = S_IRUSR | S_IWUSR | S_IXUSR;
if ((st.st_mode & PERM_MASK) != PERM_MASK)
    unix::fchmodatTryNoFollow(parentfd, name, st.st_mode | PERM_MASK);

Deletion already relaxes permissions to make a tree removable, and does it *at-relative and no-follow. Clearing FILE_ATTRIBUTE_READONLY through the handle is that same step written for Windows, not a new wart being carried along. In the descriptor-based version it is SetFileInformationByHandle on an already-open handle rather than a path-based SetFileAttributesW, which removes the objection I think you were actually making.

Worth separating from that: the attribute is there because canonicalisation chmods store contents to 0444, and chmod() on Windows is ::_wchmod, which per its documentation turns a missing write bit into exactly that attribute. posix-fs-canonicalise.cc:27 is above the #ifndef _WIN32 on line 31, so it runs on Windows. Whether it should is a fair question, and it is not one this PR settles.

On the larger point — you are right that Windows path canonicalisation is unresolved, and it is broader than deletion. AbsolutePath rejects any path without a root name, and the two overloads of canonStoreDir disagree about whether /nix/store is absolute (store-api.cc:56-68, POSIX rule hardcoded in one, is_absolute() delegated in the other). That already caused a real abort: #16363, where a POSIX-rooted SSL_CERT_FILE throws from a global constructor.

I have a write-up of that question and deliberately have not opened it as a separate issue, because the decision would then be spread across this PR, #16358, #16361 and a fourth thread. It seems more useful in one place. So, concretely, the question is: when Nix's logical POSIX path model and the host's is_absolute() disagree on Windows, which is authoritative? FilePathType in store-api.hh:96-150 already draws that distinction deliberately per store category, but NIX_STORE is process-global and feeds both, so no single value satisfies a LocalFSStore and a DummyStore at once. If you would rather that lived in its own issue I will open it; I did not want to fragment it by default.

I am not asking this PR to answer that. The deletion path has to work whatever the answer is, since a store path that is read-only on disk has to be removable either way.

Both platforms spelled it identically:

    void deletePath(const std::filesystem::path & path)
    {
        uint64_t dummy;
        deletePath(path, dummy);
    }

so it belongs in the portable file rather than in each. That means the
accounting overload has to be the primitive on both, which was already true on
Unix and is now true on Windows too.

No behaviour change.

Assisted-by: Claude Code (claude-opus-5)
@awsmadi
awsmadi force-pushed the pr/win-delete-readonly branch from 7a1eb55 to c858ffc Compare August 25, 2026 21:22
@awsmadi awsmadi changed the title libutil: make deleting store paths work on Windows libutil: delete store paths through directory handles on Windows Aug 25, 2026
Groundwork for a recursive deletion that never re-resolves a path.

Win32 has no `openat`. `NtCreateFile` does have one, via
`OBJECT_ATTRIBUTES::RootDirectory`, and `windows/file-system-at.cc` already
wrapped it as a file-local `ntOpenAt`. Move that into `nix::windows` proper and
declare it in a private header so the deletion code in `file-system.cc` can use
it too, rather than growing a second copy.

Add `tryNtOpenAt` alongside, which reports a missing object as `std::nullopt`
instead of throwing. That mirrors the `ENOENT`/`ENOTDIR` cases the Unix
`maybeFstatat` already treats as absence.

Then implement `maybeFstatat` for Windows. It has been declared in the portable
`file-system-at.hh` all along with only a Unix definition, so any Windows caller
would have failed to link; there were none. Opens no-follow, which is what
`AT_SYMLINK_NOFOLLOW` gives the Unix version, so a symlink is reported as
itself.

No behaviour change: nothing calls the new code yet.

Assisted-by: Claude Code (claude-opus-5)
@awsmadi
awsmadi force-pushed the pr/win-delete-readonly branch from c858ffc to d912253 Compare August 25, 2026 21:27
@awsmadi

awsmadi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Flagging an overlap I should have found earlier. @Ericson2314 pointed at #15244 on a different thread, and having read it: my second commit here largely duplicates work already in that PR, and does it less well.

#15244's windows/file-system-at.cc already has:

  • maybeNtOpenAt, returning outcome::unchecked<AutoCloseFD, NTSTATUS> — where I added tryNtOpenAt returning std::optional, throwing away the status
  • ntOpenAt promoted out of the anonymous namespace, which is the same move I made but without needing my private header
  • maybeFstatat and fstatat for Windows, taking OsCanonPath — where mine takes std::filesystem::path

OsCanonPath is the better signature by some distance. It is a newtype asserting a canonical relative path with no root name, which is precisely the precondition a handle-relative call has and which my version leaves to a comment and two asserts.

So commit 2 of this PR should go away in favour of #15244. I would rather say that than have someone review it.

Commit 3 is not duplicated. #15244 leaves windows/file-system.cc alone, and deletePath there is still:

std::filesystem::remove_all(path, ec);

with bytesFreed unset. So the descriptor-based walk, the read-only handling and the accounting are still the new thing here, and they sit naturally on top of #15244's at-layer rather than beside it.

What I would suggest

Land #15244 first, then reduce this PR to the deletion walk rebased onto it, using maybeNtOpenAt and OsCanonPath instead of my own primitives. That drops roughly a third of this diff and removes the private header entirely.

On reviving #15244, in case it is useful: I tried rebasing it onto current master. Its merge base is from 2026-04-23 and master is 800 commits past it, but the damage is small — two conflicting files, unix/file-system-at.cc and unix/file-system.cc, on the first of its two commits. I did not attempt the resolution, because getting the semantics right there is a judgement call about your design rather than a mechanical merge, and it is your PR. If you want it rebased I am glad to do it and put it somewhere you can look before it goes near your branch.

One caveat on that estimate: unix/file-system.cc is also a file this PR touches, so if both move at once they will want sequencing.

`std::filesystem::remove_all` re-resolves the path at every step, so each
component can be swapped between the check and the removal. Mirror the Unix
walk instead: hold a handle to the parent, and do every operation relative to
it, so a name is resolved exactly once.

That also fixes a case `remove_all` cannot do at all. A tree containing a
directory symlink fails to delete, reporting `ENOENT` and leaving the tree in
place, because libstdc++'s `std::filesystem` on MinGW does not recognise reparse
points -- `symlink_status` calls a directory symlink a plain directory with no
error set. Opening with `FILE_OPEN_REPARSE_POINT` removes the link as a link and
leaves its target alone.

Win32 has no `openat`; `NtCreateFile` does, via `OBJECT_ATTRIBUTES::RootDirectory`,
which the previous commit exposed as `ntOpenAt`. Listing is
`GetFileInformationByHandleEx(FileFullDirectoryInfo)` on the directory's own
handle. Deletion is `SetFileInformationByHandle` with `FileDispositionInfo`. The
`FileDispositionInfoEx` form with POSIX semantics would unlink the name
immediately rather than on last-handle-close, but it needs a newer API level
than the `_WIN32_WINNT=0x0602` this project sets, and the difference does not
matter here: each handle is closed before its parent is deleted, so a child's
name is already gone by then.

One handle per entry carries classification, listing, the attribute change and
the deletion. Opening twice would resolve the name twice, which is the race being
removed.

Names are collected before anything is deleted, since deleting entries while an
enumeration of the same directory is in flight is not defined to visit each entry
exactly once.

The read-only attribute is cleared through that handle. A file carrying it cannot
be deleted, and the store is full of them, because canonicalisation chmods store
contents to 0444 and `chmod()` on Windows is `::_wchmod`, which turns a missing
write bit into that attribute. This is the counterpart of the Unix walk relaxing
permissions with `fchmodatTryNoFollow` before recursing, and doing it through the
handle means the object whose attribute changes is necessarily the one about to
be deleted.

`bytesFreed` is now reported rather than left at zero. Policy copied from the
Unix implementation instead of invented: count a non-directory's size at one or
two links and nothing at three or more, two being assumed to mean an optimised
store entry. Garbage collection previously reported freeing nothing on Windows
however much it deleted, and `gc.cc` says "Rely on deletePath() accounting".

Per-entry failures are collected and rethrown at the end, so one undeletable
entry does not abandon the rest of the tree. Also Unix's behaviour.

Assisted-by: Claude Code (claude-opus-5)
@awsmadi

awsmadi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed a fix for a defect in this PR that I found by running the cross-built test suites, not
by review. It was mine, it was reachable, and it aborted a whole test binary.

deletePath as I wrote it opened with:

assert(path.is_absolute());

That assertion does not exist on master — master's Windows deletePath was
std::filesystem::remove_all(path, ec), which accepts anything. nix-fetchers-tests reaches
deletePath with an empty path while tearing down a skipped test, and path("").is_absolute()
is false, so the process died:

Assertion failed: path.is_absolute(), file .../windows/file-system.cc, line 250

Not a failing test — an abort at test 28 of 47, taking the remaining 19 with it.

The first fix was wrong, which is worth recording

I assumed the offending input was a relative or POSIX-rooted path, and replaced the assert with
std::filesystem::absolute(path). That produced:

filesystem error: cannot make absolute path: Invalid argument []
terminate called after throwing an instance of 'std::filesystem::filesystem_error'

The [] is the path: empty. So I had moved the failure rather than removed it, which is exactly the
shape I described on #16365 about the empty-root "fix" there. The real fix is an empty-path no-op,
matching what remove_all did, plus resolving genuinely relative paths instead of asserting about
them.

Verified

Built master + #16364 + this PR (#16364 is needed at all, or the suite dies during static
initialisation before reaching any of this) and ran nix-fetchers-tests.exe under Wine:

before after
tests run aborted at 28 of 47 47 of 47
passed 36 40
failed 10 6
recursively deleting … Permission denied 4 0
assert(path.is_absolute()) abort 1 0

The four that this PR fixes are GitUtilsTest.sink_basic,
GitUtilsTest.sink_replacing_empty_directory, GitUtilsTest.sink_hardlink_to_directory_root and
GitUtilsTest.peel_reference, all previously failing with
error: recursively deleting "C:\users\root\AppData\Local\Temp\...\test-git-repo": Permission denied
— which is the read-only-attribute case this PR exists to handle.

Of the 6 that remain, 5 are _NIX_TEST_UNIT_DATA environment variable is not set and are not
Windows-specific: the native Linux binary run the same way fails the identical 5. The last is
GitTest.submodulePeriodSupport, which is the ca-bundle.crt absolute-path issue (#16363) surfacing
in SetUp().

Combined branch used for the measurement, in case you want to reproduce:
awsmadi/nix at 95fa379eef0b502e4e2b2e436c51fd09505ba386.

@Ericson2314

Copy link
Copy Markdown
Member

Yes I think this PR should contain #15244 (or your rebased version of it) as a starting point.

@awsmadi

awsmadi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Restructured onto it. The series is now your two commits from #15244, rebased onto master, then mine on
top:

libutil: delete recursively through handles on Windows   (mine)
libutil: de-duplicate the non-accounting deletePath overload   (mine)
More file system function improvements                  (yours)
Introduce `OsFilename` and `OsCanonPath`, with tests    (yours)

My third commit, the one adding tryNtOpenAt and maybeFstatat, is dropped — that is the one I said
above duplicates your at-layer and does it worse. All four apply cleanly.

I have not pushed it here yet, because it does not build, and both remaining problems are in your
layer rather than mine, so I would rather ask than guess.

1. The handle-relative open is file-local. maybeNtOpenAt and ntOpenAt live inside an anonymous
namespace in windows/file-system-at.cc. My deletion walk lives in windows/file-system.cc, a
different translation unit, so it cannot see them — that is precisely why my dropped commit introduced
a private header. Either that header comes back (exporting yours rather than mine), or the walk moves
into file-system-at.cc beside them. Which would you prefer?

2. SymlinkNotAllowed::path as a variant still breaks five consumers. In
posix-source-accessor.cc, all inside catch (SymlinkNotAllowed & e), master does
anchor / e.path, parent / e.path and showPath(e.path), and none of those accept a
std::variant.

What I would write, if you want me to pick: prefixing is meaningful only for the CanonPath
alternative, so extract it when present and rethrow unchanged otherwise.

} catch (SymlinkNotAllowed & e) {
    if (auto * cp = std::get_if<CanonPath>(&e.path)) {
        auto full = anchor / *cp;
        throw SymlinkNotAllowed(full, "path '%s' (or its ancestor) is a symlink", showPath(full));
    }
    throw;  /* already a host path; there is nothing to anchor it to */
}

That keeps today's behavior exactly, since nothing on those paths throws the host-path alternative yet.
But it is your design, and if the intent was for the variant to be visited properly at each site then I
would rather write that instead.

@awsmadi

awsmadi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

I attempted the rebase of #15244 onto master to use as this PR's base. Reporting what it needs, because
one of the conflicts carries a silent-failure hazard that is worth knowing about whoever does it.

Master is 808 commits past the branch point. git rebase stops on the first of the two
commits, with five conflicted files:

  1. src/libutil/unix/file-system-at.cc (4 regions) -- master replaced raw ELOOP with the
    NIX_ERR_OPEN_SYMLINK macro (EMLINK on Windows, ELOOP elsewhere, file-system-at.hh:28-30) and
    added an ENOTDIR post-check via fstatat for O_DIRECTORY | O_NOFOLLOW on a trailing symlink.
  2. src/libutil/include/nix/util/source-accessor.hh -- master has CanonPath path; plus an anchor()
    override; the PR makes it std::variant<CanonPath, std::filesystem::path>. This is the design
    question I asked about earlier, and it is also a rebase conflict.
  3. src/libutil/include/nix/util/file-system-at.hh -- master documented the parameters on the same lines
    the PR retypes from const CanonPath & to const OsCanonPath &.
  4. src/libutil/unix/file-system.cc -- master now uses name.rel_c_str() and adds O_CLOEXEC.
  5. src/libstore/unix/build/derivation-builder.cc -- DerivationBuilderImpl has moved to
    src/libstore/unix/build/derivation-builder-impl.hh, so that hunk has no home where it lands.

The hazard, in (4)

CanonPath::rel_c_str() is assert(cs[0]); return &cs[1]; -- it strips the leading /, because
CanonPath always stores one. OsFilename::c_str() returns the std::filesystem::path directly, which
is already relative. Both are right for their own type, and the two are interchangeable at the call site
without a compile error:

// master
int fd = openat(parentfd, name.rel_c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
// #15244
int fd = openat(parentfd, name.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW);

Pair the accessor with the wrong type and the string handed to openat keeps its leading /, at which
point openat ignores parentfd and resolves from the filesystem root. In _deletePath that is a
directory escape rather than a crash, and it compiles clean. Master has ten-plus rel_c_str() call sites
that the type change touches, including posix-fs-canonicalise.cc:254,272 and fs-sink.cc:195,323, so
this wants an explicit audit rather than a mechanical find-and-replace.

I would rather not guess my way through ~1340 lines of your abstraction when one wrong pairing is
silent and lands in path-deletion code, and when (2) is still the open question. Two options, and I am
happy either way: rebase it yourself and I will stack this PR on the result, or say the word and I will
push my resolution to a branch for you to correct rather than to merge.

@awsmadi

awsmadi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

I went ahead and did the rebase, on the basis that a branch you can correct is more useful than another
question. It is pushed as wip/15244-rebase-for-review on my fork, head f699043d7 -- three commits:
your two, rebased with authorship preserved, plus one fixup: that makes it build.

Explicitly for correction rather than for merge. Four of the resolutions are choices about how
OsCanonPath meets the portability layer that landed after you wrote this, so please treat them as
proposals.

It builds:

native   nix-util, nix-store, nix-expr, nix-fetchers      0 errors
cross    nix-everything-x86_64-w64-mingw32                0 errors

The cross build matters because windows/file-system-at.cc is the 287-line half of your second commit
and a native build never touches it. Rather than trust the log, I checked the cross-built DLL's exports:
nix::createUnknownSymlinkAt(void*, nix::OsCanonPath const&, wstring const&),
nix::readLinkAt(void*, nix::OsCanonPath const&), and the Windows overload of
openFileEnsureBeneathNoSymlinks taking std::function<void(AutoCloseFD, OsCanonPath)>. No test suite
was run, native or Windows -- compilation only.

The two worth your attention

fs-sink.cc. Your createUnknownSymlinkAt refactor drops master's hooks->symlinkCreated(fd, name).
That hook is what does the ACL and permission work in posix-fs-canonicalise.cc, so losing it is a silent
behaviour change rather than a build failure. I restored it, converting for the CanonPath signature
since name is an OsFilename now. If dropping it was deliberate, say so and I will take it back out.

variant-wrapper.hh. Neither side works as-is. You factor the move pair into
FORCE_DEFAULT_MOVE_CONSTRUCTORS, so keeping either side of the conflicted line re-declares the move
constructor. Separately, CLASS_NAME(CLASS_NAME &) = default; is ill-formed unless a base or member has
a non-const copy constructor. I restructured it to expand to exactly master's four special members. Eight
or more wrapper types expand this macro, so it is worth a look.

On the open question

It answered itself: SymlinkNotAllowed::path has to be the variant, because the std::filesystem::path
constructors in your own commit require it. I added a reanchor helper that std::visits it and
re-anchors either alternative onto a CanonPath prefix, with CanonPath::root meaning "just convert".
That is what the five posix-source-accessor.cc consumers now use.

Two other things you may not have hit yet: DerivationBuilderImpl has moved to
derivation-builder-impl.hh, so that hunk of yours has no home where it lands and only the
writeBuilderFile signature change carries over; and moveFile no longer exists on master, with no
callers and no header declaration, so I did not re-add it.

Comment thread src/libutil/file-system.cc Outdated
Comment on lines +411 to +414
/**
* Both platforms implement the accounting overload; this wrapper is the same
* either way, so it lives here rather than being duplicated in each.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What's the point of a dozen comment in a .cc file which isn't part of the docs anyhow. Also doesn't seem like there's any need for a comment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair. It is in a .cc, so it renders nowhere, and the part of it worth keeping belongs on the declaration in file-system.hh instead. I moved it here only because deletePath stopped being Unix-only and I did not want the text to just vanish in the diff. I will put what is load-bearing on the header declaration and drop the rest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correcting myself: there is nothing left to move. I went back through the branch and the comment you flagged was

/**
 * Both platforms implement the accounting overload; this wrapper is the same
 * either way, so it lives here rather than being duplicated in each.
 */

which sat above the moved deletePath overload in ffeea5ae5 through 8578932aa and is already gone as of 6858ed68b. GitHub is showing this thread against the current head, where the anchor line is now the wrapper body itself, which is what led me to describe it as a comment still needing rehoming. It does not -- you were right, and it was deleted before you asked.

So: no change owed here. The remaining three points in your review I am acting on.

@xokdvium xokdvium left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This definitely needs more tests in libutil-tests? Esp the bits with making directories writable as needed?

Comment thread src/libutil/windows/file-system.cc Outdated
Comment on lines +85 to +87
* A file carrying it cannot be deleted, and the store is full of them:
* canonicalisation chmods store contents to 0444, and `chmod()` on Windows is
* `::_wchmod`, which turns a missing write bit into exactly this attribute.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think this actually makes much sense, considering that canonicalisation was never actually internationally thought out or implemented for windows. E.g. recent refractors to unix case basically made restorePath canonicalisation a no-op on windows.

So at the very least this comment isn't correct in the current state of things. Or maybe we can even drop the comment because it's pretty evident that it's what the existing deletePath does on Linux etc etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, and the specific thing I asserted is the part I had not checked. The comment leans on store canonicalisation being what produces these files, and if restorePath canonicalisation is effectively a no-op on Windows after the recent unix-case work then that justification does not hold.

What is load-bearing and does hold: chmod() on Windows is ::_wchmod, which maps a cleared write bit onto FILE_ATTRIBUTE_READONLY, and an object carrying it cannot be deleted. That is the mechanism the new tests exercise directly -- they create mode-0444 files, no store involved. I will narrow the comment to that and drop the canonicalisation claim rather than defend it.

Comment thread src/libutil/windows/file-system.cc Outdated
std::filesystem::remove_all(path, ec); // NOLINT(bugprone-unsafe-functions)
if (ec && ec != std::errc::no_such_file_or_directory)
throw SysError(ec.default_error_condition().value(), "recursively deleting %1%", PathFmt(path));
std::vector<std::wstring> names;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OsString?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes. This file is Windows-only so OsString is std::wstring here -- same type -- but OsString is what the rest of the tree spells it, so there is no reason for this one to differ. Switching it.

Comment thread src/libutil/windows/file-system.cc Outdated
for (auto & child : listByHandle(fd->get(), path))
deletePathAt(fd->get(), path / child, bytesFreed, ex);

clearReadOnly(fd->get());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this mean that attributes or clobbered on deletion failures? Doesn't seem right? Though this might already be the case with the posix implementation.

Probably needs tests for that or something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and your hedge is right too: POSIX already behaves this way.

If the disposition fails after we have cleared FILE_ATTRIBUTE_READONLY, the attribute stays cleared, so a failed deletePath leaves the tree more permissive than it found it. The Unix walk has exactly the same property -- fchmodatTryNoFollow relaxes the mode before recursing and nothing puts it back if the unlink then fails. So this is pre-existing cross-platform behaviour rather than something the Windows path introduces.

Restoring it would mean recording prior attributes per entry and reapplying them on every failure path. The POSIX side does not do that, so implementing it on Windows only would make the two diverge in a way that is harder to reason about than the current shared wart. I would rather leave the behaviour matched and say so in the comment. If you want it pinned rather than merely described I will add a test that asserts the attribute is left cleared after a failed delete, so a future change to either platform has to do it deliberately.

Comment thread src/libutil/windows/file-system.cc Outdated

/* Big enough that a typical directory needs one round trip, but the loop
below does not depend on that. */
std::vector<char> buf(64 * 1024);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are there any alignment requirements on the structure? Default allocator alignment is enough or not?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good question, and the current code gets the right answer for the wrong reason.

alignof(FILE_FULL_DIR_INFO) is 8 -- it has LARGE_INTEGER members. std::vector<char> is 1-byte aligned as a type, so the reinterpret_cast is not justified by the buffer's declared type. It happens to work because the storage comes from operator new, which guarantees __STDCPP_DEFAULT_NEW_ALIGNMENT__ (16 on x86-64), and subsequent entries are reached through NextEntryOffset, which the OS keeps 8-aligned.

So default allocator alignment is enough, but only via a guarantee the code never states, and it would break silently under an allocator that gave back 1-byte-aligned storage. I will make the requirement explicit at the declaration instead of leaving it incidental.

`has_root_name() && has_root_directory()`, so a relative path -- or a
POSIX-rooted one like `/tmp/x` -- is not absolute even when it names a real
file, and `remove_all` accepted those too. */
auto absPath = path.is_absolute() ? path : std::filesystem::absolute(path);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

absPath maybe? Or do we even need that? Is there anything like AT_FDCWD we could make use of?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is no AT_FDCWD analogue. NtCreateFile does take a RootDirectory handle, but a null one means "treat ObjectName as a fully qualified NT path", not "resolve against the process working directory", so there is nothing to hand a relative path to.

The absolute() is not there for the open, though. The next two lines take parent_path() and open the parent by name, and parent_path() of a bare relative filename is empty -- the assert(parentPath != absPath) below is what catches that. So the path has to be absolute before it is split, independently of the AT_FDCWD question. I will say that in the comment, since right now it only explains the is_absolute() half.

Comment on lines +250 to +254
/* An empty path is a no-op. The `std::filesystem::remove_all` this replaces
treated it as one, and callers depend on that: `nix-fetchers-tests` reaches
here with an empty path while tearing down a skipped test. */
if (path.empty())
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems like a bug then? Does AutoDelete do this or some other caller? Either way this seems like a busted API somewhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed that it points at a caller, and I owe you a correction: I cannot reproduce the caller my comment names, so the comment overclaims and I should not have written it that confidently.

AutoDelete is specifically not it, which I checked after reading your question. The default constructor sets del{false} and deletePath() guards on if (del), so a default-constructed one never forwards its empty _path. Move-assignment goes through swap, so the temporary in delTmpDir = AutoDelete(tmpDir, true) ends up holding del == false as well. Neither route reaches here.

The only caller I can actually demonstrate is the test I added asserting the no-op, which is circular as justification. What does survive checking is behaviour compatibility: std::filesystem::remove_all, which this replaces, treats an empty path as a no-op and returns 0, so throwing here would be a behaviour change on an input callers could previously pass.

The genuinely awkward part is that the Unix _deletePath asserts is_absolute() and aborts on that same input, so the two platforms already disagree about it and neither documents why. I would rather resolve that deliberately than leave an unexplained guard on one side. Do you want both to no-op, or both to reject, with the caller fixed?

awsmadi added a commit to awsmadi/nix that referenced this pull request Aug 27, 2026
Review feedback from xokdvium on NixOS#16359.

Adds a DeletePathTest fixture, modelled on MovePathTest:

- readOnlyFile / treeOfReadOnlyFiles pin the behaviour the Windows walk needs
  clearReadOnly for. `nix::chmod(f, 0444)` is `::_wchmod` there, which sets
  FILE_ATTRIBUTE_READONLY and blocks deletion outright. Verified they catch it:
  stubbing clearReadOnly to a no-op fails exactly these two under Wine and
  leaves the other three passing.
- nonWritableDirectory is the Unix counterpart, where the walk has to add write
  permission to the directory before unlinking its contents. Guarded to Unix
  because the read-only attribute is not honoured on Windows directories.
- reportsBytesFreed, nonexistentIsNoop cover the accounting and the
  already-gone early return on both platforms.
- emptyPathIsNoop is Windows-only. The Unix side asserts is_absolute() instead,
  so on a debug build the same call aborts rather than returning; that
  asymmetry predates this change and the test pins the side that guarantees it.

Also drops the doxygen comment above the shared deletePath wrapper: it is a
definition in a .cc, so it never reaches the docs, and the wrapper is
self-evident.
@awsmadi

awsmadi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Both done in 6858ed6. (Amended from a0e6126 to fold in a clang-format fixup — I had left a stray
double blank line, which pre-commit caught.)

The comment — removed. You're right that a /** */ block on a definition in a .cc never reaches the
docs, and the wrapper doesn't need explaining.

Tests — added a DeletePathTest fixture modelled on MovePathTest:

readOnlyFile           nix::chmod(f, 0444) then delete        both platforms
treeOfReadOnlyFiles    read-only files at three depths        both platforms
reportsBytesFreed      accounting equals st_size              both platforms
nonexistentIsNoop      the already-gone early return          both platforms
nonWritableDirectory   dir at 0500 containing a file          Unix only
emptyPathIsNoop        empty path returns rather than throws   Windows only

The two you asked about are the first two. nix::chmod is ::_wchmod on Windows, which turns a missing
write bit into FILE_ATTRIBUTE_READONLY, and that blocks deletion outright — so those two exercise
clearReadOnly directly. I checked they actually catch it rather than merely passing: stubbing
clearReadOnly to an early return fails exactly readOnlyFile and treeOfReadOnlyFiles under Wine
(rc=1) and leaves the other three green.

nonWritableDirectory is the Unix half of the same idea, where the walk has to add write permission to the
directory before it can unlink the contents. It's guarded to Unix because the read-only attribute isn't
honoured on Windows directories.

One thing the tests turned up that's worth knowing. emptyPathIsNoop had to be made Windows-only: the Unix
_deletePath asserts is_absolute(), so deletePath("") aborts there on a debug build instead of
returning. That asymmetry predates this PR — the Windows side handles it because nix-fetchers-tests
reaches it with an empty path while tearing down a skipped test — so I've pinned the behaviour on the side
that guarantees it and left a note rather than changing Unix here.

Verified 816 ran / 814 passed / 2 skipped natively, and the full nix-util-tests suite green under Wine.

Review feedback from xokdvium on NixOS#16359.

Adds a DeletePathTest fixture, modelled on MovePathTest:

- readOnlyFile / treeOfReadOnlyFiles pin the behaviour the Windows walk needs
  clearReadOnly for. `nix::chmod(f, 0444)` is `::_wchmod` there, which sets
  FILE_ATTRIBUTE_READONLY and blocks deletion outright. Verified they catch it:
  stubbing clearReadOnly to a no-op fails exactly these two under Wine and
  leaves the other three passing.
- nonWritableDirectory is the Unix counterpart, where the walk has to add write
  permission to the directory before unlinking its contents. Guarded to Unix
  because the read-only attribute is not honoured on Windows directories.
- reportsBytesFreed, nonexistentIsNoop cover the accounting and the
  already-gone early return on both platforms.
- emptyPathIsNoop is Windows-only. The Unix side asserts is_absolute() instead,
  so on a debug build the same call aborts rather than returning; that
  asymmetry predates this change and the test pins the side that guarantees it.

Also drops the doxygen comment above the shared deletePath wrapper: it is a
definition in a .cc, so it never reaches the docs, and the wrapper is
self-evident.
@awsmadi
awsmadi force-pushed the pr/win-delete-readonly branch from a0e6126 to 6858ed6 Compare August 27, 2026 14:15
The new DeletePathTest cases failed under Wine, and the cause was real rather
than a test artefact: read-only files could not be deleted at all there, which
is the case this walk exists to handle.

The delete open asked for FILE_WRITE_ATTRIBUTES so that clearReadOnly could drop
FILE_ATTRIBUTE_READONLY, which Windows requires before it honours DELETE. Wine
wants the opposite. It derives that attribute from the Unix write bits
(get_file_attributes in dlls/ntdll/unix/file.c) and refuses the open when the
mask asks for FILE_WRITE_ATTRIBUTES on a file lacking them, while allowing
DELETE. Measured on a mode-0444 file under Wine 11.0:

    FILE_READ_ATTRIBUTES  | SYNCHRONIZE   OK
    FILE_WRITE_ATTRIBUTES | SYNCHRONIZE   ERROR_ACCESS_DENIED
    DELETE                | SYNCHRONIZE   OK

So ask for it and fall back to the narrower mask when refused. DELETE access is
granted on a read-only file but the disposition is still refused while the
attribute is set, so where the handle route is unavailable the attribute is
cleared by path instead. That re-resolves the path, which this walk otherwise
avoids, and there is no handle-relative equivalent; the Unix side has the same
shape, relaxing permissions with fchmodatTryNoFollow by name rather than through
the object handle.

The full nix-util-tests suite now passes under Wine: 802 ran, 794 passed,
8 skipped.
@awsmadi

awsmadi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Worth reporting that the tests you asked for immediately earned their keep — they failed, and for a real
reason rather than a test artefact. Fixed in e71b787.

Read-only files could not be deleted at all under Wine, which is the case this walk exists to handle. The
delete open asked for FILE_WRITE_ATTRIBUTES so clearReadOnly could drop FILE_ATTRIBUTE_READONLY,
which Windows requires before it honours DELETE. Wine wants the opposite: it derives that attribute from
the Unix write bits (get_file_attributes in dlls/ntdll/unix/file.c) and refuses the open when the mask
asks for FILE_WRITE_ATTRIBUTES on a file lacking them, while allowing DELETE. Measured on a mode-0444
file under Wine 11.0:

FILE_READ_ATTRIBUTES  | SYNCHRONIZE   OK
FILE_WRITE_ATTRIBUTES | SYNCHRONIZE   ERROR_ACCESS_DENIED
DELETE                | SYNCHRONIZE   OK

So it now asks for FILE_WRITE_ATTRIBUTES and falls back to the narrower mask when refused. That is not
sufficient on its own — DELETE access is granted on a read-only file, but the disposition is still
refused while the attribute is set — so where the handle route is unavailable the attribute is cleared by
path instead.

That by-path call re-resolves the path, which this walk otherwise exists to avoid, and I could not find a
handle-relative equivalent: setting FileBasicInformation needs exactly the access Wine is withholding. It
does at least match the Unix side, which relaxes permissions with fchmodatTryNoFollow by name rather than
through the object handle. If you would rather keep the walk strictly handle-only and accept that read-only
deletion is broken under Wine, say so and I will gate it instead — but that would leave the feature
untested in CI, since Windows CI is Wine.

nix-util-tests under Wine is now green: 802 ran, 794 passed, 8 skipped.

Worth noting for anyone testing this by hand: running the test binary directly as root hides the bug
entirely, because root bypasses the permission check. It only shows up inside the nix build sandbox as
nixbld, which is what CI does — nix build --file ci/gha/tests/windows.nix unitTests.nix-util-tests.

- Narrow the read-only comment to the mechanism that holds. It claimed store
  canonicalisation produces these files, which was not verified and may not
  hold on Windows at all; what does hold is that `chmod()` is `::_wchmod`,
  which maps a cleared write bit onto `FILE_ATTRIBUTE_READONLY`.
- Use `OsString` rather than spelling `std::wstring` directly.
- Carry the `FILE_FULL_DIR_INFO` alignment requirement in the buffer's element
  type instead of relying on `operator new` over-aligning a `char` buffer.
@awsmadi

awsmadi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@xokdvium thanks for the review, and sorry for the delay in answering it -- my thread monitoring keyed off "comments newer than the last one I handled", so once I commented on an unrelated PR your review stopped registering as outstanding. That is fixed; it is why all seven answers arrived at once.

I have replied in each thread. e5c636e7d now carries the three that were code changes:

  • The read-only comment no longer claims store canonicalisation produces these files. You were right that I had not checked it, and it may not hold on Windows at all. It now states only the mechanism that does hold, that chmod() is ::_wchmod and maps a cleared write bit onto FILE_ATTRIBUTE_READONLY.
  • OsString instead of spelling std::wstring directly, and sizeof(OsChar) in the name construction.
  • The FILE_FULL_DIR_INFO alignment requirement is now carried by the buffer's element type. Your instinct was right and the old code was correct only by accident: alignof(FILE_FULL_DIR_INFO) is 8, std::vector<char> is 1-byte aligned as a type, and it worked purely because operator new hands back something more aligned than it has to at that type.

Two did not need a code change and I have said why in-thread. The .cc doc comment you flagged was already deleted before you asked -- GitHub is anchoring that thread on the current head where the line is now the function body. And the absolute() call stays, because parent_path() is taken from it two lines down and there is no AT_FDCWD equivalent on Win32 to avoid it.

Two are questions back to you rather than changes:

  • Attributes left cleared after a failed delete. Confirmed, and the Unix walk already behaves the same way via fchmodatTryNoFollow. I would rather keep the two platforms matched and document it than fix Windows alone. Say the word if you want a test pinning it.
  • The empty-path guard. I owe you a correction here: I could not reproduce the caller my comment named, and AutoDelete is specifically not it. So the guard's only real justification is that remove_all, which this replaces, treats an empty path as a no-op, while the Unix _deletePath asserts is_absolute() and aborts on the same input. The platforms already disagree and neither says why. Do you want both to no-op, or both to reject with the caller fixed?

Verified before pushing: checks.x86_64-linux.pre-commit passes all seven hooks, the x86_64-w64-mingw32 cross build succeeds, and nix-util-tests under Wine runs 802 tests with 794 passing and none failing, DeletePathTest included at 5 of 5 -- five rather than six on Windows because nonWritableDirectory is #ifndef _WIN32.

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.

3 participants