Chapter 07Project Safe File Copier

项目

概述

我们的第三个项目将文件I/O提升到了一个新的水平:构建一个小型、健壮的文件复制器,它默认是安全的,能发出清晰的诊断信息,并能自行清理。我们将把第四章的defer/errdefer模式与真实世界的错误处理联系起来,同时展示标准库的原子复制助手;参见04Dir.zig

两种方法说明了其中的权衡:

  • 高级方法:单次调用std.fs.Dir.copyFile执行原子复制并保留文件模式。
  • 手动流式处理:使用defererrdefer打开、读取和写入,如果出现任何失败,则删除部分输出,具体如#defer和errdeferFile.zig中所述。

学习目标

  • 设计一个CLI,除非明确强制,否则拒绝覆盖现有文件,如#命令行标志中所述。
  • 使用defer/errdefer来保证资源清理并在失败时移除部分文件。
  • 在原子便利性的Dir.copyFile和用于细粒度控制的手动流式处理之间进行选择。

正确性优先:默认安全的CLI

破坏用户数据是不可原谅的。这个工具采取保守立场:除非提供了--force,否则现有的目标会中止复制。我们还验证了源是常规文件,并在成功时保持stdout静默,这样脚本就可以将“无输出”视为好兆头,具体如#错误处理中所述。

在现有目标上中止

我们首先探测目标路径。如果存在且未提供--force,我们会打印单行诊断信息并以非零状态退出。这反映了常见的Unix实用程序,并使失败变得明确。

一次调用完成原子复制

尽可能利用标准库。Dir.copyFile使用一个临时文件并将其重命名到位,这意味着即使进程在复制中途崩溃,调用者也永远不会观察到部分写入的目标。文件模式默认保留;如果你需要时间戳,可以使用下面提到的updateFile来处理。

Zig
const std = @import("std");

// Chapter 7 – Safe File Copier (atomic via std.fs.Dir.copyFile)
//
// A minimal, safe-by-default CLI that refuses to clobber an existing
// destination unless --force is provided. Uses std.fs.Dir.copyFile,
// which writes to a temporary file and atomically renames it into place.
//
// Usage:
//   zig run safe_copy.zig -- <src> <dst>
//   zig run safe_copy.zig -- --force <src> <dst>
// 第7章 - 安全文件复制器(通过std.fs.Dir.copyFile实现原子性)
//
// 一个最小化、默认安全的CLI,拒绝覆盖现有目标文件,
// 除非提供--force参数。使用std.fs.Dir.copyFile,
// 它将数据写入临时文件并原子性地重命名到目标位置。
//
// 用法:
//   zig run safe_copy.zig -- <src> <dst>
//   zig run safe_copy.zig -- --force <src> <dst>

const Cli = struct {
    force: bool = false,
    src: []const u8 = &[_]u8{},
    dst: []const u8 = &[_]u8{},
};

fn printUsage() void {
    std.debug.print("usage: safe-copy [--force] <source> <dest>\n", .{});
}

fn parseArgs(allocator: std.mem.Allocator) !Cli {
    var cli: Cli = .{};
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--help"))) {
        printUsage();
        std.process.exit(0);
    }

    var i: usize = 1;
    while (i < args.len and std.mem.startsWith(u8, args[i], "--")) : (i += 1) {
        const flag = args[i];
        if (std.mem.eql(u8, flag, "--force")) {
            cli.force = true;
        } else if (std.mem.eql(u8, flag, "--help")) {
            printUsage();
            std.process.exit(0);
        } else {
            std.debug.print("error: unknown flag '{s}'\n", .{flag});
            printUsage();
            std.process.exit(2);
        }
    }

    const remaining = args.len - i;
    if (remaining != 2) {
        std.debug.print("error: expected <source> and <dest>\n", .{});
        printUsage();
        std.process.exit(2);
    }

    // Duplicate paths so they remain valid after freeing args.
    // 复制路径以确保释放args后路径仍然有效。
    cli.src = try allocator.dupe(u8, args[i]);
    cli.dst = try allocator.dupe(u8, args[i + 1]);
    return cli;
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const cli = try parseArgs(allocator);

    const cwd = std.fs.cwd();

    // Validate that source exists and is a regular file.
    // 验证源文件存在且为常规文件。
    var src_file = cwd.openFile(cli.src, .{ .mode = .read_only }) catch {
        std.debug.print("error: unable to open source '{s}'\n", .{cli.src});
        std.process.exit(1);
    };
    defer src_file.close();

    const st = try src_file.stat();
    if (st.kind != .file) {
        std.debug.print("error: source is not a regular file\n", .{});
        std.process.exit(1);
    }

    // Respect safe-by-default semantics: refuse to overwrite unless --force.
    // 遵循默认安全的语义:除非提供--force,否则拒绝覆盖。
    const dest_exists = blk: {
        _ = cwd.statFile(cli.dst) catch |err| switch (err) {
            error.FileNotFound => break :blk false,
            else => |e| return e,
        };
        break :blk true;
    };
    if (dest_exists and !cli.force) {
        std.debug.print("error: destination exists; pass --force to overwrite\n", .{});
        std.process.exit(2);
    }

    // Perform an atomic copy preserving mode by default. On success, there is
    // intentionally no output to keep pipelines quiet and scripting-friendly.
    // 执行原子复制并默认保留文件模式。成功时,
    // 有意不输出任何内容,以保持管道的安静和脚本友好性。
    cwd.copyFile(cli.src, cwd, cli.dst, .{ .override_mode = null }) catch |err| {
        std.debug.print("error: copy failed ({s})\n", .{@errorName(err)});
        std.process.exit(1);
    };
}
运行
Shell
$ printf 'hello, copier!\n' > from.txt
$ zig run safe_copy.zig -- from.txt to.txt
输出
Shell
(无输出)

