Skip to content
zit Artifact [bac3a549cc]
Login

Artifact [bac3a549cc]

Artifact bac3a549cc907590993aa9377c581d721afe643b47045c5c9148c16984efc335:


//! Fossil's sterilization of tracked files whose bytes are a structural
//! artifact, as `fossil import --git` applies it (src/import.c
//! `check_and_add_file`, src/manifest.c `sterilize_manifest`).
//!
//! A store names a blob by its content hash and nothing else, so a file whose
//! bytes parse as a check-in, technote, tag or any other structural artifact
//! is crosslinked as one by every reader -- zit, and Fossil after a clone or a
//! rebuild. A Git repository that keeps raw Fossil artifacts as test fixtures
//! would otherwise come out of an import with phantom check-ins. Fossil's
//! import appends a comment line after the final card, before a trailing
//! signature block, which the card-order rule rejects
//! (draft-fossil-artifact-format ยง4.1.2), so the stored blob is file content
//! only, on every peer, with no out-of-band marking to lose on sync.
//!
//! The marker is always the control-artifact variant, for a check-in too:
//! `check_and_add_file` passes -1, not the check-in type. That choice is also
//! what keeps the mapping invertible. The manifest variant is what a Fossil
//! mirror's own `manifest` file already carries, and a file that ends in it is
//! not an artifact, so it is stored untouched; had the import used the same
//! line, export could not tell that file from a sterilized raw check-in.
//!
//! export-git undoes exactly what the import did. A file is ARTIFACT-SHAPED
//! when it parses as a structural artifact once every control marker at its
//! card boundary is peeled off. The import adds one marker to every
//! artifact-shaped file and the export removes one from every artifact-shaped
//! blob that ends in one. A Git file that already ends in the marker over an
//! artifact (a Fossil import exported back to Git) is artifact-shaped too, so
//! it gains a second marker and loses exactly that one on the way out: the
//! round trip is byte-identical for every file, and no stored blob parses.

const std = @import("std");
const artifact = @import("../artifact.zig");

/// Fossil's `sterilize_manifest(p, -1)` line, byte for byte.
pub const control_sterilizer = "# Remove this line to create a well-formed Fossil control artifact.\n";

const pgp_clearsign_open = "-----BEGIN PGP SIGNED MESSAGE-----";
const ssh_clearsign_open = "-----BEGIN SSH SIGNED MESSAGE-----";

/// The bytes the import stores for a file, or null when they are the file's
/// own: an artifact-shaped file comes back with the marker spliced in at its
/// card boundary. The caller owns the result.
pub fn sterilize(gpa: std.mem.Allocator, bytes: []const u8) error{OutOfMemory}!?[]u8 {
    if (!try isArtifactShaped(gpa, bytes)) return null;
    const end = cardsEnd(bytes);
    return try std.mem.concat(gpa, u8, &.{ bytes[0..end], control_sterilizer, bytes[end..] });
}

/// The file bytes the import was given for a stored blob, or null when the
/// blob is them already: one marker removed from an artifact-shaped blob that
/// ends in one. The caller owns the result.
pub fn unsterilize(gpa: std.mem.Allocator, bytes: []const u8) error{OutOfMemory}!?[]u8 {
    const end = cardsEnd(bytes);
    if (!std.mem.endsWith(u8, bytes[0..end], control_sterilizer)) return null;
    if (!try isArtifactShaped(gpa, bytes)) return null;
    return try std.mem.concat(gpa, u8, &.{ bytes[0 .. end - control_sterilizer.len], bytes[end..] });
}

/// Whether `bytes`, with every control marker at the card boundary peeled
/// off, parse as a structural artifact. The parse is the import-tolerant one,
/// the widest any reader of the store applies, so nothing a reader would
/// crosslink escapes.
///
/// Two bytes reject essentially all real file content before anything is
/// parsed: an artifact opens with a card, an uppercase letter followed by SP
/// or LF, or with a clearsign header.
pub fn isArtifactShaped(gpa: std.mem.Allocator, bytes: []const u8) error{OutOfMemory}!bool {
    if (bytes.len < 2) return false;
    const card_shaped = bytes[0] >= 'A' and bytes[0] <= 'Z' and (bytes[1] == ' ' or bytes[1] == '\n');
    if (!card_shaped and !std.mem.startsWith(u8, bytes, pgp_clearsign_open) and
        !std.mem.startsWith(u8, bytes, ssh_clearsign_open)) return false;

    const end = cardsEnd(bytes);
    var cards_end = end;
    while (std.mem.endsWith(u8, bytes[0..cards_end], control_sterilizer)) cards_end -= control_sterilizer.len;
    if (cards_end == end) return parses(gpa, bytes);
    const peeled = try std.mem.concat(gpa, u8, &.{ bytes[0..cards_end], bytes[end..] });
    defer gpa.free(peeled);
    return parses(gpa, peeled);
}

fn parses(gpa: std.mem.Allocator, bytes: []const u8) error{OutOfMemory}!bool {
    var parsed = artifact.parseForImport(gpa, bytes) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return false,
    };
    parsed.deinit(gpa);
    return true;
}

