Skip to content
zit Artifact [2ee73f3269]
Login

Artifact [2ee73f3269]

Artifact 2ee73f32691bdd41b083c283c17bf4551d73e8d09ba111d7880b199ff12de949:


//! The Git identity rule, pinned by vectors: a fixed manifest in, a fixed
//! commit ID out. A Git commit ID hashes the author and committer lines, so
//! the rule that writes them is part of every exported commit's name, and a
//! change to it would rename every commit a mirror has published.
//!
//! Each vector check-in carries no files, so its tree is Git's empty tree
//! and the commit object is the identity lines plus the message, exactly as
//! `git fast-import` writes it. The expected IDs were computed outside zit,
//! with `git hash-object -t commit` over those bytes, and cross-checked by
//! feeding the same commit to a real `git fast-import`.

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

const testing = std.testing;

/// Strip the leading `#` guard block, as `rfc_vectors.zig` does: on disk
/// each vector stays inert, since Fossil crosslinks any blob that parses as
/// an artifact into every clone of this repository.
fn vector(comptime raw: []const u8) []const u8 {
    comptime {
        var i: usize = 0;
        while (i < raw.len and raw[i] == '#') {
            while (i < raw.len and raw[i] != '\n') i += 1;
            if (i < raw.len) i += 1;
        }
        return raw[i..];
    }
}

const guarded = [_][]const u8{
    @embedFile("identity_vectors/contact_login.txt"),
    @embedFile("identity_vectors/plain_login.txt"),
    @embedFile("identity_vectors/no_user.txt"),
    @embedFile("identity_vectors/no_date.txt"),
    @embedFile("identity_vectors/fractional_second.txt"),
};

const contact_login = vector(guarded[0]);
const plain_login = vector(guarded[1]);
const no_user = vector(guarded[2]);
const no_date = vector(guarded[3]);
const fractional_second = vector(guarded[4]);

const empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";

/// The commit ID Git gives a parentless commit of the empty tree.
fn commitId(identity: anytype, message: []const u8, out: *[40]u8) !void {
    var buf: [512]u8 = undefined;
    const line = try std.fmt.bufPrint(buf[256..], "{s} <{s}> {d} +0000", .{ identity.name, identity.email, identity.timestamp });
    const body = try std.fmt.bufPrint(buf[0..256], "tree {s}\nauthor {s}\ncommitter {s}\n\n{s}", .{ empty_tree, line, line, message });
    var header: [32]u8 = undefined;
    const prefix = try std.fmt.bufPrint(&header, "commit {d}\x00", .{body.len});
    var sha = std.crypto.hash.Sha1.init(.{});
    sha.update(prefix);
    sha.update(body);
    var digest: [20]u8 = undefined;
    sha.final(&digest);
    out.* = std.fmt.bytesToHex(digest, .lower);
}

fn expectCommitId(
    text: []const u8,
    rule: export_impl.IdentityRule,
    contacts: ?*const std.StringHashMapUnmanaged([]const u8),
    want: []const u8,
) !void {
    const gpa = testing.allocator;
    var m = try artifact.parse(gpa, text);
    defer m.deinit();
    try expectManifestCommitId(&m, rule, contacts, want);
}

fn expectManifestCommitId(
    m: *const artifact.Manifest,
    rule: export_impl.IdentityRule,
    contacts: ?*const std.StringHashMapUnmanaged([]const u8),
    want: []const u8,
) !void {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const identity = try export_impl.identityOf(arena_state.allocator(), m, rule, contacts);
    try testing.expectEqual(@as(i16, 0), identity.timezone_offset_minutes);
    const message: []const u8 = if (m.c) |c| c.comment else "";
    var got: [40]u8 = undefined;
    try commitId(identity, message, &got);
    try testing.expectEqualStrings(want, &got);
}

const repository_rule: export_impl.IdentityRule = .{ .domain = "example.com" };

test "git identity vectors: every vector file is inert to Fossil on disk" {
    for (guarded) |raw| try testing.expect(artifact.frame(raw) == null);
}

