Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

std.fs: Get some more rename tests passing on Windows #18632

Closed
wants to merge 1 commit into from

Conversation

squeek502
Copy link
Collaborator

@squeek502 squeek502 commented Jan 21, 2024

Most of these tests were made to pass by #16717, but there were a few loose ends to tie up with regards to the 'fallback' behavior (when FILE_RENAME_POSIX_SEMANTICS either isn't available or isn't supported by the underlying filesystem).

We now do a bit of extra work to get POSIX-like renaming in the fallback path. This feels a bit bad, but the Windows behavior around renaming a directory to the path of a file (it succeeds and replaces the file with the directory) sort of forces our hand, and allows making the rest of the fallback conform to POSIX rename behavior without too much additional trouble.

Closes #6364

Most of these tests were made to pass by ziglang#16717, but there were a few loose ends to tie up with regards to the 'fallback' behavior (when FILE_RENAME_POSIX_SEMANTICS either isn't available or isn't supported by the underlying filesystem).

We now do a bit of extra work to get POSIX-like renaming in the fallback path.

Closes ziglang#6364
@Jarred-Sumner
Copy link
Contributor

These calls to stat() are expensive (FileAllInformation) and may not be necessary

I find cosmopolitan libc's Windows implementations of posix functions to be useful points of reference

Here's their renameat:

https://github.com/jart/cosmopolitan/blob/cb19e172da4072f531e86bdcc4d401bde311e612/libc/calls/renameat-nt.c#L31-L98

Instead of FileAllInformation, since only kind is used, NtQueryAttributesFile can be used (or the wrapper)

This also skips opening the file for that case - as internally it sounds like it does something like O_PATH on Linux where it doesn't "fully" open with the same privileges as ordinary handles

@squeek502
Copy link
Collaborator Author

squeek502 commented Jan 22, 2024

Thanks for the info/link! I didn't realize NtQueryAttributesFile could be called without an open handle, so that's definitely something to look into.

Something I haven't investigated yet (but have been meaning to) is the behavior with regards to symlinks. Will need to see how NtQueryAttributesFile handles them, and also just generally need to figure out what the intended behavior actually is.

EDIT: rename(2) behavior:

If oldpath refers to a symbolic link the link is renamed; if newpath refers to a symbolic link the link will be overwritten.

I'll mess around a bit and try to figure things out a bit more. Would also be interesting to benchmark something like cosmopoliation libc's implementation against the Windows FILE_RENAME_POSIX_SEMANTICS implementation.

Comment on lines +2855 to +2861
// FileRenameInformation has surprising behavior around renaming a directory to the
// path of a file: it succeeds and replaces the file with the directory.
//
// To avoid this behavior and instead return error.NotDir, we (unfortunately) have
// to query the type of both the source and destination. However, doing so also
// allows us to return error.IsDir/error.PathAlreadyExists in cases where NtSetInformationFile
// with FileRenameInformation would have only returned a generic ACCESS_DENIED.
Copy link
Member

Choose a reason for hiding this comment

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

I think this is kind of handy behavior by the kernel actually. Before going through with this fallback mechanism, I think it would be worth looking into something like this:

  • Remove std.os.renameatW. Don't try to provide a posix-style API for this function on Windows.
  • Make std.os.rename give a compile error if called on Windows for the same reason.
  • Update std.fs and other former callsites to renameatW to directly use the Windows API rather than going through this posix layer.
  • If necessary, make changes to the API of std.fs operations, taking into account our new understanding of what the Windows kernel is capable of. Define the API such that such a fallback mechanism is not needed, and this logic can be deleted. Sorry, I know you just did a bunch of creative work to figure out how to make it work. The goal however is to make the fs API to minimize such logic as this while still being useful and providing cross platform abstractions.

Copy link
Collaborator Author

@squeek502 squeek502 Jan 22, 2024

Choose a reason for hiding this comment

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

I'm having trouble imagining what the usage of the proposed API would be like. If I'm writing cross-platform code, fs.rename/Dir.rename having quite different properties on different platforms seems like it'd make for a rather hard-to-use API, since any relevant properties can't actually be relied on to be consistent across platforms. For example, if you write your code such that it can take advantage of being able to rename a directory onto an existing file, then that property will only hold on Windows, so you'd need to do something else for non-Windows platforms at every callsite of rename. To me, it seems like this would just sort-of move the burden of creating a cross-platform abstraction to the users/callsites of rename (and force users to keep in their heads all the different semantics for each platform).

Maybe it would make sense to split rename into a POSIX-like version and a "native" version? This would still give access to consistent cross-platform rename semantics while also providing a public API for the most syscall-optimal rename implementation for each platform.

Copy link
Member