copyFile会覆盖现有文件。我们的包装器首先检查是否存在,并需要--force才能覆盖。如果你还想保留atime/mtime,请优先使用Dir.updateFile

有意覆盖

当输出已存在时,演示显式覆盖:

Shell
$ printf 'v1\n' > from.txt
$ printf 'old\n' > to.txt
$ zig run safe_copy.zig -- from.txt to.txt
error: destination exists; pass --force to overwrite
$ zig run safe_copy.zig -- --force from.txt to.txt
输出
Shell
error: destination exists; pass --force to overwrite
(无输出)

成功在设计上保持静默;与echo $?结合使用,以便在脚本中消费状态码。

使用defer/errdefer进行手动流式处理

为了进行细粒度控制(或作为学习练习),将Reader连接到Writer并自己流式传输字节。关键之处在于使用errdefer在创建后如果出现任何问题就移除目标——这可以防止留下被截断的文件。

Zig
const std = @import("std");

// Chapter 7 – Safe File Copier (manual streaming with errdefer cleanup)
//
// Demonstrates opening, reading, writing, and cleaning up safely using
// defer/errdefer. If the copy fails after destination creation, we remove
// the partial file so callers never observe a truncated artifact.
//
// Usage:
//   zig run copy_stream.zig -- <src> <dst>
//   zig run copy_stream.zig -- --force <src> <dst>
// 第7章 - 安全文件复制器(使用errdefer清理的手动流式处理)
//
// 演示使用defer/errdefer进行安全的打开、读取、写入和清理。
// 如果在创建目标文件后复制失败,我们将删除部分文件,
// 以便调用者永远观察不到截断的产物。
//
// 用法:
//   zig run copy_stream.zig -- <src> <dst>
//   zig run copy_stream.zig -- --force <src> <dst>

const Cli = struct {
    force: bool = false,
    src: []const u8 = &[_]u8{},
    dst: []const u8 = &[_]u8{},
};

fn printUsage() void {
    std.debug.print("usage: copy-stream [--force] <source> <dest>\n", .{});
}

