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.copyFileperforms an atomic copy and preserves file mode. - Manual streaming: open, read, and write with
deferanderrdefer, 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/errdeferto guarantee resource cleanup and remove partial files on failure. - Choose between
Dir.copyFilefor 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.
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);
};
}
$ printf 'hello, copier!\n' > from.txt
$ zig run safe_copy.zig -- from.txt to.txt(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:
$ 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.txterror: 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.
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();
}
$ printf 'stream me\n' > src.txt
$ zig run copy_stream.zig -- src.txt dst.txt(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.copyFilecreates 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.updateFilewhen you need atime/mtime to match the source, in addition to content and mode. - Performance hints: the
Writerinterface uses platform accelerations (sendfile,copy_file_range, orfcopyfile) when available, falling back to buffered loops; see posix.zig. - CLI lifetimes: duplicate
argsstrings before freeing them to avoid dangling[]u8slices (both examples useallocator.dupe); see process.zig. - Sanity checks: open the source first, then
stat()it and requirekind == .fileto reject directories and special files.
Exercises
- Add a
--no-clobberflag that forces an error even when--forceis also present—then emit a helpful message suggesting which one to remove. - Implement
--preserve-timesby switching toDir.updateFileand verifying viastatthat timestamps match. - Teach the tool to copy file permissions from a numeric mode override (e.g.,
--mode=0644) usingCopyFileOptions.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
deleteFileis safer thanrenamewhen 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
Writerinterface.