//! Archive export: 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 repeat = @import("repeat.zig").repeat;
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;
/// General-purpose bit 11: the member name is UTF-8. Without it a conforming
/// reader must decode the name as CP437, so a correctly stored non-ASCII name
/// extracts as mojibake. Set on every member rather than only the non-ASCII
/// ones: ASCII is a subset of UTF-8, every reader treats a flagged ASCII name
/// identically, and one unconditional value cannot disagree with itself. Both
/// headers take it from here, because a reader trusts the central directory
/// for metadata and a fix to the local header alone would still be wrong.
const zip_flag_utf8_names: u16 = 0x0800;
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);
// Bit 3 (a trailing data descriptor) stays clear: the sizes are known
// before the header is written. Bit 11 says the name is UTF-8.
try putU16(w, zip_flag_utf8_names);
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, zip_flag_utf8_names);
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 filters tokenize their glob lists as fossil does" {
// The `--include`/`--exclude` lists go through the same tokenizer as every
// `*-glob` setting, so whitespace separates and quotes group here too.
try testing.expect(selected("src/a.zig", "docs/* src/*", null));
try testing.expect(!selected("src/a.zig", null, "docs/* src/*"));
try testing.expect(selected("sp ace.txt", "\"sp ace.txt\"", null));
try testing.expect(!selected("sp", "\"sp ace.txt\"", 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 = repeat("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 = repeat("d", 90) ++ "/" ++ repeat("f", 40);
const split = ustarSplit(long).?;
try testing.expectEqualStrings(repeat("d", 90), split.prefix);
try testing.expectEqualStrings(repeat("f", 40), split.name);
// Over 100 with NO separator cannot be spelled by ustar at all.
try testing.expect(ustarSplit(repeat("a", 120)) == null);
// A tail longer than the name field cannot be spelled either, however
// early the separator sits.
try testing.expect(ustarSplit("d/" ++ repeat("f", 120)) == null);
// Past the joined bound entirely.
try testing.expect(ustarSplit(repeat("d", 200) ++ "/" ++ repeat("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: both headers flag the name as UTF-8, so a non-ASCII name is not mojibake" {
// The name bytes were always correct UTF-8; the flag saying so was never
// set, and a conforming reader must then read them as CP437 -- so
// `café.txt` extracted as `caf├⌐.txt`. The local and central records are
// written by different functions, and a reader trusts the central one for
// metadata, so both are pinned here: fixing one alone still ships the bug.
const path = "top/café.txt";
const m: ZipMember = .{
.path = path,
.mode = .regular,
.crc = 1,
.compressed_size = 2,
.uncompressed_size = 3,
.method = zip_method_stored,
.local_offset = 0,
};
var local_buf: [256]u8 = undefined;
var lw = Writer.fixed(&local_buf);
try writeZipLocal(&lw, m, 1_600_000_000);
const local = lw.buffered();
// Local: signature(4) + version needed(2), then the flags.
const local_flags = std.mem.readInt(u16, local[6..8], .little);
try testing.expect(local_flags & zip_flag_utf8_names != 0);
try testing.expectEqual(@as(u16, 0), local_flags & 0x0008); // bit 3 stays clear
var central_buf: [256]u8 = undefined;
var cw = Writer.fixed(¢ral_buf);
try writeZipCentral(&cw, m, 1_600_000_000);
const central = cw.buffered();
// Central: signature(4) + version made by(2) + version needed(2), then flags.
const central_flags = std.mem.readInt(u16, central[8..10], .little);
try testing.expect(central_flags & zip_flag_utf8_names != 0);
try testing.expectEqual(local_flags, central_flags); // the two records agree
// The name itself is unchanged UTF-8, and the predicted header lengths
// still hold for a multi-byte name.
try testing.expectEqualStrings(path, local[local.len - path.len ..]);
try testing.expectEqual(zipLocalHeaderLen(path), @as(u32, @intCast(local.len)));
try testing.expectEqual(zipCentralHeaderLen(path), @as(u32, @intCast(central.len)));
}
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]);
}