Artifact a8d163436c3f4b5c24ee2c446992f24858b7edbc5ddb26c62a2cb538febf7d1f:
- File
src/archive.zig
— part of check-in
[c454de8df9]
at
2026-08-17 16:20:45
on branch trunk
— zip: the same archive in the format Windows reads (#95)
Selection, top-level directory, reproducibility, and the refusal to write a partial archive are shared with tarball whole; only the container differs. Zip's header carries the size and CRC, so a member is compressed before its header is written, and deflate is used only when it makes the member smaller. Modes live in the central directory's external attributes, which is where a reader looks and where an executable silently becomes 0644 if you get it wrong -- so the fossil differential compares both formats now. (user: dmestas, size: 30102) [annotate] [blame] [check-ins using]
//! Archive export (#95): a check-in's effective file set written to a tar or //! zip stream instead of to the filesystem. //! //! This module is the FORMAT and the POLICY, deliberately without I/O beyond //! the caller's writer: header encoding, member selection, and the top-level //! directory name are pure functions of their inputs, so the properties that //! matter can be tested without a repository. Enumerating the check-in and //! reading blobs stays in the command, where those handles live. //! //! Every archive this module writes is REPRODUCIBLE: identical inputs give //! identical bytes, on any machine, in any order of runs. That is not a //! side effect to be preserved by care, it is the property that makes an //! archive usable in a release pipeline, and it is easy to lose by accident. //! Three things secure it, and each is load-bearing: //! //! 1. **Member order is the check-in's own.** The effective-set walk yields F //! cards in canonical filename order (artifact §4.5.3), so the caller never //! sorts and never depends on a directory read. //! 2. **Every timestamp comes from history**, never from the clock: members //! carry the check-in's own D-card time, which `SOURCE_DATE_EPOCH` already //! pins for reproducible builds. //! 3. **No identity is embedded.** uid/gid 0, and the gzip container's header //! is a fixed constant with a zeroed mtime and no host field, so neither //! the operator nor the build machine leaks into the bytes. const std = @import("std"); const artifact = @import("artifact.zig"); const control_paths = @import("control_paths.zig"); const ignore = @import("ignore.zig"); const managed = @import("managed.zig"); const testing = std.testing; const Writer = std.Io.Writer; /// tar's fixed block. Every header is one block and every member's content is /// padded up to a multiple of it. pub const tar_block: usize = 512; /// ustar's own name fields: `name` is 100 bytes, `prefix` 155, joined by an /// implied '/'. A path that fits is written as plain ustar and read by every /// tar in existence; a longer one needs the GNU long-name escape below, since /// `managed.max_path_len` (4096) is far past what ustar can spell. pub const ustar_name_len: usize = 100; pub const ustar_prefix_len: usize = 155; /// The uid/gid every member carries. Zero, because an archive that recorded /// the exporting operator's numeric id would differ between two machines /// producing otherwise identical output — the reproducibility property this /// module exists to hold. pub const archive_uid: u32 = 0; pub const archive_gid: u32 = 0; comptime { // The GNU long-name escape is what covers the gap between ustar's spelling // and a real tracked path; if the bound ever fit, the escape would be dead // code and this module should lose it rather than keep it untested. std.debug.assert(managed.max_path_len > ustar_name_len + 1 + ustar_prefix_len); } pub const Error = error{ /// A member's path names a reserved control root. `add` and every import /// path already refuse to create such an F card, so seeing one here means /// the manifest is not one this tool could have written — fail closed /// rather than silently drop it, which is how a partial archive that /// looks complete gets made. ReservedPath, /// A path longer than `managed.max_path_len`, which no legal F card can /// carry and no escape here is sized for. PathTooLong, }; /// One member of the archive, in the order the effective-set walk yielded it. pub const Entry = struct { /// Repository-relative path, WITHOUT the top-level directory — the caller /// supplies that once and this module joins it, so the two cannot drift. path: []const u8, mode: artifact.FileMode, }; /// tar's typeflag byte for a mode. Symlinks stay symlinks (`2`) rather than /// being flattened into a regular file holding their target text, so a tree /// extracted from an archive matches one `zit open` would have written. fn typeFlag(mode: artifact.FileMode) u8 { return switch (mode) { .regular, .executable => '0', .symlink => '2', }; } /// The permission bits a mode earns. Deliberately a fixed table rather than /// anything read from the filesystem: the F card's mode is the whole of what /// history records, so it is the whole of what the archive can honestly claim. fn permBits(mode: artifact.FileMode) u32 { return switch (mode) { .regular => 0o644, .executable => 0o755, .symlink => 0o777, }; } /// Whether a member survives the `--include` / `--exclude` filters. /// /// **Exclude wins**, matching what fossil documents for the same two options. /// The rule is copied rather than decided afresh precisely because it is the /// kind of detail that silently differs between two tools and then surprises /// someone holding an archive that is missing a file they asked for. /// /// An absent `include` means "everything not excluded"; an empty-but-present /// one means the operator asked for a glob list and gave none, which selects /// nothing. Both globs read through the shared fossil-dialect list matcher, so /// `--exclude` here and `ignore-glob` in a setting cannot drift into separate /// dialects. pub fn selected(path: []const u8, include: ?[]const u8, exclude: ?[]const u8) bool { if (exclude) |globs| { if (ignore.listMatches(globs, path)) return false; } if (include) |globs| return ignore.listMatches(globs, path); return true; } /// Validate one effective-set path for archiving. Separate from `selected` so /// a refusal is never confused with a filter miss: one is the operator's own /// glob doing its job, the other is a repository that should not exist. pub fn admit(path: []const u8) Error!void { if (path.len > managed.max_path_len) return error.PathTooLong; if (control_paths.isReservedRoot(path)) return error.ReservedPath; } /// Longest top-level directory name this module will join onto a member path /// and still guarantee the result is spellable. Bounded so `--name` cannot /// push a legal path past what the long-name escape is sized for. pub const max_top_dir_len: usize = 255; /// Archive suffixes stripped when deriving a default top-level directory from /// an output filename. Ordered longest-first so `.tar.gz` wins over `.gz`. const archive_suffixes = [_][]const u8{ ".tar.gz", ".tar.bz2", ".tgz", ".tar", ".zip" }; /// The default top-level directory, when `--name` was not given. /// /// Fossil derives this from the project NAME, which zit has no equivalent of — /// `project-code` is a sync identity, not a human label, and an archive whose /// top directory was a 40-hex sync code would be worse than useless. So the /// derivation is: the output filename's basename with archive suffixes /// stripped (`release-1.2.tar.gz` gives `release-1.2`, which is what an /// operator naming an output file almost always meant), falling back to the /// check-in's own short artifact id when there is no usable output name — /// `--list`, `/dev/null`, or the empty string. /// /// `out` must hold `max_top_dir_len` bytes; the result borrows it. pub fn defaultTopDir(output_path: []const u8, checkin_prefix: []const u8, out: []u8) []const u8 { std.debug.assert(out.len >= max_top_dir_len); std.debug.assert(checkin_prefix.len >= 1); const base = std.fs.path.basename(output_path); var name = base; for (archive_suffixes) |suffix| { // `>=`, not `>`: a name that IS a suffix (`.tar.gz`) strips to empty // and falls back below, rather than standing as a top directory // literally called `.tar.gz`. if (name.len >= suffix.len and std.ascii.endsWithIgnoreCase(name, suffix)) { name = name[0 .. name.len - suffix.len]; break; } } // `/dev/null` basenames to "null", which would be a silently wrong top // directory rather than an obviously missing one; treat every unusable // output name the same way and fall back to the check-in. if (name.len == 0 or name.len > max_top_dir_len or std.mem.eql(u8, base, "null")) { const n = @min(checkin_prefix.len, max_top_dir_len); @memcpy(out[0..n], checkin_prefix[0..n]); return out[0..n]; } @memcpy(out[0..name.len], name); return out[0..name.len]; } /// Split `path` into ustar's `prefix` and `name` fields, or null when it /// cannot be spelled that way. ustar joins them with an implied '/', so the /// split must land ON a separator and neither half may overflow its field. /// Returns the longest legal prefix, which is what maximises the number of /// paths that avoid the long-name escape. fn ustarSplit(path: []const u8) ?struct { prefix: []const u8, name: []const u8 } { if (path.len <= ustar_name_len) return .{ .prefix = "", .name = path }; if (path.len > ustar_prefix_len + 1 + ustar_name_len) return null; // Walk separators from the latest legal split backwards: the name half // must fit in 100 bytes, the prefix half in 155. var cut: ?usize = null; var i: usize = 0; while (i < path.len) : (i += 1) { if (path[i] != '/') continue; const prefix_len = i; const name_len = path.len - i - 1; if (prefix_len == 0 or prefix_len > ustar_prefix_len) continue; if (name_len == 0 or name_len > ustar_name_len) continue; cut = i; // keep scanning; a later cut shortens the name half } const c = cut orelse return null; return .{ .prefix = path[0..c], .name = path[c + 1 ..] }; } /// Write `value` as tar's zero-padded octal with a trailing NUL, the encoding /// every field but the checksum uses. fn octal(field: []u8, value: u64) void { std.debug.assert(field.len >= 2); const digits = field.len - 1; var v = value; var i: usize = digits; while (i > 0) { i -= 1; field[i] = '0' + @as(u8, @intCast(v & 7)); v >>= 3; } std.debug.assert(v == 0); // caller's value must fit the field field[digits] = 0; } /// The ustar header checksum: the sum of every header byte with the checksum /// field itself read as eight spaces. Written as six octal digits, a NUL, and /// a space — the historical encoding every reader accepts. fn checksum(header: *[tar_block]u8) void { @memset(header[148..156], ' '); var sum: u64 = 0; for (header) |b| sum += b; octal(header[148..155], sum); header[155] = ' '; } /// Encode one ustar header block. `link_target` is the symlink destination /// and must be null for every other mode. fn ustarHeader( name: []const u8, prefix: []const u8, size: u64, mode: artifact.FileMode, mtime: u64, link_target: ?[]const u8, type_flag: u8, out: *[tar_block]u8, ) void { std.debug.assert(name.len <= ustar_name_len); std.debug.assert(prefix.len <= ustar_prefix_len); @memset(out, 0); @memcpy(out[0..name.len], name); octal(out[100..108], permBits(mode)); octal(out[108..116], archive_uid); octal(out[116..124], archive_gid); octal(out[124..136], size); octal(out[136..148], mtime); out[156] = type_flag; if (link_target) |target| { std.debug.assert(target.len <= ustar_name_len); @memcpy(out[157..][0..target.len], target); } @memcpy(out[257..263], "ustar\x00"); @memcpy(out[263..265], "00"); // uname/gname stay EMPTY rather than "root": a name is a claim about the // extracting system's account table that this archive has no basis for, // and readers fall back to the numeric id, which is the honest answer. @memcpy(out[345..][0..prefix.len], prefix); checksum(out); } /// Pad the current member up to tar's block boundary. fn padTo(w: *Writer, written: u64) Writer.Error!void { const rem = written % tar_block; if (rem == 0) return; var zeros: [tar_block]u8 = @splat(0); try w.writeAll(zeros[0 .. tar_block - rem]); } /// GNU's long-name escape: a pseudo-member of type 'L' whose CONTENT is the /// real path, immediately followed by the real header carrying a truncated /// name. Chosen over pax extended headers because tracked paths reach 4096 /// bytes and this is the escape the widest set of readers implements — /// GNU tar, bsdtar, and Python's tarfile all honour it. const gnu_longname_flag: u8 = 'L'; const gnu_longname_name = "././@LongLink"; /// Write one archive member's header, then `size` bytes of content the caller /// streams, then the block padding. `path` already includes the top-level /// directory. The caller writes content between this call and `endTarMember`. pub fn beginTarMember( w: *Writer, path: []const u8, size: u64, mode: artifact.FileMode, mtime: u64, link_target: ?[]const u8, ) Writer.Error!void { var header: [tar_block]u8 = undefined; if (ustarSplit(path)) |split| { ustarHeader(split.name, split.prefix, size, mode, mtime, link_target, typeFlag(mode), &header); try w.writeAll(&header); return; } // Too long for ustar's two fields: emit the name as a 'L' member's // content, then a header whose own name field is a truncated stand-in. // A reader that does not know 'L' extracts a file literally called // `././@LongLink`, which is visibly wrong rather than silently truncated. ustarHeader(gnu_longname_name, "", path.len + 1, .regular, 0, null, gnu_longname_flag, &header); try w.writeAll(&header); try w.writeAll(path); try w.writeByte(0); try padTo(w, path.len + 1); ustarHeader(path[0..ustar_name_len], "", size, mode, mtime, link_target, typeFlag(mode), &header); try w.writeAll(&header); } /// Close a member started by `beginTarMember` after `size` content bytes. pub fn endTarMember(w: *Writer, size: u64) Writer.Error!void { try padTo(w, size); } /// tar's end-of-archive marker: two zero blocks. pub fn writeTarEnd(w: *Writer) Writer.Error!void { var zeros: [tar_block * 2]u8 = @splat(0); try w.writeAll(&zeros); } // --------------------------------------------------------------------------- // zip. // // The same member set and the same selection as tar; only the container // differs. Two zip-specific facts shape everything below: // // * Sizes and the CRC go in the header that PRECEDES the data, so a member // is compressed into memory before anything is written. (Zip's alternative // is a trailing data descriptor, which is what streaming writers use and // which a meaningful minority of readers still mishandle. Blobs are // already reconstructed whole in memory, so the compatible choice is free.) // * Modes live in the central directory's `external attributes`, high 16 // bits, and only when `version made by` says Unix. Get either wrong and // every member silently unpacks 0644 — the same archetypal release bug // tar's typeflag guards against, hidden one field deeper. pub const zip_method_stored: u16 = 0; pub const zip_method_deflate: u16 = 8; /// `version made by`: low byte the spec version (3.0), high byte the source /// filesystem. 3 is Unix, and it is what makes a reader honour the mode bits /// in `external attributes` instead of ignoring them. const zip_version_made_by: u16 = (3 << 8) | 30; /// `version needed to extract`: 2.0, the version that introduced deflate. const zip_version_needed: u16 = 20; const zip_sig_local: u32 = 0x04034b50; const zip_sig_central: u32 = 0x02014b50; const zip_sig_end: u32 = 0x06054b50; /// Largest member a zip32 archive can carry. Beyond this the format needs the /// zip64 extensions, which this writer does not emit — refused by name rather /// than written as a truncated size field that unpacks to garbage. pub const zip_max_member_bytes: u64 = std.math.maxInt(u32); /// One member's finished, compressed form. Built before any output because /// zip's header carries the sizes and the checksum. pub const ZipMember = struct { /// Path inside the archive, top-level directory already joined. path: []const u8, mode: artifact.FileMode, crc: u32, compressed_size: u32, uncompressed_size: u32, method: u16, /// Byte offset of this member's local header, for the central directory. local_offset: u32, }; /// The `external attributes` word: a Unix `st_mode` in the high 16 bits. A /// symlink is `S_IFLNK`, which is what makes a reader recreate a link rather /// than a small text file holding the target. fn zipExternalAttrs(mode: artifact.FileMode) u32 { const unix_mode: u32 = switch (mode) { .regular => 0o100000 | 0o644, // S_IFREG .executable => 0o100000 | 0o755, .symlink => 0o120000 | 0o777, // S_IFLNK }; return unix_mode << 16; } /// MS-DOS date and time, from a Unix timestamp in UTC. /// /// DOS time starts at 1980 and stores seconds in two-second units, so this is /// a lossy field by design — which is fine, because it is not what carries the /// archive's fidelity. It is derived from the CHECK-IN's timestamp rather than /// the clock for the same reason tar's mtime is: two runs must agree. Anything /// before 1980 clamps to the DOS epoch rather than wrapping into a date the /// format cannot spell. pub fn dosDateTime(unix_secs: u64) struct { time: u16, date: u16 } { const dos_epoch: u64 = 315_532_800; // 1980-01-01T00:00:00Z if (unix_secs < dos_epoch) return .{ .time = 0, .date = (1 << 5) | 1 }; // 1980-01-01 const es: std.time.epoch.EpochSeconds = .{ .secs = unix_secs }; const day = es.getEpochDay(); const year_day = day.calculateYearDay(); const month_day = year_day.calculateMonthDay(); const secs = es.getDaySeconds(); const year: u16 = @intCast(year_day.year - 1980); const month: u16 = month_day.month.numeric(); const day_of_month: u16 = @as(u16, month_day.day_index) + 1; return .{ .time = (@as(u16, secs.getHoursIntoDay()) << 11) | (@as(u16, secs.getMinutesIntoHour()) << 5) | (@as(u16, secs.getSecondsIntoMinute()) / 2), .date = (year << 9) | (month << 5) | day_of_month, }; } fn putU16(w: *Writer, v: u16) Writer.Error!void { var b: [2]u8 = undefined; std.mem.writeInt(u16, &b, v, .little); try w.writeAll(&b); } fn putU32(w: *Writer, v: u32) Writer.Error!void { var b: [4]u8 = undefined; std.mem.writeInt(u32, &b, v, .little); try w.writeAll(&b); } /// A member's local header. The caller writes `compressed_size` bytes after it. pub fn writeZipLocal(w: *Writer, m: ZipMember, unix_secs: u64) Writer.Error!void { const dos = dosDateTime(unix_secs); try putU32(w, zip_sig_local); try putU16(w, zip_version_needed); try putU16(w, 0); // no flags: sizes are known, so no data descriptor try putU16(w, m.method); try putU16(w, dos.time); try putU16(w, dos.date); try putU32(w, m.crc); try putU32(w, m.compressed_size); try putU32(w, m.uncompressed_size); try putU16(w, @intCast(m.path.len)); try putU16(w, 0); // no extra field try w.writeAll(m.path); } /// One central-directory entry. This is the record a reader actually trusts /// for metadata, and the only place the mode appears. pub fn writeZipCentral(w: *Writer, m: ZipMember, unix_secs: u64) Writer.Error!void { const dos = dosDateTime(unix_secs); try putU32(w, zip_sig_central); try putU16(w, zip_version_made_by); try putU16(w, zip_version_needed); try putU16(w, 0); try putU16(w, m.method); try putU16(w, dos.time); try putU16(w, dos.date); try putU32(w, m.crc); try putU32(w, m.compressed_size); try putU32(w, m.uncompressed_size); try putU16(w, @intCast(m.path.len)); try putU16(w, 0); // extra try putU16(w, 0); // comment try putU16(w, 0); // disk number try putU16(w, 0); // internal attributes try putU32(w, zipExternalAttrs(m.mode)); try putU32(w, m.local_offset); try w.writeAll(m.path); } /// The end-of-central-directory record that closes the archive. pub fn writeZipEnd(w: *Writer, count: u16, cd_size: u32, cd_offset: u32) Writer.Error!void { try putU32(w, zip_sig_end); try putU16(w, 0); // this disk try putU16(w, 0); // disk with the central directory try putU16(w, count); try putU16(w, count); try putU32(w, cd_size); try putU32(w, cd_offset); try putU16(w, 0); // no archive comment } /// The bytes a local header occupies for `path` — the writer needs this to /// track each member's offset without seeking. pub fn zipLocalHeaderLen(path: []const u8) u32 { return 30 + @as(u32, @intCast(path.len)); } /// The bytes a central-directory entry occupies for `path`. pub fn zipCentralHeaderLen(path: []const u8) u32 { return 46 + @as(u32, @intCast(path.len)); } test "archive selected: exclude wins over include, absent include admits" { // The precedence rule copied from fossil, pinned because it is the detail // that silently differs between tools. try testing.expect(selected("src/a.zig", null, null)); try testing.expect(selected("src/a.zig", "src/*", null)); try testing.expect(!selected("src/a.zig", null, "src/*")); // Matching BOTH is excluded — the whole point of the rule. try testing.expect(!selected("src/a.zig", "src/*", "src/*")); try testing.expect(!selected("src/a.zig", "*.zig", "src/a.zig")); // An include that matches nothing selects nothing. try testing.expect(!selected("src/a.zig", "docs/*", null)); // A present-but-empty include is a list with no patterns: it matches // nothing, which is different from having asked for no filter at all. try testing.expect(!selected("src/a.zig", "", null)); try testing.expect(selected("src/a.zig", null, "")); } test "archive admit: a reserved control root is refused, not filtered" { try admit("src/a.zig"); try admit("README"); try testing.expectError(error.ReservedPath, admit(".zit")); try testing.expectError(error.ReservedPath, admit(".zit/store")); try testing.expectError(error.ReservedPath, admit(".zit-checkout")); try testing.expectError(error.ReservedPath, admit(".zit-cache/x")); // A name that merely starts with the same bytes is a normal file. try admit(".zitten"); } test "archive defaultTopDir: derived from the output name, else the check-in" { var buf: [max_top_dir_len]u8 = undefined; try testing.expectEqualStrings("release-1.2", defaultTopDir("release-1.2.tar.gz", "abc123", &buf)); try testing.expectEqualStrings("release-1.2", defaultTopDir("out/release-1.2.zip", "abc123", &buf)); try testing.expectEqualStrings("release-1.2", defaultTopDir("release-1.2.tgz", "abc123", &buf)); try testing.expectEqualStrings("snap", defaultTopDir("snap.tar", "abc123", &buf)); // No recognised suffix: the basename stands as given. try testing.expectEqualStrings("snapshot", defaultTopDir("snapshot", "abc123", &buf)); // The three unusable output names all fall back to the check-in, and // /dev/null must NOT become a top directory called "null". try testing.expectEqualStrings("abc123", defaultTopDir("", "abc123", &buf)); try testing.expectEqualStrings("abc123", defaultTopDir("/dev/null", "abc123", &buf)); // A suffix with nothing before it is not a name. try testing.expectEqualStrings("abc123", defaultTopDir(".tar.gz", "abc123", &buf)); } test "archive ustarSplit: fits, splits on a separator, or refuses" { // Short enough for the name field alone. const short = ustarSplit("src/a.zig").?; try testing.expectEqualStrings("", short.prefix); try testing.expectEqualStrings("src/a.zig", short.name); // 100 bytes exactly still fits the name field. const exact = "a" ** 100; try testing.expectEqualStrings(exact, ustarSplit(exact).?.name); // Longer than 100 with a separator positioned so both halves fit: the // split lands on the separator and the halves rejoin to the original. const long = "d" ** 90 ++ "/" ++ "f" ** 40; const split = ustarSplit(long).?; try testing.expectEqualStrings("d" ** 90, split.prefix); try testing.expectEqualStrings("f" ** 40, split.name); // Over 100 with NO separator cannot be spelled by ustar at all. try testing.expect(ustarSplit("a" ** 120) == null); // A tail longer than the name field cannot be spelled either, however // early the separator sits. try testing.expect(ustarSplit("d/" ++ "f" ** 120) == null); // Past the joined bound entirely. try testing.expect(ustarSplit("d" ** 200 ++ "/" ++ "f" ** 90) == null); } test "archive octal and checksum: the encodings every tar reader expects" { var field: [8]u8 = undefined; octal(&field, 0o644); try testing.expectEqualStrings("0000644\x00", &field); octal(&field, 0); try testing.expectEqualStrings("0000000\x00", &field); // A header's checksum covers every byte with the field read as spaces, so // recomputing over the finished block must reproduce the stored value. var header: [tar_block]u8 = undefined; ustarHeader("a.txt", "", 3, .regular, 1_600_000_000, null, '0', &header); const stored = header[148..156].*; var sum: u64 = 0; for (header, 0..) |b, i| sum += if (i >= 148 and i < 156) ' ' else b; var expect: [8]u8 = undefined; octal(expect[0..7], sum); expect[7] = ' '; try testing.expectEqualSlices(u8, &expect, &stored); try testing.expectEqualStrings("ustar\x00", header[257..263]); try testing.expectEqualStrings("00", header[263..265]); // No identity is embedded: uid, gid, and both name fields stay zero. try testing.expectEqualStrings("0000000\x00", header[108..116]); try testing.expectEqualStrings("0000000\x00", header[116..124]); for (header[265..345]) |b| try testing.expectEqual(@as(u8, 0), b); } test "archive zip: modes ride the external attributes, where a reader looks" { // The mode lives in the CENTRAL directory's high 16 bits, and only counts // when `version made by` says Unix — get either wrong and every member // unpacks 0644 while the archive looks perfectly correct. var buf: [256]u8 = undefined; var w = Writer.fixed(&buf); const m: ZipMember = .{ .path = "top/configure", .mode = .executable, .crc = 0xdeadbeef, .compressed_size = 7, .uncompressed_size = 11, .method = zip_method_deflate, .local_offset = 42, }; try writeZipCentral(&w, m, 1_600_000_000); const out = w.buffered(); try testing.expectEqual(zip_sig_central, std.mem.readInt(u32, out[0..4], .little)); // High byte 3 = Unix. Without it the attributes below are ignored. try testing.expectEqual(@as(u16, (3 << 8) | 30), std.mem.readInt(u16, out[4..6], .little)); try testing.expectEqual(@as(u32, 0xdeadbeef), std.mem.readInt(u32, out[16..20], .little)); try testing.expectEqual(@as(u32, 7), std.mem.readInt(u32, out[20..24], .little)); try testing.expectEqual(@as(u32, 11), std.mem.readInt(u32, out[24..28], .little)); try testing.expectEqual(@as(u32, 0o100755 << 16), std.mem.readInt(u32, out[38..42], .little)); try testing.expectEqual(@as(u32, 42), std.mem.readInt(u32, out[42..46], .little)); try testing.expectEqualStrings("top/configure", out[46..]); try testing.expectEqual(zipCentralHeaderLen("top/configure"), @as(u32, @intCast(out.len))); // A symlink must be S_IFLNK, or it recreates as a text file holding the // target — the failure that looks correct until someone follows the link. try testing.expectEqual(@as(u32, 0o120777 << 16), zipExternalAttrs(.symlink)); try testing.expectEqual(@as(u32, 0o100644 << 16), zipExternalAttrs(.regular)); } test "archive zip: the local header's length matches what the writer assumes" { var buf: [256]u8 = undefined; var w = Writer.fixed(&buf); const m: ZipMember = .{ .path = "top/a.txt", .mode = .regular, .crc = 1, .compressed_size = 2, .uncompressed_size = 3, .method = zip_method_stored, .local_offset = 0, }; try writeZipLocal(&w, m, 1_600_000_000); // Offsets are tracked by arithmetic rather than by seeking, so a header // whose real length disagreed with the predicted one would put every // central-directory offset past the first member off by a constant. try testing.expectEqual(zipLocalHeaderLen("top/a.txt"), @as(u32, @intCast(w.buffered().len))); } test "archive dosDateTime: derived from history, clamped below the DOS epoch" { // 2020-09-13T12:26:40Z, the SOURCE_DATE_EPOCH the e2e fixtures pin. const dt = dosDateTime(1_600_000_000); try testing.expectEqual(@as(u16, 2020 - 1980), dt.date >> 9); try testing.expectEqual(@as(u16, 9), (dt.date >> 5) & 0xf); try testing.expectEqual(@as(u16, 13), dt.date & 0x1f); // DOS cannot spell a date before 1980; clamping beats wrapping into a // year the format would render as garbage. const early = dosDateTime(0); try testing.expectEqual(@as(u16, 0), early.time); try testing.expectEqual(@as(u16, (1 << 5) | 1), early.date); // Seconds are stored in two-second units, so the field is lossy by design. try testing.expectEqual(dosDateTime(100).date, dosDateTime(101).date); } test "archive header modes: perms and typeflags follow the F card, not the disk" { var header: [tar_block]u8 = undefined; ustarHeader("x", "", 0, .regular, 0, null, typeFlag(.regular), &header); try testing.expectEqualStrings("0000644\x00", header[100..108]); try testing.expectEqual(@as(u8, '0'), header[156]); ustarHeader("x", "", 0, .executable, 0, null, typeFlag(.executable), &header); try testing.expectEqualStrings("0000755\x00", header[100..108]); try testing.expectEqual(@as(u8, '0'), header[156]); // A symlink stays a symlink and carries its target in the link field. ustarHeader("x", "", 0, .symlink, 0, "../target", typeFlag(.symlink), &header); try testing.expectEqual(@as(u8, '2'), header[156]); try testing.expectEqualStrings("../target", header[157..166]); }