Artifact e8df1aa2d99e6ca40885db7042a27f34c153e0077a34a864b9097961d81af83a:
- File
src/cmd/archive.zig
— part of check-in
[580d0691c7]
at
2026-08-17 14:41:42
on branch trunk
— tarball: write a check-in's files as a reproducible gzip tar (#95)
The effective-set walk already resolves sub-manifests and delta chains in canonical order, so this is that walk with an archive writer. Members carry the F card's permission, so an executable stays executable. Two runs of one version are byte-identical: history supplies the order and the timestamps, and no uid, gid, or build host is recorded. A file the store does not hold is refused by name -- a partial archive looks exactly like success. (user: dmestas, size: 14226) [annotate] [blame] [check-ins using]
//! `zit tarball` / `zit zip` (#95) — export a check-in's effective file set as //! an archive instead of a working tree. //! //! No new history logic lives here. `Repository.files` already walks a //! check-in's effective set through sub-manifests and delta baselines, in //! canonical filename order; `Repository.blob` already reconstructs a blob //! through its delta chain and verifies it against its F-card id. This verb is //! that same walk with a writer that appends to an archive, which is why it is //! small: the format and the selection policy live in `archive.zig`, and what //! is left here is the repository handle, the output file, and the diagnostics. //! //! The one property worth stating at this layer: **a member whose content the //! store does not hold is a refusal, never an omission.** A sparse store //! deliberately elides out-of-cone blobs, and an archive quietly missing those //! files would be the failure mode that looks exactly like success — right //! member count, plausible size, missing source. So the whole member set is //! checked for presence BEFORE a single byte is written, and the refusal names //! the files rather than reporting a count. const std = @import("std"); const zit = @import("zit"); const cli = @import("zit").cli; const archive = @import("zit").archive; const artifact = @import("zit").artifact; const Repository = @import("zit").Repository; const main = @import("../main.zig"); const openRepo = main.openRepo; const not_a_repo_msg = main.not_a_repo_msg; /// Which archive format a run writes. The enumeration and selection are shared /// whole; only the writer differs. pub const Kind = enum { tarball, zip, fn verb(self: Kind) []const u8 { return switch (self) { .tarball => "tarball", .zip => "zip", }; } fn help(self: Kind) []const u8 { return switch (self) { .tarball => main.tarball_help, .zip => main.tarball_help, // the zip writer lands with its own help }; } }; /// How many members one archive may carry. The same bound the managed set /// already imposes on a checkout, restated here so the collection loop below /// is finite by inspection rather than by trusting the manifest it walked. const max_members: usize = zit.managed.max_entries; /// Largest single member. A blob is reconstructed whole into memory to be /// written, exactly as `zit cat` does, so this is `cat`'s bound and not a new /// policy — an archive is not a streaming decompressor. const max_member_bytes: u64 = 1 << 32; /// A selected member: its repository path, its mode, and its blob id. Paths /// and hashes borrow the `FileList`, which outlives this slice. const Member = struct { path: []const u8, mode: artifact.FileMode, hash: []const u8, }; pub fn runTarball( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { return run(.tarball, gpa, io, arena, verb_args, streams, amb); } fn run( kind: Kind, gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const verb = kind.verb(); const parsed = switch (cli.parseArchiveArgs(verb_args)) { .ok => |p| p, .unknown_option => |t| return cli.usageError(streams, verb, .unknown_option, t), .extra_argument => |t| return cli.usageError(streams, verb, .extra_argument, t), .empty_value => |t| return cli.usageError(streams, verb, .empty_value, t), .repeated_option => |t| return cli.usageError(streams, verb, .repeated_option, t), }; if (parsed.help) { streams.out.writeAll(kind.help()) catch {}; return .ok; } const version = parsed.version orelse return cli.usageError(streams, verb, .no_checkin, ""); // OUTPUTFILE is required unless there is nothing to write. if (parsed.output == null and !parsed.list) return cli.usageError(streams, verb, .no_file, ""); if (parsed.name) |n| { if (n.len > archive.max_top_dir_len or std.mem.indexOfScalar(u8, n, '/') != null) { streams.err.print( "zit {s}: --name must be a single directory name of at most {d} bytes\n", .{ verb, archive.max_top_dir_len }, ) catch {}; return .usage; } } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, verb, amb.repo_override, parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); const rid = repo.resolve(version, .checkin) catch |e| switch (e) { error.NotFound => return fail(streams, verb, "check-in '{s}' not found", .{version}), error.Ambiguous => return fail(streams, verb, "name '{s}' is ambiguous", .{version}), error.OutOfMemory => return oom(streams, verb), }; var list = (repo.files(gpa, rid) catch |e| return filesFail(streams, verb, e)) orelse return fail(streams, verb, "check-in content is missing", .{}); defer list.deinit(gpa); // Collect the selected members first, so nothing is written before the // whole set is known to be admissible AND present. var members: std.ArrayListUnmanaged(Member) = .empty; defer members.deinit(gpa); var it = list.iterator(); while (it.next()) |f| { std.debug.assert(f.hash != null); // the walk suppresses delete markers archive.admit(f.filename) catch |e| return admitFail(streams, verb, f.filename, e); if (!archive.selected(f.filename, parsed.include, parsed.exclude)) continue; if (members.items.len >= max_members) return fail(streams, verb, "check-in carries more than {d} files", .{max_members}); members.append(gpa, .{ .path = f.filename, .mode = artifact.fileModeOf(f.perm), .hash = f.hash.?, }) catch return oom(streams, verb); } // Presence, before any output. A sparse store interns an elided blob's // name while holding no bytes, so this is a real question even when the // manifest looks complete — and answering it costs a lookup per member, // not an inflate. var missing: usize = 0; for (members.items) |m| { const held = repo.blobPresent(gpa, m.hash) catch |e| return openFail(streams, verb, e); if (held) continue; if (missing == 0) { streams.err.print( "zit {s}: this store does not hold every file in the check-in; refusing to write a partial archive\n", .{verb}, ) catch {}; } missing += 1; // Name them rather than counting them: "3 files missing" sends the // operator to guess which, and the remedy differs per path. if (missing <= 20) streams.err.print("zit {s}: {s}\n", .{ verb, m.path }) catch {}; } if (missing > 20) { streams.err.print("zit {s}: ... and {d} more\n", .{ verb, missing - 20 }) catch {}; } if (missing != 0) { streams.err.print( "zit {s}: widen the cone with 'zit cone add PATH' (which fetches), or archive from a dense store\n", .{verb}, ) catch {}; return .failure; } // The top-level directory: the operator's, or derived. var name_buf: [archive.max_top_dir_len]u8 = undefined; const checkin_hash = repo.hashOf(rid); const top = parsed.name orelse archive.defaultTopDir( parsed.output orelse "", checkin_hash[0..@min(checkin_hash.len, 16)], &name_buf, ); if (parsed.list) { for (members.items) |m| streams.out.print("{s}/{s}\n", .{ top, m.path }) catch {}; return .ok; } // Members carry the CHECK-IN's own time, never the clock: it is what makes // two runs on two machines agree, and `SOURCE_DATE_EPOCH` already pins it // at the moment the D card was written. const mtime_ms = repo.state().mtimeOf(rid) orelse 0; const mtime: u64 = mtime_ms / 1000; return writeArchive(kind, gpa, io, &repo, streams, verb, parsed.output.?, top, members.items, mtime); } /// Whether an output path means "write nothing" — fossil's convention, and the /// half of `--list` that lets a preview run against a real command line. fn writesNothing(path: []const u8) bool { return path.len == 0 or std.mem.eql(u8, path, "/dev/null"); } fn writeArchive( kind: Kind, gpa: std.mem.Allocator, io: std.Io, repo: *Repository, streams: cli.Streams, verb: []const u8, output: []const u8, top: []const u8, members: []const Member, mtime: u64, ) cli.Exit { std.debug.assert(kind == .tarball); // zip lands in its own change if (writesNothing(output)) return .ok; var out_file = std.Io.Dir.cwd().createFile(io, output, .{ .truncate = true }) catch { streams.err.print("zit {s}: cannot create '{s}'\n", .{ verb, output }) catch {}; return .failure; }; defer out_file.close(io); var file_buf: [64 * 1024]u8 = undefined; var file_writer = out_file.writerStreaming(io, &file_buf); // gzip through the shared deflate: its container header is a fixed // constant with a zeroed mtime and no host byte, so the compressed wrapper // cannot leak a build environment into an otherwise reproducible archive. var window: [std.compress.flate.max_window_len]u8 = undefined; var gz = std.compress.flate.Compress.init( &file_writer.interface, &window, .gzip, .default, ) catch return writeFail(streams, verb, output); const w = &gz.writer; for (members) |m| { const bytes = (repo.blob(gpa, m.hash) catch |e| return openFail(streams, verb, e)) orelse { // Presence was checked above, so reaching here means the store // changed under us mid-run. Fail rather than emit a short member. streams.err.print("zit {s}: content for '{s}' disappeared mid-archive\n", .{ verb, m.path }) catch {}; return .failure; }; defer gpa.free(bytes); if (bytes.len > max_member_bytes) return fail(streams, verb, "'{s}' is larger than one archive member may be", .{m.path}); // A symlink's blob content IS its target path, so it becomes the link // field of a symlink header and contributes no member content — a // writer that missed this would emit a small text file where a link // belonged, and the archive would look perfectly correct until use. const is_link = m.mode == .symlink; const size: u64 = if (is_link) 0 else bytes.len; const link_target: ?[]const u8 = if (is_link) bytes else null; if (is_link and bytes.len > archive.ustar_name_len) { return fail(streams, verb, "'{s}' links to a target too long for tar", .{m.path}); } var path_buf: [zit.managed.max_path_len + archive.max_top_dir_len + 1]u8 = undefined; const joined = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ top, m.path }) catch return fail(streams, verb, "'{s}' is too long to archive under '{s}'", .{ m.path, top }); archive.beginTarMember(w, joined, size, m.mode, mtime, link_target) catch return writeFail(streams, verb, output); if (!is_link) { w.writeAll(bytes) catch return writeFail(streams, verb, output); archive.endTarMember(w, size) catch return writeFail(streams, verb, output); } } archive.writeTarEnd(w) catch return writeFail(streams, verb, output); gz.finish() catch return writeFail(streams, verb, output); file_writer.interface.flush() catch return writeFail(streams, verb, output); return .ok; } fn fail(s: cli.Streams, verb: []const u8, comptime fmt: []const u8, args: anytype) cli.Exit { s.err.print("zit {s}: ", .{verb}) catch {}; s.err.print(fmt ++ "\n", args) catch {}; return .failure; } fn oom(s: cli.Streams, verb: []const u8) cli.Exit { s.err.print("zit {s}: out of memory\n", .{verb}) catch {}; return .failure; } fn writeFail(s: cli.Streams, verb: []const u8, output: []const u8) cli.Exit { s.err.print("zit {s}: cannot write '{s}'\n", .{ verb, output }) catch {}; return .failure; } fn admitFail(s: cli.Streams, verb: []const u8, path: []const u8, e: archive.Error) cli.Exit { switch (e) { error.ReservedPath => s.err.print( "zit {s}: the check-in names a reserved control path '{s}'; refusing to archive it\n", .{ verb, path }, ) catch {}, error.PathTooLong => s.err.print("zit {s}: path '{s}' is too long\n", .{ verb, path }) catch {}, } return .failure; } fn filesFail(s: cli.Streams, verb: []const u8, e: Repository.FilesError) cli.Exit { switch (e) { error.OutOfMemory => return oom(s, verb), error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {}, error.Corrupt => s.err.print("zit {s}: cannot read repository: repository is corrupt\n", .{verb}) catch {}, error.NotManifest => s.err.print("zit {s}: not a check-in\n", .{verb}) catch {}, error.BaselineUnresolvable => s.err.print("zit {s}: cannot resolve the baseline of a delta check-in\n", .{verb}) catch {}, error.BaselineIsDelta => s.err.print("zit {s}: the baseline of a delta check-in is itself a delta\n", .{verb}) catch {}, } return .failure; } fn openFail(s: cli.Streams, verb: []const u8, e: Repository.OpenError) cli.Exit { switch (e) { error.OutOfMemory => return oom(s, verb), error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {}, error.Corrupt => s.err.print("zit {s}: cannot read repository: repository is corrupt\n", .{verb}) catch {}, } return .failure; }