/// The offset just past the final card: the end of the signed body of a
/// clearsigned artifact, where `sterilize_manifest` splices its line, and the
/// end of the bytes otherwise.
fn cardsEnd(bytes: []const u8) usize {
    const body = artifact.stripSignatureWrapper(bytes);
    if (body.len == 0) return bytes.len;
    const end = @intFromPtr(body.ptr) - @intFromPtr(bytes.ptr) + body.len;
    std.debug.assert(end <= bytes.len);
    return end;
}

// Real artifacts, written by fossil 2.x: a check-in naming a parent and a file
// this store never holds, a technote, and a tag control artifact.
const fixture_checkin =
    \\C sparse\scommit
    \\D 2026-10-23T00:47:51.328
    \\F README a57d4ad9fb250f5a211a3ed89d378cd5aaa6fc8da2d626cac4868d9964840965
    \\P 973a9910b48160590ebea3ee7f2a4ef4dccea66a0609afa636303ce984ab4df7
    \\R 4a2f3eb03fa2d776c4ce076c13863f30
    \\U u
    \\Z af1e769c68cc3c121e2da2b27964c4ff
    \\
;
const fixture_technote =
    \\C a\snote
    \\D 2026-10-23T00:47:57.876
    \\E 2026-01-02T03:04:05 768fb88c0f4a48b522c937ce3f31ae41c223b65d
    \\N text/plain
    \\U u
    \\W 10
    \\note body
    \\
    \\Z 885540e498e2ad8549c9b5f87808e67f
    \\
;
const fixture_tag =
    \\D 2026-10-23T00:47:51.343
    \\T +sym-release c887f9cb7f5508a9c80a768cca0b61f2735fc7fbfe553117a0389bb08cc2a88a
    \\U u
    \\Z 01fc88bc79d1f34680020972308023a3
    \\
;
const manifest_sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n";
const signature =
    "-----BEGIN PGP SIGNATURE-----\n" ++
    "iEYEARECAAYFAk507VMACgkQoxKgR168RlEAVgCfXvTVgVXWPGlRVI4ZDt0OzYbj\n" ++
    "=LZzm\n" ++
    "-----END PGP SIGNATURE-----\n";
const signed_checkin = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA1\n\n" ++ fixture_checkin ++ signature;

fn expectParses(bytes: []const u8, want: bool) !void {
    try std.testing.expectEqual(want, try parses(std.testing.allocator, bytes));
}

test "file sterilizer: every artifact-shaped file is stored unparseable and exported byte-identical" {
    const gpa = std.testing.allocator;
    const twice = control_sterilizer ++ control_sterilizer;
    const Case = struct { file: []const u8, stored: ?[]const u8 };
    const cases = [_]Case{
        // Each structural type gains the control-artifact line after its Z card.
        .{ .file = fixture_checkin, .stored = fixture_checkin ++ control_sterilizer },
        .{ .file = fixture_technote, .stored = fixture_technote ++ control_sterilizer },
        .{ .file = fixture_tag, .stored = fixture_tag ++ control_sterilizer },
        // A signature stays trailing: the line goes before it, as Fossil splices it.
        .{
            .file = signed_checkin,
            .stored = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA1\n\n" ++ fixture_checkin ++ control_sterilizer ++ signature,
        },
        // Already sterilized by Fossil's import: a second line, so export can
        // take exactly one back off.
        .{ .file = fixture_tag ++ control_sterilizer, .stored = fixture_tag ++ twice },
        .{ .file = fixture_checkin ++ twice, .stored = fixture_checkin ++ twice ++ control_sterilizer },
        // A Fossil mirror's `manifest` file is not an artifact, and stays as is.
        .{ .file = fixture_checkin ++ manifest_sterilizer, .stored = null },
        // Not artifacts: a damaged Z card, ordinary text, text that merely
        // ends in the line, and the line alone.
        .{ .file = fixture_tag[0 .. fixture_tag.len - 3] ++ "00\n", .stored = null },
        .{ .file = "hello\n", .stored = null },
        .{ .file = "A note\n" ++ control_sterilizer, .stored = null },
        .{ .file = control_sterilizer, .stored = null },
        .{ .file = "", .stored = null },
    };
    for (cases) |case| {
        const stored = try sterilize(gpa, case.file);
        defer if (stored) |bytes| gpa.free(bytes);
        if (case.stored) |want| {
            try std.testing.expectEqualStrings(want, stored orelse return error.NotSterilized);
        } else try std.testing.expect(stored == null);
        const kept = stored orelse case.file;
        // No stored blob is a structural artifact.
        try expectParses(kept, false);
        // Export gives back the file exactly.
        const exported = try unsterilize(gpa, kept);
        defer if (exported) |bytes| gpa.free(bytes);
        try std.testing.expectEqualStrings(case.file, exported orelse kept);
        try std.testing.expectEqual(stored != null, exported != null);
    }
    // The fixtures themselves are what a reader would crosslink.
    for ([_][]const u8{ fixture_checkin, fixture_technote, fixture_tag, signed_checkin }) |bytes| try expectParses(bytes, true);
}