fn parseArgs(allocator: std.mem.Allocator) !Cli {
    var cli: Cli = .{};
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--help"))) {
        printUsage();
        std.process.exit(0);
    }

    var i: usize = 1;
    while (i < args.len and std.mem.startsWith(u8, args[i], "--")) : (i += 1) {
        const flag = args[i];
        if (std.mem.eql(u8, flag, "--force")) {
            cli.force = true;
        } else if (std.mem.eql(u8, flag, "--help")) {
            printUsage();
            std.process.exit(0);
        } else {
            std.debug.print("error: unknown flag '{s}'\n", .{flag});
            printUsage();
            std.process.exit(2);
        }
    }

    const remaining = args.len - i;
    if (remaining != 2) {
        std.debug.print("error: expected <source> and <dest>\n", .{});
        printUsage();
        std.process.exit(2);
    }

    // Duplicate paths so they remain valid after freeing args.
    // 复制路径以确保释放args后路径仍然有效。
    cli.src = try allocator.dupe(u8, args[i]);
    cli.dst = try allocator.dupe(u8, args[i + 1]);
    return cli;
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const cli = try parseArgs(allocator);

    const cwd = std.fs.cwd();

    // Open source and inspect its metadata.
    // 打开源文件并检查其元数据。
    var src = cwd.openFile(cli.src, .{ .mode = .read_only }) catch {
        std.debug.print("error: unable to open source '{s}'\n", .{cli.src});
        std.process.exit(1);
    };
    defer src.close();

    const st = try src.stat();
    if (st.kind != .file) {
        std.debug.print("error: source is not a regular file\n", .{});
        std.process.exit(1);
    }

    // Safe-by-default: refuse to overwrite unless --force.
    // 默认安全:除非提供--force,否则拒绝覆盖。
    if (!cli.force) {
        const dest_exists = blk: {
            _ = cwd.statFile(cli.dst) catch |err| switch (err) {
                error.FileNotFound => break :blk false,
                else => |e| return e,
            };
            break :blk true;
        };
        if (dest_exists) {
            std.debug.print("error: destination exists; pass --force to overwrite\n", .{});
            std.process.exit(2);
        }
    }

    // Create destination with exclusive mode when not forcing overwrite.
    // 在不强制覆盖时以独占模式创建目标文件。
    var dest = cwd.createFile(cli.dst, .{
        .read = false,
        .truncate = cli.force,
        .exclusive = !cli.force,
        .mode = st.mode,
    }) catch |err| switch (err) {
        error.PathAlreadyExists => {
            std.debug.print("error: destination exists; pass --force to overwrite\n", .{});
            std.process.exit(2);
        },
        else => |e| {
            std.debug.print("error: cannot create destination ({s})\n", .{@errorName(e)});
            std.process.exit(1);
        },
    };
    // Ensure closure and cleanup order: close first, then delete on error.
    // 确保关闭和清理的顺序:先关闭,出错时再删除。
    defer dest.close();
    errdefer cwd.deleteFile(cli.dst) catch {};

    // Wire a Reader/Writer pair and copy using the Writer interface.
    // 连接Reader/Writer对并使用Writer接口进行复制。
    var reader: std.fs.File.Reader = .initSize(src, &.{}, st.size);
    var write_buf: [64 * 1024]u8 = undefined; // buffered writes
    // 缓冲写入
    var writer = std.fs.File.writer(dest, &write_buf);

    _ = writer.interface.sendFileAll(&reader, .unlimited) catch |err| switch (err) {
        error.ReadFailed => return reader.err.?,
        error.WriteFailed => return writer.err.?,
    };

    // Flush buffered bytes and set the final file length.
    // 冲刷缓冲的字节并设置最终的文件长度。
    try writer.end();
}
运行
Shell
$ printf 'stream me\n' > src.txt
$ zig run copy_stream.zig -- src.txt dst.txt
输出
Shell
(无输出)

使用.exclusive = true创建目标时,如果文件已存在,打开操作将失败。这加上errdefer deleteFile,在典型的单进程场景中提供了强大的安全保证,而不会产生竞争条件。

注意与警告

  • 原子语义:Dir.copyFile创建一个临时文件并将其重命名到位,从而避免了其他进程的部分读取。在旧的Linux内核上,断电可能会留下一个临时文件;详情请参见函数的文档注释。
  • 保留时间戳:当你需要atime/mtime与源文件匹配时,除了内容和模式,请优先使用Dir.updateFile
  • 性能提示:Writer接口在可用时使用平台加速(sendfilecopy_file_rangefcopyfile),否则回退到缓冲循环;参见posix.zig
  • CLI生命周期:在释放args之前复制其字符串,以避免悬空的[]u8切片(两个例子都使用allocator.dupe);参见process.zig
  • 健全性检查:首先打开源文件,然后对其进行stat(),并要求kind == .file以拒绝目录和特殊文件。

练习

  • 添加一个--no-clobber标志,即使同时存在--force也强制报错——然后发出一个有用的消息,建议移除其中一个。
  • 通过切换到Dir.updateFile并用stat验证时间戳是否匹配来实现--preserve-times
  • 使用CopyFileOptions.override_mode教工具从数字模式覆盖(例如--mode=0644)复制文件权限。

替代方案和边缘情况:

  • 在这些示例中,有意拒绝复制特殊文件(目录、fifo、设备);请明确处理或跳过它们。
  • 跨文件系统移动:当设备不同时,复制加deleteFilerename更安全;Zig的助手在给定内容副本的情况下会做正确的事情。
  • 非常大的文件:首先选择高级复制;如果你不使用Writer接口,手动循环应分块读取并小心处理短写。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.