Chapter 07Project Safe File Copier

Project

Overview

Our third project turns file I/O up a notch: build a small, robust file copier that is safe by default, emits clear diagnostics, and cleans up after itself. We’ll connect the dots from Chapter 4’s defer/errdefer patterns to real-world error handling while showcasing the standard library’s atomic copy helpers; see 04 and Dir.zig.

Two approaches illustrate the trade-offs:

  • High-level: a single call to std.fs.Dir.copyFile performs an atomic copy and preserves file mode.
  • Manual streaming: open, read, and write with defer and errdefer, deleting partial outputs if anything fails, as described in #defer and errdefer and File.zig.

Learning Goals

  • Design a CLI that refuses to overwrite existing files unless explicitly forced, as described in #Command-line-flags.
  • Use defer/errdefer to guarantee resource cleanup and remove partial files on failure.
  • Choose between Dir.copyFile for atomic convenience and manual streaming for fine-grained control.

Correctness First: Safe-by-Default CLI

Clobbering a user’s data is unforgivable. This tool adopts a conservative stance: unless --force is provided, an existing destination aborts the copy. We also validate that the source is a regular file and keep stdout silent on success so scripts can treat “no output” as a good sign, as described in #Error-Handling.

Aborting on Existing Destinations

We probe the destination path first. If present and --force is absent, we print a single-line diagnostic and exit with a non-zero status. This mirrors common Unix utilities and makes failures unambiguous.

Atomic Copy in One Call

Leverage the standard library when possible. Dir.copyFile uses a temporary file and renames it into place, which means callers never observe a partially written destination even if the process crashes mid-copy. File mode is preserved by default; timestamps are handled by updateFile if you need them, which we mention below.

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>

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.
    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.
    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);
    };
}
Run
Shell
$ printf 'hello, copier!\n' > from.txt
$ zig run safe_copy.zig -- from.txt to.txt
Output
Shell
(no output)

copyFile overwrites existing files. Our wrapper checks for existence first and requires --force to clobber. Prefer Dir.updateFile if you want to also preserve atime/mtime.

Overwrite with Intent

When an output already exists, demonstrate explicit overwrite:

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
Output
Shell
error: destination exists; pass --force to overwrite
(no output)

Success remains quiet by design; combine with echo $? to consume status codes in scripts.

Manual Streaming with defer/errdefer

For fine-grained control (or as a learning exercise), wire a Reader to a Writer and stream the bytes yourself. The crucial bit is errdefer to remove the destination if anything goes wrong after creation—this prevents leaving a truncated file behind.

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>

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.
    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.
    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.
    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();
}
Run
Shell
$ printf 'stream me\n' > src.txt
$ zig run copy_stream.zig -- src.txt dst.txt
Output
Shell
(no output)

When creating the destination with .exclusive = true, the open fails if the file already exists. That, plus errdefer deleteFile, gives strong safety guarantees without races in typical single-process scenarios.

Notes & Caveats

  • Atomic semantics: Dir.copyFile creates a temporary file and renames it into place, avoiding partial reads by other processes. On older Linux kernels, power loss may leave a temp file; see the function’s doc comment for details.
  • Preserving timestamps: prefer Dir.updateFile when you need atime/mtime to match the source, in addition to content and mode.
  • Performance hints: the Writer interface uses platform accelerations (sendfile, copy_file_range, or fcopyfile) when available, falling back to buffered loops; see posix.zig.
  • CLI lifetimes: duplicate args strings before freeing them to avoid dangling []u8 slices (both examples use allocator.dupe); see process.zig.
  • Sanity checks: open the source first, then stat() it and require kind == .file to reject directories and special files.

Exercises

  • Add a --no-clobber flag that forces an error even when --force is also present—then emit a helpful message suggesting which one to remove.
  • Implement --preserve-times by switching to Dir.updateFile and verifying via stat that timestamps match.
  • Teach the tool to copy file permissions from a numeric mode override (e.g., --mode=0644) using CopyFileOptions.override_mode

Alternatives & Edge Cases:

  • Copying special files (directories, fifos, devices) is intentionally rejected in these examples; handle them explicitly or skip.
  • Cross-filesystem moves: copying plus deleteFile is safer than rename when devices differ; Zig’s helpers do the right thing given a content copy.
  • Very large files: prefer the high-level copy first; manual loops should chunk reads and handle short writes carefully if you don’t use the Writer interface.

Help make this chapter better.

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