Skip to content

Commit d912253

Browse files
committed
libutil: delete recursively through handles on Windows
`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)
1 parent 9eb4d68 commit d912253

1 file changed

Lines changed: 186 additions & 4 deletions

File tree

src/libutil/windows/file-system.cc

Lines changed: 186 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#include "nix/util/file-system.hh"
22
#include "nix/util/logging.hh"
33
#include "nix/util/signals.hh"
4+
#include "nix/util/canon-path.hh"
5+
#include "nix/util/util.hh"
6+
#include "file-system-at-private.hh"
47

58
#define WIN32_LEAN_AND_MEAN
69
#include <windows.h>
@@ -74,14 +77,193 @@ std::filesystem::path defaultTempDir()
7477
return std::filesystem::path(buf);
7578
}
7679

80+
namespace {
81+
82+
/**
83+
* Clear `FILE_ATTRIBUTE_READONLY` through an already-open handle.
84+
*
85+
* A file carrying it cannot be deleted, and the store is full of them:
86+
* canonicalisation chmods store contents to 0444, and `chmod()` on Windows is
87+
* `::_wchmod`, which turns a missing write bit into exactly this attribute.
88+
*
89+
* This is the counterpart of the Unix walk relaxing permissions with
90+
* `fchmodatTryNoFollow` before it recurses. Doing it through the handle rather
91+
* than by path means the object whose attribute is cleared is necessarily the
92+
* one about to be deleted.
93+
*
94+
* The attribute is not honoured on directories, so in practice this only
95+
* matters for files, but it is harmless to apply uniformly.
96+
*/
97+
void clearReadOnly(Descriptor fd)
98+
{
99+
FILE_BASIC_INFO basic;
100+
if (!GetFileInformationByHandleEx(fd, FileBasicInfo, &basic, sizeof(basic)))
101+
return; /* Leave it; the deletion below will report the real problem. */
102+
103+
if (!(basic.FileAttributes & FILE_ATTRIBUTE_READONLY))
104+
return;
105+
106+
basic.FileAttributes &= ~FILE_ATTRIBUTE_READONLY;
107+
/* Clearing the last attribute leaves zero, which is not a valid value to
108+
set; `FILE_ATTRIBUTE_NORMAL` is how you say "no attributes". */
109+
if (basic.FileAttributes == 0)
110+
basic.FileAttributes = FILE_ATTRIBUTE_NORMAL;
111+
112+
SetFileInformationByHandle(fd, FileBasicInfo, &basic, sizeof(basic));
113+
}
114+
115+
/**
116+
* Delete through an already-open handle, so the name is never resolved twice.
117+
*
118+
* This marks the object for deletion on last-handle-close rather than unlinking
119+
* the name immediately. `FileDispositionInfoEx` with
120+
* `FILE_DISPOSITION_FLAG_POSIX_SEMANTICS` would do the latter, but it needs a
121+
* newer API level than the `_WIN32_WINNT=0x0602` this project sets, and the
122+
* distinction does not matter here: the caller closes the handle before moving
123+
* on, so a child's name is gone before its parent is deleted.
124+
*
125+
* @return whether the object was marked for deletion.
126+
*/
127+
bool deleteByHandle(Descriptor fd)
128+
{
129+
FILE_DISPOSITION_INFO disposition{};
130+
disposition.DeleteFile = TRUE;
131+
return SetFileInformationByHandle(fd, FileDispositionInfo, &disposition, sizeof(disposition));
132+
}
133+
134+
/**
135+
* List a directory through its own handle.
136+
*
137+
* The names are collected rather than acted on as they arrive, because deleting
138+
* entries while an enumeration of the same directory is in flight is not
139+
* defined to visit each entry exactly once.
140+
*/
141+
std::vector<std::wstring> listByHandle(Descriptor fd, const std::filesystem::path & path)
142+
{
143+
std::vector<std::wstring> names;
144+
145+
/* Big enough that a typical directory needs one round trip, but the loop
146+
below does not depend on that. */
147+
std::vector<char> buf(64 * 1024);
148+
149+
while (true) {
150+
checkInterrupt();
151+
152+
if (!GetFileInformationByHandleEx(fd, FileFullDirectoryInfo, buf.data(), buf.size())) {
153+
auto lastError = GetLastError();
154+
if (lastError == ERROR_NO_MORE_FILES)
155+
break;
156+
throw windows::WinError(lastError, "reading directory %1%", PathFmt(path));
157+
}
158+
159+
auto * info = reinterpret_cast<FILE_FULL_DIR_INFO *>(buf.data());
160+
while (true) {
161+
std::wstring name(info->FileName, info->FileNameLength / sizeof(wchar_t));
162+
if (name != L"." && name != L"..")
163+
names.push_back(std::move(name));
164+
if (info->NextEntryOffset == 0)
165+
break;
166+
info = reinterpret_cast<FILE_FULL_DIR_INFO *>(reinterpret_cast<char *>(info) + info->NextEntryOffset);
167+
}
168+
}
169+
170+
return names;
171+
}
172+
173+
/**
174+
* Recursively delete `name` within the directory `parentFd` refers to.
175+
*
176+
* Mirrors the Unix `_deletePath`: every step is relative to a directory handle,
177+
* so no path is resolved a second time and there is no window in which a
178+
* component could be replaced. Reparse points are opened rather than followed,
179+
* so a symlink is removed as a link and its target is left alone.
180+
*
181+
* Errors deleting individual entries are collected in `ex` rather than thrown
182+
* immediately, so that one undeletable entry does not abandon the rest of the
183+
* tree. This too matches Unix.
184+
*/
185+
void deletePathAt(
186+
Descriptor parentFd, const std::filesystem::path & path, uint64_t & bytesFreed, std::exception_ptr & ex)
187+
{
188+
checkInterrupt();
189+
190+
auto name = path.filename().native();
191+
192+
/* One handle, carrying everything the rest of this function needs:
193+
classification, listing, clearing the attribute, and the deletion. Two
194+
opens would mean resolving `name` twice, which is the race this is
195+
written to avoid. */
196+
auto fd = windows::tryNtOpenAt(
197+
parentFd,
198+
name,
199+
DELETE | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | SYNCHRONIZE,
200+
FILE_OPEN_REPARSE_POINT);
201+
if (!fd)
202+
return; /* Already gone. */
203+
204+
FILE_BASIC_INFO basic;
205+
if (!GetFileInformationByHandleEx(fd->get(), FileBasicInfo, &basic, sizeof(basic)))
206+
throw windows::WinError("getting attributes of %1%", PathFmt(path));
207+
208+
bool isDir = (basic.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
209+
bool isReparsePoint = (basic.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
210+
211+
if (!isDir) {
212+
/* Will deleting this actually free space? Same policy as Unix: count it
213+
at one or two links, nothing at three or more, on the assumption that
214+
two means an optimised store entry. */
215+
FILE_STANDARD_INFO standard;
216+
if (GetFileInformationByHandleEx(fd->get(), FileStandardInfo, &standard, sizeof(standard))
217+
&& standard.NumberOfLinks <= 2)
218+
bytesFreed += static_cast<uint64_t>(standard.EndOfFile.QuadPart);
219+
}
220+
221+
/* Descend into real directories only. A directory symlink or junction is
222+
deleted as itself. */
223+
if (isDir && !isReparsePoint)
224+
for (auto & child : listByHandle(fd->get(), path))
225+
deletePathAt(fd->get(), path / child, bytesFreed, ex);
226+
227+
clearReadOnly(fd->get());
228+
229+
if (!deleteByHandle(fd->get())) {
230+
auto lastError = GetLastError();
231+
if (lastError == ERROR_FILE_NOT_FOUND || lastError == ERROR_PATH_NOT_FOUND)
232+
return;
233+
try {
234+
throw windows::WinError(lastError, "cannot delete %1%", PathFmt(path));
235+
} catch (...) {
236+
if (!ex)
237+
ex = std::current_exception();
238+
else
239+
ignoreExceptionExceptInterrupt();
240+
}
241+
}
242+
}
243+
244+
} // namespace
245+
77246
void deletePath(const std::filesystem::path & path, uint64_t & bytesFreed)
78247
{
79248
bytesFreed = 0;
80249

81-
std::error_code ec;
82-
std::filesystem::remove_all(path, ec); // NOLINT(bugprone-unsafe-functions)
83-
if (ec && ec != std::errc::no_such_file_or_directory)
84-
throw SysError(ec.default_error_condition().value(), "recursively deleting %1%", PathFmt(path));
250+
assert(path.is_absolute());
251+
auto parentPath = path.parent_path();
252+
assert(parentPath != path);
253+
254+
auto parentFd = openDirectory(parentPath);
255+
if (!parentFd) {
256+
if (GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND)
257+
return;
258+
throw windows::WinError("opening directory %1%", PathFmt(parentPath));
259+
}
260+
261+
std::exception_ptr ex;
262+
263+
deletePathAt(parentFd.get(), path, bytesFreed, ex);
264+
265+
if (ex)
266+
std::rethrow_exception(ex);
85267
}
86268

87269
std::filesystem::path descriptorToPath(Descriptor handle)

0 commit comments

Comments
 (0)