test "git identity vectors: a login with a contact is still login@DOMAIN; contacts are not an input" {
    var contacts: std.StringHashMapUnmanaged([]const u8) = .empty;
    defer contacts.deinit(testing.allocator);
    try contacts.put(testing.allocator, "alice", "[email protected]");
    // author alice <[email protected]> 1704164645 +0000
    try expectCommitId(contact_login, repository_rule, &contacts, "dfd25fc0d2bfcedd78385a3e2066fdf71786b9d7");
    try expectCommitId(contact_login, repository_rule, null, "dfd25fc0d2bfcedd78385a3e2066fdf71786b9d7");
}

test "git identity vectors: a mirror recorded as built from contacts keeps taking the contact's address" {
    var contacts: std.StringHashMapUnmanaged([]const u8) = .empty;
    defer contacts.deinit(testing.allocator);
    try contacts.put(testing.allocator, "alice", "[email protected]");
    // author alice <[email protected]> 1704164645 +0000
    try expectCommitId(contact_login, .{ .domain = "example.com", .contacts = true }, &contacts, "c7c7c707df42de2882ffa9ad05d4f8a60ab38c17");
}

test "git identity vectors: a login with no contact is login@DOMAIN" {
    // author bob <[email protected]> 1704164645 +0000
    try expectCommitId(plain_login, repository_rule, null, "ac3a0d4bf4ff939f6c9ce6382f4b691104b5fb18");
    // The same under a recorded contacts rule: there is no contact to take.
    var contacts: std.StringHashMapUnmanaged([]const u8) = .empty;
    defer contacts.deinit(testing.allocator);
    try contacts.put(testing.allocator, "alice", "[email protected]");
    try expectCommitId(plain_login, .{ .domain = "example.com", .contacts = true }, &contacts, "ac3a0d4bf4ff939f6c9ce6382f4b691104b5fb18");
}

test "git identity vectors: a manifest with no U card is the login unknown, under the default domain" {
    // author unknown <[email protected]> 1704164645 +0000
    try expectCommitId(no_user, .{}, null, "d0ba58be6c4f99f617abd61883f2748f20d88a86");
}

test "git identity vectors: a manifest with no D card is time zero" {
    const gpa = testing.allocator;
    // Without a D card the cards classify as a submanifest, which may not
    // carry C or U, so no stored check-in lacks one ...
    try testing.expectError(error.DisallowedCard, artifact.parse(gpa, no_date));
    // ... but the rule still answers for one: time zero.
    var m = try artifact.parse(gpa, plain_login);
    defer m.deinit();
    m.d = null;
    // author bob <[email protected]> 0 +0000
    try expectManifestCommitId(&m, repository_rule, null, "27e84ab4fc283cee1dde7d88a9701bd0037d7209");
}

test "git identity vectors: a fractional second is truncated to the whole second" {
    // author dave <[email protected]> 1704164645 +0000
    try expectCommitId(fractional_second, repository_rule, null, "ce23a5a8a55162f26785e6d60388d7b3058b4975");
}

test "a contact's email address is the bracketed part, or else the first word with an @" {
    try testing.expectEqualStrings("[email protected]", export_impl.contactAddress("Dan Mestas <[email protected]>").?);
    try testing.expectEqualStrings("[email protected]", export_impl.contactAddress("[email protected]").?);
    try testing.expectEqualStrings("[email protected]", export_impl.contactAddress("mail [email protected], phone 555").?);
    try testing.expect(export_impl.contactAddress("") == null);
    try testing.expect(export_impl.contactAddress("just a name") == null);
    try testing.expect(export_impl.contactAddress("@ alone") == null);
}

test "an email domain is refused where it could break an identity line" {
    try testing.expect(export_impl.validEmailDomain("example.org"));
    try testing.expect(export_impl.validEmailDomain("users.noreply.github.com"));
    try testing.expect(export_impl.validEmailDomain("zit.invalid"));
    try testing.expect(!export_impl.validEmailDomain(""));
    try testing.expect(!export_impl.validEmailDomain("a b"));
    try testing.expect(!export_impl.validEmailDomain("x> 0 +0000\ncommitter"));
    try testing.expect(!export_impl.validEmailDomain("a@b"));
    try testing.expect(!export_impl.validEmailDomain(&@as([export_impl.max_email_domain + 1]u8, @splat('a'))));
}