@andrewrk andrewrk Jan 22, 2024

Choose a reason for hiding this comment

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

Mainly, one would be aware of the differences in platforms and then take the following actions:

  • Rely on only the ability for a renamed file to overwrite an existing file
  • Not rely on a directory overwriting a file to produce an error

This is a reasonable set of requirements, still allows for useful abstraction, and eliminates the fallback code from being needed at all.

Most users will not need this fallback logic, and the ones that do should absolutely have this logic pushed up into the application domain.

Maybe it would make sense to split rename into a POSIX-like version and a "native" version?

This is the intention of std.os.rename (related: #5019). My suggestion here is to make this be one of the functions that does not attempt a posix compatibility layer on Windows, since it's not really a directly lowerable operation.

I suppose we could consider trying hard to provide posix compatibilty layers for windows, but I think it would result in better software in practice if users were strongly encouraged instead towards structuring their projects to avoid not-strictly-necessary compatibility layers.

Copy link
Collaborator Author

@squeek502 squeek502 Jan 22, 2024

Choose a reason for hiding this comment

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

Just to give a complete picture, these are all the known differences between Windows/POSIX rename when not using FILE_RENAME_POSIX_SEMANTICS (and ignoring symlinks, not tested the symlink behavior yet; I expect that to be another can of worms):

  • Renaming a directory onto an existing file succeeds on Windows, fails with ENOTDIR on POSIX systems
  • Renaming a directory onto an existing empty directory succeeds on POSIX systems, fails with ACCESS_DENIED on Windows
  • Renaming a directory onto an existing non-empty directory fails on POSIX systems with ENOTEMPTY, but fails on Windows with ACCESS_DENIED
  • Renaming a file onto any existing directory fails on POSIX systems with EISDIR, but fails on Windows with ACCESS_DENIED

I also did some very basic/preliminary benchmarking out of curiosity (not really relevant to the API design question, just potentially interesting on its own):

Benchmark code
const std = @import("std");
const builtin = @import("builtin");

const fd_t = std.os.fd_t;
const windows = std.os.windows;
const RenameError = std.os.RenameError;
const MAX_PATH_BYTES = std.fs.MAX_PATH_BYTES;

const Implementation = enum {
    /// Windows POSIX rename implementation via FILE_RENAME_POSIX_SEMANTICS
    windows_posix_rename_semantics,
    /// Custom POSIX rename implementation via NtQueryAttributesFile/DeleteFile/FileRenameInformation
    emulated,
    /// Windows rename via FileRenameInformation, no POSIX emulation
    native,
    /// Windows rename via FileRenameInformationEx and FILE_RENAME_IGNORE_READONLY_ATTRIBUTE,
    /// no POSIX emulation
    native_ex,

    pub fn isNative(self: Implementation) bool {
        return self == .native or self == .native_ex;
    }
};

pub fn main() !void {
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const implementations = [_]Implementation{ .windows_posix_rename_semantics, .emulated, .native, .native_ex };
    inline for (implementations) |implementation| {
        std.debug.print("\n{s}\n", .{@tagName(implementation)});
        var timer = try std.time.Timer.start();
        const iterations = 1000;
        for (0..iterations) |_| {
            try testRenameFiles(implementation, tmp.dir);
        }
        std.debug.print("rename files: {}ms\n", .{timer.lap() / std.time.ns_per_ms});
        for (0..iterations) |_| {
            try testRenameDirectories(implementation, tmp.dir);
        }
        std.debug.print("rename dirs: {}ms\n", .{timer.lap() / std.time.ns_per_ms});
        for (0..iterations) |_| {
            try testRenameOntoEmptyDir(implementation, tmp.dir);
        }
        std.debug.print("rename onto empty: {}ms\n", .{timer.lap() / std.time.ns_per_ms});
        for (0..iterations) |_| {
            try testRenameOntoNonEmptyDir(implementation, tmp.dir);
        }
        std.debug.print("rename onto non-empty: {}ms\n", .{timer.lap() / std.time.ns_per_ms});
        for (0..iterations) |_| {
            try testRenameFileOntoDir(implementation, tmp.dir);
        }
        std.debug.print("rename file onto dir: {}ms\n", .{timer.lap() / std.time.ns_per_ms});
        for (0..iterations) |_| {
            try testRenameDirOntoFile(implementation, tmp.dir);
        }
        std.debug.print("rename dir onto file: {}ms\n", .{timer.lap() / std.time.ns_per_ms});
    }
}

fn testRenameFiles(comptime implementation: Implementation, dir: std.fs.Dir) !void {
    const missing_file_path = "missing_file_name";
    const something_else_path = "something_else";

    try std.testing.expectError(
        error.FileNotFound,
        rename(implementation, dir, missing_file_path, something_else_path),
    );

    // Renaming files
    const test_file_name = "test_file";
    const renamed_test_file_name = "test_file_renamed";
    var file = try dir.createFile(test_file_name, .{ .read = true });
    file.close();
    try rename(implementation, dir, test_file_name, renamed_test_file_name);

    // Ensure the file was renamed
    try std.testing.expectError(error.FileNotFound, dir.openFile(test_file_name, .{}));
    file = try dir.openFile(renamed_test_file_name, .{});
    file.close();

    // Rename to self succeeds
    try rename(implementation, dir, renamed_test_file_name, renamed_test_file_name);

    // Rename to existing file succeeds
    const existing_file_path = "existing_file";
    var existing_file = try dir.createFile(existing_file_path, .{ .read = true });
    existing_file.close();
    try rename(implementation, dir, renamed_test_file_name, existing_file_path);

    try std.testing.expectError(error.FileNotFound, dir.openFile(renamed_test_file_name, .{}));
    file = try dir.openFile(existing_file_path, .{});
    file.close();

    try dir.deleteTree(test_file_name);
    try dir.deleteTree(renamed_test_file_name);
    try dir.deleteTree(existing_file_path);
}

fn testRenameDirectories(comptime implementation: Implementation, dir: std.fs.Dir) !void {
    const test_dir_path = "test_dir";
    const test_dir_renamed_path = "test_dir_renamed";

    // Renaming directories
    try dir.makeDir(test_dir_path);
    try rename(implementation, dir, test_dir_path, test_dir_renamed_path);

    // Ensure the directory was renamed
    {
        try std.testing.expectError(error.FileNotFound, dir.openDir(test_dir_path, .{}));
        var renamed_dir = try dir.openDir(test_dir_renamed_path, .{});
        defer renamed_dir.close();

        // Put a file in the directory
        var file = try renamed_dir.createFile("test_file", .{ .read = true });
        defer file.close();
    }

    const test_dir_renamed_again_path = "test_dir_renamed_again";
    try rename(implementation, dir, test_dir_renamed_path, test_dir_renamed_again_path);

    // Ensure the directory was renamed and the file still exists in it
    {
        try std.testing.expectError(error.FileNotFound, dir.openDir(test_dir_renamed_path, .{}));
        var renamed_dir = try dir.openDir(test_dir_renamed_again_path, .{});
        defer renamed_dir.close();
        var file = try renamed_dir.openFile("test_file", .{});
        defer file.close();
    }

    try dir.deleteTree(test_dir_path);
    try dir.deleteTree(test_dir_renamed_path);
    try dir.deleteTree(test_dir_renamed_again_path);
}

fn testRenameOntoEmptyDir(comptime implementation: Implementation, dir: std.fs.Dir) !void {
    const test_dir_path = "test_dir";
    const target_dir_path = "empty_dir_path";
    try dir.makeDir(test_dir_path);
    try dir.makeDir(target_dir_path);
    if (implementation.isNative()) {
        rename(implementation, dir, test_dir_path, target_dir_path) catch |err| switch (err) {
            error.AccessDenied => {},
            else => return err,
        };
    } else {
        try rename(implementation, dir, test_dir_path, target_dir_path);

        try std.testing.expectError(error.FileNotFound, dir.openDir(test_dir_path, .{}));
        var renamed_dir = try dir.openDir(target_dir_path, .{});
        renamed_dir.close();
    }

    try dir.deleteTree(test_dir_path);
    try dir.deleteTree(target_dir_path);
}

fn testRenameOntoNonEmptyDir(comptime implementation: Implementation, dir: std.fs.Dir) !void {
    const test_dir_path = "test_dir";
    const target_dir_path = "non_empty_dir_path";
    try dir.makeDir(test_dir_path);

    {
        var target_dir = try dir.makeOpenPath(target_dir_path, .{});
        defer target_dir.close();
        var file = try target_dir.createFile("test_file", .{ .read = true });
        defer file.close();
    }

    // Rename should fail with PathAlreadyExists if target_dir is non-empty
    try std.testing.expectError(
        if (implementation.isNative()) error.AccessDenied else error.PathAlreadyExists,
        rename(implementation, dir, test_dir_path, target_dir_path),
    );

    // Ensure the directory was not renamed
    var test_dir = try dir.openDir(test_dir_path, .{});
    test_dir.close();

    try dir.deleteTree(test_dir_path);
    try dir.deleteTree(target_dir_path);
}

fn testRenameFileOntoDir(comptime implementation: Implementation, dir: std.fs.Dir) !void {
    const test_file_path = "test_file";
    const test_dir_path = "test_dir";

    var file = try dir.createFile(test_file_path, .{ .read = true });
    file.close();
    try dir.makeDir(test_dir_path);
    try std.testing.expectError(
        if (implementation.isNative()) error.AccessDenied else error.IsDir,
        rename(implementation, dir, test_file_path, test_dir_path),
    );

    try dir.deleteTree(test_file_path);
    try dir.deleteTree(test_dir_path);
}

fn testRenameDirOntoFile(comptime implementation: Implementation, dir: std.fs.Dir) !void {
    const test_file_path = "test_file";
    const test_dir_path = "test_dir";

    var file = try dir.createFile(test_file_path, .{ .read = true });
    file.close();
    try dir.makeDir(test_dir_path);
    if (implementation.isNative()) {
        try rename(implementation, dir, test_dir_path, test_file_path);
    } else {
        try std.testing.expectError(
            error.NotDir,
            rename(implementation, dir, test_dir_path, test_file_path),
        );
    }

    try dir.deleteTree(test_file_path);
    try dir.deleteTree(test_dir_path);
}

fn rename(comptime implementation: Implementation, dir: std.fs.Dir, old_path: []const u8, new_path: []const u8) RenameError!void {
    const old_path_w = try windows.sliceToPrefixedFileW(dir.fd, old_path);
    const new_path_w = try windows.sliceToPrefixedFileW(dir.fd, new_path);
    const func = switch (implementation) {
        .windows_posix_rename_semantics => renameWindowsPosixSemantics,
        .emulated => renameEmulatedPosix,
        .native => renameNative,
        .native_ex => renameNativeEx,
    };
    return func(dir.fd, old_path_w.span(), dir.fd, new_path_w.span(), windows.TRUE);
}

pub fn renameWindowsPosixSemantics(
    old_dir_fd: fd_t,
    old_path_w: []const u16,
    new_dir_fd: fd_t,
    new_path_w: []const u16,
    ReplaceIfExists: windows.BOOLEAN,
) RenameError!void {
    if (comptime !builtin.target.os.version_range.windows.min.isAtLeast(.win10_rs5)) {
        @compileError("need >= win10_rs5");
    }

    const src_fd = windows.OpenFile(old_path_w, .{
        .dir = old_dir_fd,
        .access_mask = windows.SYNCHRONIZE | windows.GENERIC_WRITE | windows.DELETE | windows.GENERIC_READ,
        .creation = windows.FILE_OPEN,
        .io_mode = .blocking,
        .filter = .any, // This function is supposed to rename both files and directories.
        .follow_symlinks = false,
    }) catch |err| switch (err) {
        error.WouldBlock => unreachable, // Not possible without `.share_access_nonblocking = true`.
        else => |e| return e,
    };
    defer windows.CloseHandle(src_fd);

    const struct_buf_len = @sizeOf(windows.FILE_RENAME_INFORMATION_EX) + (MAX_PATH_BYTES - 1);
    var rename_info_buf: [struct_buf_len]u8 align(@alignOf(windows.FILE_RENAME_INFORMATION_EX)) = undefined;
    const struct_len = @sizeOf(windows.FILE_RENAME_INFORMATION_EX) - 1 + new_path_w.len * 2;
    if (struct_len > struct_buf_len) return error.NameTooLong;

    const rename_info = @as(*windows.FILE_RENAME_INFORMATION_EX, @ptrCast(&rename_info_buf));
    var io_status_block: windows.IO_STATUS_BLOCK = undefined;

    var flags: windows.ULONG = windows.FILE_RENAME_POSIX_SEMANTICS | windows.FILE_RENAME_IGNORE_READONLY_ATTRIBUTE;
    if (ReplaceIfExists == windows.TRUE) flags |= windows.FILE_RENAME_REPLACE_IF_EXISTS;
    rename_info.* = .{
        .Flags = flags,
        .RootDirectory = if (std.fs.path.isAbsoluteWindowsWTF16(new_path_w)) null else new_dir_fd,
        .FileNameLength = @intCast(new_path_w.len * 2), // already checked error.NameTooLong
        .FileName = undefined,
    };
    @memcpy(@as([*]u16, &rename_info.FileName)[0..new_path_w.len], new_path_w);
    const rc = windows.ntdll.NtSetInformationFile(
        src_fd,
        &io_status_block,
        rename_info,
        @intCast(struct_len), // already checked for error.NameTooLong
        .FileRenameInformationEx,
    );
    switch (rc) {
        .SUCCESS => {},
        .INVALID_HANDLE => unreachable,
        // INVALID_PARAMETER here means that the filesystem does not support FileRenameInformationEx
        .INVALID_PARAMETER => unreachable,
        .OBJECT_PATH_SYNTAX_BAD => unreachable,
        .ACCESS_DENIED => return error.AccessDenied,
        .OBJECT_NAME_NOT_FOUND => return error.FileNotFound,
        .OBJECT_PATH_NOT_FOUND => return error.FileNotFound,
        .NOT_SAME_DEVICE => return error.RenameAcrossMountPoints,
        .OBJECT_NAME_COLLISION => return error.PathAlreadyExists,
        .DIRECTORY_NOT_EMPTY => return error.PathAlreadyExists,
        .FILE_IS_A_DIRECTORY => return error.IsDir,
        .NOT_A_DIRECTORY => return error.NotDir,
        else => return windows.unexpectedStatus(rc),
    }
}

pub fn renameEmulatedPosix(
    old_dir_fd: fd_t,
    old_path_w: []const u16,
    new_dir_fd: fd_t,
    new_path_w: []const u16,
    ReplaceIfExists: windows.BOOLEAN,
) RenameError!void {
    const old_attr = QueryAttributes(old_dir_fd, old_path_w) catch |err| switch (err) {
        error.PermissionDenied => return error.AccessDenied,
        else => |e| return e,
    };
    const maybe_new_attr: ?windows.ULONG = QueryAttributes(new_dir_fd, new_path_w) catch |err| switch (err) {
        error.FileNotFound => null,
        error.PermissionDenied => return error.AccessDenied,
        else => |e| return e,
    };

    if (maybe_new_attr != null and ReplaceIfExists != windows.TRUE) return error.PathAlreadyExists;

    if (maybe_new_attr) |new_attr| {
        const old_is_dir = old_attr & windows.FILE_ATTRIBUTE_DIRECTORY != 0;
        const new_is_dir = new_attr & windows.FILE_ATTRIBUTE_DIRECTORY != 0;

        if (!old_is_dir and new_is_dir) return error.IsDir;
        if (old_is_dir and !new_is_dir) return error.NotDir;
        if (old_is_dir and new_is_dir) {
            windows.DeleteFile(new_path_w, .{ .dir = new_dir_fd, .remove_dir = true }) catch {
                return error.PathAlreadyExists;
            };
        }
    }

    const src_fd = windows.OpenFile(old_path_w, .{
        .dir = old_dir_fd,
        .access_mask = windows.SYNCHRONIZE | windows.GENERIC_WRITE | windows.DELETE,
        .creation = windows.FILE_OPEN,
        .io_mode = .blocking,
        .filter = .any, // This function is supposed to rename both files and directories.
        .follow_symlinks = false,
    }) catch |err| switch (err) {
        error.WouldBlock => unreachable, // Not possible without `.share_access_nonblocking = true`.
        else => |e| return e,
    };
    defer windows.CloseHandle(src_fd);

    const struct_buf_len = @sizeOf(windows.FILE_RENAME_INFORMATION) + (MAX_PATH_BYTES - 1);
    var rename_info_buf: [struct_buf_len]u8 align(@alignOf(windows.FILE_RENAME_INFORMATION)) = undefined;
    const struct_len = @sizeOf(windows.FILE_RENAME_INFORMATION) - 1 + new_path_w.len * 2;
    if (struct_len > struct_buf_len) return error.NameTooLong;

    const rename_info = @as(*windows.FILE_RENAME_INFORMATION, @ptrCast(&rename_info_buf));
    var io_status_block: windows.IO_STATUS_BLOCK = undefined;

    rename_info.* = .{
        .Flags = ReplaceIfExists,
        .RootDirectory = if (std.fs.path.isAbsoluteWindowsWTF16(new_path_w)) null else new_dir_fd,
        .FileNameLength = @intCast(new_path_w.len * 2), // already checked error.NameTooLong
        .FileName = undefined,
    };
    @memcpy(@as([*]u16, &rename_info.FileName)[0..new_path_w.len], new_path_w);

    const rc =
        windows.ntdll.NtSetInformationFile(
        src_fd,
        &io_status_block,
        rename_info,
        @intCast(struct_len), // already checked for error.NameTooLong
        .FileRenameInformation,
    );

    switch (rc) {
        .SUCCESS => {},
        .INVALID_HANDLE => unreachable,
        .INVALID_PARAMETER => unreachable,
        .OBJECT_PATH_SYNTAX_BAD => unreachable,
        .ACCESS_DENIED => return error.AccessDenied,
        .OBJECT_NAME_NOT_FOUND => return error.FileNotFound,
        .OBJECT_PATH_NOT_FOUND => return error.FileNotFound,
        .NOT_SAME_DEVICE => return error.RenameAcrossMountPoints,
        .OBJECT_NAME_COLLISION => return error.PathAlreadyExists,
        else => return windows.unexpectedStatus(rc),
    }
}

pub fn renameNative(
    old_dir_fd: fd_t,
    old_path_w: []const u16,
    new_dir_fd: fd_t,
    new_path_w: []const u16,
    ReplaceIfExists: windows.BOOLEAN,
) RenameError!void {
    const src_fd = windows.OpenFile(old_path_w, .{
        .dir = old_dir_fd,
        .access_mask = windows.SYNCHRONIZE | windows.GENERIC_WRITE | windows.DELETE,
        .creation = windows.FILE_OPEN,
        .io_mode = .blocking,
        .filter = .any, // This function is supposed to rename both files and directories.
        .follow_symlinks = false,
    }) catch |err| switch (err) {
        error.WouldBlock => unreachable, // Not possible without `.share_access_nonblocking = true`.
        else => |e| return e,
    };
    defer windows.CloseHandle(src_fd);

    const struct_buf_len = @sizeOf(windows.FILE_RENAME_INFORMATION) + (MAX_PATH_BYTES - 1);
    var rename_info_buf: [struct_buf_len]u8 align(@alignOf(windows.FILE_RENAME_INFORMATION)) = undefined;
    const struct_len = @sizeOf(windows.FILE_RENAME_INFORMATION) - 1 + new_path_w.len * 2;
    if (struct_len > struct_buf_len) return error.NameTooLong;

    const rename_info = @as(*windows.FILE_RENAME_INFORMATION, @ptrCast(&rename_info_buf));
    var io_status_block: windows.IO_STATUS_BLOCK = undefined;

    rename_info.* = .{
        .Flags = ReplaceIfExists,
        .RootDirectory = if (std.fs.path.isAbsoluteWindowsWTF16(new_path_w)) null else new_dir_fd,
        .FileNameLength = @intCast(new_path_w.len * 2), // already checked error.NameTooLong
        .FileName = undefined,
    };
    @memcpy(@as([*]u16, &rename_info.FileName)[0..new_path_w.len], new_path_w);

    const rc =
        windows.ntdll.NtSetInformationFile(
        src_fd,
        &io_status_block,
        rename_info,
        @intCast(struct_len), // already checked for error.NameTooLong
        .FileRenameInformation,
    );

    switch (rc) {
        .SUCCESS => {},
        .INVALID_HANDLE => unreachable,
        .INVALID_PARAMETER => unreachable,
        .OBJECT_PATH_SYNTAX_BAD => unreachable,
        .ACCESS_DENIED => return error.AccessDenied,
        .OBJECT_NAME_NOT_FOUND => return error.FileNotFound,
        .OBJECT_PATH_NOT_FOUND => return error.FileNotFound,
        .NOT_SAME_DEVICE => return error.RenameAcrossMountPoints,
        .OBJECT_NAME_COLLISION => return error.PathAlreadyExists,
        else => return windows.unexpectedStatus(rc),
    }
}

pub fn renameNativeEx(
    old_dir_fd: fd_t,
    old_path_w: []const u16,
    new_dir_fd: fd_t,
    new_path_w: []const u16,
    ReplaceIfExists: windows.BOOLEAN,
) RenameError!void {
    if (comptime !builtin.target.os.version_range.windows.min.isAtLeast(.win10_rs5)) {
        @compileError("need >= win10_rs5");
    }

    const src_fd = windows.OpenFile(old_path_w, .{
        .dir = old_dir_fd,
        .access_mask = windows.SYNCHRONIZE | windows.GENERIC_WRITE | windows.DELETE | windows.GENERIC_READ,
        .creation = windows.FILE_OPEN,
        .io_mode = .blocking,
        .filter = .any, // This function is supposed to rename both files and directories.
        .follow_symlinks = false,
    }) catch |err| switch (err) {
        error.WouldBlock => unreachable, // Not possible without `.share_access_nonblocking = true`.
        else => |e| return e,
    };
    defer windows.CloseHandle(src_fd);

    const struct_buf_len = @sizeOf(windows.FILE_RENAME_INFORMATION_EX) + (MAX_PATH_BYTES - 1);
    var rename_info_buf: [struct_buf_len]u8 align(@alignOf(windows.FILE_RENAME_INFORMATION_EX)) = undefined;
    const struct_len = @sizeOf(windows.FILE_RENAME_INFORMATION_EX) - 1 + new_path_w.len * 2;
    if (struct_len > struct_buf_len) return error.NameTooLong;

    const rename_info = @as(*windows.FILE_RENAME_INFORMATION_EX, @ptrCast(&rename_info_buf));
    var io_status_block: windows.IO_STATUS_BLOCK = undefined;

    var flags: windows.ULONG = windows.FILE_RENAME_IGNORE_READONLY_ATTRIBUTE;
    if (ReplaceIfExists == windows.TRUE) flags |= windows.FILE_RENAME_REPLACE_IF_EXISTS;
    rename_info.* = .{
        .Flags = flags,
        .RootDirectory = if (std.fs.path.isAbsoluteWindowsWTF16(new_path_w)) null else new_dir_fd,
        .FileNameLength = @intCast(new_path_w.len * 2), // already checked error.NameTooLong
        .FileName = undefined,
    };
    @memcpy(@as([*]u16, &rename_info.FileName)[0..new_path_w.len], new_path_w);
    const rc = windows.ntdll.NtSetInformationFile(
        src_fd,
        &io_status_block,
        rename_info,
        @intCast(struct_len), // already checked for error.NameTooLong
        .FileRenameInformationEx,
    );
    switch (rc) {
        .SUCCESS => {},
        .INVALID_HANDLE => unreachable,
        // INVALID_PARAMETER here means that the filesystem does not support FileRenameInformationEx
        .INVALID_PARAMETER => unreachable,
        .OBJECT_PATH_SYNTAX_BAD => unreachable,
        .ACCESS_DENIED => return error.AccessDenied,
        .OBJECT_NAME_NOT_FOUND => return error.FileNotFound,
        .OBJECT_PATH_NOT_FOUND => return error.FileNotFound,
        .NOT_SAME_DEVICE => return error.RenameAcrossMountPoints,
        .OBJECT_NAME_COLLISION => return error.PathAlreadyExists,
        .DIRECTORY_NOT_EMPTY => return error.PathAlreadyExists,
        .FILE_IS_A_DIRECTORY => return error.IsDir,
        .NOT_A_DIRECTORY => return error.NotDir,
        else => return windows.unexpectedStatus(rc),
    }
}

fn QueryAttributes(dir_fd: fd_t, sub_path_w: []const u16) !windows.ULONG {
    const path_len_bytes = std.math.cast(u16, sub_path_w.len * 2) orelse return error.NameTooLong;
    var nt_name = windows.UNICODE_STRING{
        .Length = path_len_bytes,
        .MaximumLength = path_len_bytes,
        .Buffer = @constCast(sub_path_w.ptr),
    };
    var attr = windows.OBJECT_ATTRIBUTES{
        .Length = @sizeOf(windows.OBJECT_ATTRIBUTES),
        .RootDirectory = if (std.fs.path.isAbsoluteWindowsWTF16(sub_path_w)) null else dir_fd,
        .Attributes = 0, // Note we do not use OBJ_CASE_INSENSITIVE here.
        .ObjectName = &nt_name,
        .SecurityDescriptor = null,
        .SecurityQualityOfService = null,
    };
    var basic_info: windows.FILE_BASIC_INFORMATION = undefined;
    switch (windows.ntdll.NtQueryAttributesFile(&attr, &basic_info)) {
        .SUCCESS => {},
        .OBJECT_NAME_NOT_FOUND => return error.FileNotFound,
        .OBJECT_PATH_NOT_FOUND => return error.FileNotFound,
        .OBJECT_NAME_INVALID => unreachable,
        .INVALID_PARAMETER => unreachable,
        .ACCESS_DENIED => return error.PermissionDenied,
        .OBJECT_PATH_SYNTAX_BAD => unreachable,
        else => |rc| return windows.unexpectedStatus(rc),
    }
    return basic_info.FileAttributes;
}

The results on my computer are:

windows_posix_rename_semantics
rename files: 1769ms
rename dirs: 1617ms
rename onto empty: 873ms
rename onto non-empty: 1221ms
rename file onto dir: 882ms
rename dir onto file: 951ms

emulated
rename files: 1907ms
rename dirs: 1624ms
rename onto empty: 932ms
rename onto non-empty: 900ms
rename file onto dir: 644ms
rename dir onto file: 647ms

native
rename files: 1744ms
rename dirs: 1582ms
rename onto empty: 805ms
rename onto non-empty: 1206ms
rename file onto dir: 860ms
rename dir onto file: 968ms

native_ex
rename files: 1737ms
rename dirs: 1565ms
rename onto empty: 800ms
rename onto non-empty: 1147ms
rename file onto dir: 873ms
rename dir onto file: 961ms

where:

    /// Windows POSIX rename implementation via FILE_RENAME_POSIX_SEMANTICS
    windows_posix_rename_semantics,
    /// Custom POSIX rename implementation via NtQueryAttributesFile/DeleteFile/FileRenameInformation
    emulated,
    /// Windows rename via FileRenameInformation, no POSIX emulation
    native,
    /// Windows rename via FileRenameInformationEx and FILE_RENAME_IGNORE_READONLY_ATTRIBUTE,
    /// no POSIX emulation
    native_ex,

Copy link
Member

Choose a reason for hiding this comment

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

Nice work, as usual.

    Renaming a directory onto an existing file succeeds on Windows, fails with ENOTDIR on POSIX systems
    Renaming a directory onto an existing empty directory succeeds on POSIX systems, fails with ACCESS_DENIED on Windows
    Renaming a directory onto an existing non-empty directory fails on POSIX systems with ENOTEMPTY, but fails on Windows with ACCESS_DENIED
    Renaming a file onto any existing directory fails on POSIX systems with EISDIR, but fails on Windows with ACCESS_DENIED

Having this flow chart in the doc comments, along with a list of behaviors that are fully cross platform, I think is an underrated deliverable. This is how we make "sweet spot" abstractions that will provide the building blocks for truly reusable code.

Copy link
Collaborator Author

@squeek502 squeek502 Jan 25, 2024

Choose a reason for hiding this comment

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

Another wrinkle:

  • The rename semantics and returned errors on Windows actually seem to be dictated by the underlying filesystem or possibly the filesystem driver that controls the particular drive (not sure if 'filesystem driver' is the correct terminology)
    • On a local FAT32 drive, the error cases return OBJECT_NAME_COLLISION instead of ACCESS_DENIED, but the rename semantics are the same as outlined above:
    testRenameOntoEmptyDir: error.PathAlreadyExists
    testRenameOntoNonEmptyDir: error.PathAlreadyExists
    testRenameFileOntoDir: error.PathAlreadyExists
    testRenameDirOntoFile: success
    
    • On a networked SMB drive (NTFS) that lives on a Linux machine, it seems like rename gains POSIX-like semantics and returns OBJECT_NAME_COLLISION for the error cases:
    testRenameOntoEmptyDir: success
    testRenameOntoNonEmptyDir: error.PathAlreadyExists
    testRenameFileOntoDir: error.PathAlreadyExists
    testRenameDirOntoFile: error.PathAlreadyExists
    

So there may not technically be a knowable/defined behavior for FileRenameInformation on Windows, since it seems to ultimately be dictated by the underlying filesystem.

Copy link

@jacwil jacwil Jan 27, 2024

Choose a reason for hiding this comment

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

Would preferring failure if one of the error cases are true be possible to unify the behavior in std across os and drives/filesystems?

EDIT: Ignore the following proposal. Not atomic thus not very well thought out.

testRenameOntoEmptyDir: error.PathAlreadyExists // Always check if path exists before rename
testRenameOntoNonEmptyDir: error.PathAlreadyExists
testRenameFileOntoDir: error.PathAlreadyExists
testRenameDirOntoFile: error.PathAlreadyExists // On Windows, check if file exists before rename

@rootbeer
Copy link
Contributor

I've been poking around to see if I can find examples of well-worn, cross-platform filesystem APIs that Zig could steal semantics from. I'm not finding much. This section of the CERT Secure C Coding Standard about how to securely use rename across POSIX and Windows from C code does get into some of the differences in behavior and some well-established work-arounds: https://wiki.sei.cmu.edu/confluence/display/c/FIO10-C.+Take+care+when+using+the+rename%28%29+function

I wonder if Zig could have slightly more targeted functions like renameWithOverwrite and renameIfAvailable (or maybe some other subseting, e.g., of a source file-vs-directory)? These could return errors on the platforms that don't support them to make it clear that any work-arounds (and their inevitable race conditions and special cases) are addressed by the app developer.

Also here's a fun corner case: the source and target of the rename are hardlinks to the same underlying file.

@andrewrk
Copy link
Member

I wonder if Zig could have slightly more targeted functions like renameWithOverwrite and renameIfAvailable (or maybe some other subseting, e.g., of a source file-vs-directory)? These could return errors on the platforms that don't support them to make it clear that any work-arounds (and their inevitable race conditions and special cases) are addressed by the app developer.

Generally, yes, that's the idea. And in such functions it is more OK to have fallback logic since the semantics of the function explain what must be done, and so of course that fallback code must be done. The problem arises when, instead, it could be lowered in multiple ways, and the std lib takes an opinionated approach, making one system emulate the other.

The goal is for the programmer to communicate their intent precisely, in other words, do they need error.PathNotFound to occur if the rename fails due to the destination being a directory? That piece of information is relevant to the implementation, so it needs to be communicated.

@andrewrk
Copy link
Member

andrewrk commented Mar 15, 2024

Closing since it's a non-goal to have full std.posix API support on Windows.

Any complex, buggy, or bloaty implementations of posix APIs should just be @compileError("not supported on Windows").

@andrewrk andrewrk closed this Mar 15, 2024
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.

Windows: rename fails for directories
5 participants