Chapter 07Project Safe File Copier

项目

概述

我们的第三个项目将文件I/O提升到一个新水平:构建一个默认安全、发出清晰诊断信息并能自我清理的小型、健壮的文件复制器。我们将第4章的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");

// 第7章 - 安全文件复制器(通过 std.fs.Dir.copyFile 实现原子操作)
//
// 一个极简的命令行工具,默认安全,拒绝覆盖已存在的目标文件,
// 除非提供 --force 参数。使用 std.fs.Dir.copyFile 实现,
// 该函数先写入临时文件,然后原子性地重命名到目标位置。
//
// 用法:
//   zig run safe_copy.zig -- <源文件> <目标文件>
//   zig run safe_copy.zig -- --force <源文件> <目标文件>

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);
    }

    // 复制路径,确保在释放参数后仍保持有效
    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();

    // 验证源文件存在且为常规文件
    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);
    }

    // 遵循"默认安全"理念:除非使用 --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);
    }

    // 执行原子性复制,默认保留文件权限。成功时不输出任何内容,
    // 以保持管道安静并便于脚本化使用。
    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");

// 第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);
    }

    // 复制路径以便在释放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();

    // 打开源文件并检查其元数据
    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);
    }

    // 默认安全:拒绝覆盖,除非使用--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);
        }
    }

    // 在不强制覆盖时以独占模式创建目标文件
    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);
        },
    };
    // 确保关闭和清理顺序:先关闭,错误时再删除
    defer dest.close();
    errdefer cwd.deleteFile(cli.dst) catch {};

    // 连接Reader/Writer对并使用Writer接口复制
    var reader: std.fs.File.Reader = .initSize(src, &.{}, st.size);
    var write_buf: [64 * 1024]u8 = undefined; // 缓冲写入
    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.?,
    };

    // 刷新缓冲字节并设置最终文件长度
    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并验证时间戳是否匹配来实现--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.