//! Scripted-session tests for `ExportSession`: a Git double cans every
//! subprocess answer, so each failure shape is pinned — exit path, precise
//! diagnostic, and an UNCHANGED `MirrorState` — without spawning anything.
const std = @import("std");
const zit = @import("zit");
const artifact = zit.artifact;
const logstore = zit.logstore;
const storage = zit.storage;
const Repository = zit.Repository;
const export_cli = @import("git_export_cli.zig");
const export_state = zit.git_export;
const testing = std.testing;
// --- fixture ----------------------------------------------------------------
const Fixture = struct {
tmp: std.testing.TmpDir,
io: std.Io,
repo: Repository,
root_hash: []u8,
/// Set by `initWith(.{ .child = true })`: a second check-in on the root,
/// appended after `source_key` was taken, so a state recorded against
/// that key covers the root and the next run exports the child alone.
child_hash: ?[]u8 = null,
source_key: logstore.LogStore.LogKey,
const Options = struct {
child: bool = false,
/// The user `u`'s contact in the user table.
contact: ?[]const u8 = null,
};
fn init(self: *Fixture, io: std.Io) !void {
return self.initWith(io, .{});
}
fn initWith(self: *Fixture, io: std.Io, options: Options) !void {
const gpa = testing.allocator;
self.io = io;
self.tmp = testing.tmpDir(.{});
var raw = try self.tmp.dir.createFile(io, ".zit", .{ .read = true });
var fs = storage.FileStorage.init(io, raw, 0);
var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
try log.putConfig(.{ .hash_policy = 0, .default_branch = "trunk", .project_id = "p" });
var blob_digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash("payload\n", &blob_digest, .{});
_ = try log.put(&blob_digest, "payload\n");
var blob_hex: [64]u8 = undefined;
hexOf(&blob_digest, &blob_hex);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "root" },
.d = .{ .timestamp = "2020-01-01T00:00:00" },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &.{.{ .filename = "a.txt", .hash = &blob_hex, .perm = null, .old_name = null, .mode = .regular }},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.t = &.{.{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" }},
};
defer m.deinit();
const bytes = try artifact.create(&m, gpa);
defer gpa.free(bytes);
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
_ = try log.put(&digest, bytes);
self.root_hash = try gpa.alloc(u8, 64);
hexOf(&digest, self.root_hash[0..64]);
if (options.contact) |info| {
try log.putUser(.{ .login = "u", .pw = "", .cap = "v", .info = info, .mtime = 1 });
}
try log.commit();
self.source_key = try log.logKey();
self.child_hash = null;
if (options.child) {
var child: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "child" },
.d = .{ .timestamp = "2020-01-02T00:00:00" },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &.{.{ .filename = "a.txt", .hash = &blob_hex, .perm = null, .old_name = null, .mode = .regular }},
.j = &.{},
.m = &.{},
.p = &.{.{ .parents = self.root_hash, .count = 1 }},
.q = &.{},
.t = &.{},
};
defer child.deinit();
const child_bytes = try artifact.create(&child, gpa);
defer gpa.free(child_bytes);
var child_digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(child_bytes, &child_digest, .{});
_ = try log.put(&child_digest, child_bytes);
const child_hex = try gpa.alloc(u8, 64);
hexOf(&child_digest, child_hex);
self.child_hash = child_hex;
try log.commit();
}
log.deinit(gpa);
raw.close(io);
try Repository.open(&self.repo, gpa, io, self.tmp.dir, .{ .mode = .full });
}
fn deinit(self: *Fixture) void {
const gpa = testing.allocator;
self.repo.deinit(gpa);
gpa.free(self.root_hash);
if (self.child_hash) |c| gpa.free(c);
self.tmp.cleanup();
}
fn session(self: *Fixture, ops: export_cli.GitOps) export_cli.Session {
return .{
.gpa = testing.allocator,
.io = self.io,
.repo = &self.repo,
.source_log_key = self.source_key,
.dest_path = "dest",
.dest_dir = self.tmp.dir,
.ops = ops,
};
}
};
fn hexOf(digest: []const u8, out: []u8) void {
const digits = "0123456789abcdef";
for (digest, 0..) |b, i| {
out[i * 2] = digits[b >> 4];
out[i * 2 + 1] = digits[b & 0x0f];
}
}
// --- the scripted double ------------------------------------------------------
const oid_a = "1111111111111111111111111111111111111111";
const ScriptedGit = struct {
io: std.Io,
dir: std.Io.Dir,
/// Behaviour knobs, one per scenario.
git_missing: bool = false,
init_fails: bool = false,
rev_parse_oid: ?[]const u8 = null,
fast_import_exit: u8 = 0,
fast_import_stderr: []const u8 = "",
marks_lines: ?[]const u8 = null, // null = derive good marks from the stream
/// Marks seen by fastImport: how many `mark :N` lines the stream carried.
stream_marks: std.ArrayListUnmanaged(u64) = .empty,
/// The `rev-parse --git-dir` probe answer. `.git` is a repo answering
/// from its own root; an absolute path is upward discovery from a
/// non-repo DEST inside an enclosing repository.
git_dir_answer: []const u8 = ".git",
/// `git init` invocations observed (the upward-discovery fix must init).
init_calls: usize = 0,
/// What `git log` prints: the mirror's commits as `OID TAB NAME TAB
/// EMAIL` lines, newest first.
log_answer: []const u8 = "",
/// Every `author` line the stream carried, without the keyword.
stream_authors: std.ArrayListUnmanaged([]u8) = .empty,
fn deinit(self: *ScriptedGit) void {
self.stream_marks.deinit(testing.allocator);
for (self.stream_authors.items) |a| testing.allocator.free(a);
self.stream_authors.deinit(testing.allocator);
}
fn runImpl(context: *anyopaque, gpa: std.mem.Allocator, argv: []const []const u8, out: *std.ArrayList(u8)) export_cli.RunError!u8 {
const self: *ScriptedGit = @ptrCast(@alignCast(context));
if (self.git_missing) return error.GitMissing;
const sub = argv[if (std.mem.eql(u8, argv[1], "-C")) 3 else 1];
if (std.mem.eql(u8, sub, "--version")) return 0;
if (std.mem.eql(u8, sub, "rev-parse")) {
if (std.mem.eql(u8, argv[argv.len - 1], "--git-dir")) {
if (self.init_fails) return 1;
// What a repo answers from its own root — or, scripted, what
// upward discovery answers from a non-repo subdirectory.
try out.appendSlice(gpa, self.git_dir_answer);
try out.append(gpa, '\n');
return 0;
}
// --verify <ref>: the canned answer — oid_a (where this
// exporter leaves refs) unless a scenario moved it.
const oid = self.rev_parse_oid orelse oid_a;
try out.appendSlice(gpa, oid);
try out.append(gpa, '\n');
return 0;
}
if (std.mem.eql(u8, sub, "init")) {
self.init_calls += 1;
return if (self.init_fails) 1 else 0;
}
if (std.mem.eql(u8, sub, "log")) {
try out.appendSlice(gpa, self.log_answer);
return 0;
}
if (std.mem.eql(u8, sub, "update-ref")) return 0;
if (std.mem.eql(u8, sub, "symbolic-ref")) return 0;
return 0;
}
fn fastImportImpl(
context: *anyopaque,
gpa: std.mem.Allocator,
dest: []const u8,
marks_out: []const u8,
marks_in: ?[]const u8,
stream: []const u8,
stderr_tail: *std.ArrayList(u8),
) export_cli.RunError!u8 {
_ = dest;
_ = marks_in;
const self: *ScriptedGit = @ptrCast(@alignCast(context));
if (self.fast_import_exit != 0) {
try stderr_tail.appendSlice(gpa, self.fast_import_stderr);
return self.fast_import_exit;
}
// Collect the stream's marks so good marks files can be derived.
var lines = std.mem.splitScalar(u8, stream, '\n');
while (lines.next()) |line| {
if (std.mem.startsWith(u8, line, "author ")) {
try self.stream_authors.append(testing.allocator, try testing.allocator.dupe(u8, line["author ".len..]));
}
if (!std.mem.startsWith(u8, line, "mark :")) continue;
const mark = std.fmt.parseInt(u64, line["mark :".len..], 10) catch continue;
try self.stream_marks.append(testing.allocator, mark);
}
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
if (self.marks_lines) |canned| {
try body.appendSlice(gpa, canned);
} else {
for (self.stream_marks.items) |mark| {
try body.print(gpa, ":{d} {s}\n", .{ mark, oid_a });
}
}
self.dir.createDir(self.io, export_state.state_dirname, .default_dir) catch {};
var f = self.dir.createFile(self.io, marks_out, .{ .truncate = true }) catch return error.CommandFailed;
defer f.close(self.io);
f.writePositionalAll(self.io, body.items, 0) catch return error.CommandFailed;
return 0;
}
fn ops(self: *ScriptedGit) export_cli.GitOps {
return .{ .context = self, .runFn = runImpl, .fastImportFn = fastImportImpl };
}
};
fn expectNoState(io: std.Io, dir: std.Io.Dir) !void {
try testing.expectError(error.Absent, export_state.loadState(testing.allocator, io, dir));
}
// --- scenarios ----------------------------------------------------------------
test "export-git session: a healthy scripted run publishes marks, refs, and the state file" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
const outcome = try s.run();
try testing.expectEqual(@as(usize, 1), outcome.commits_emitted);
try testing.expect(!outcome.source_has_trees);
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
try testing.expectEqual(@as(usize, 1), loaded.records.len);
try testing.expectEqualStrings(fx.root_hash, loaded.records[0].hash);
try testing.expectEqualStrings(oid_a, loaded.records[0].oid);
try testing.expectEqual(@as(usize, 1), loaded.refs.len);
try testing.expectEqualStrings("refs/heads/trunk", loaded.refs[0].name);
// Idempotence: a second run over the same log emits nothing.
var git2 = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git2.deinit();
var s2 = fx.session(git2.ops());
defer s2.deinit();
const again = try s2.run();
try testing.expectEqual(@as(usize, 0), again.commits_emitted);
}
test "export-git session: a DEST inside an enclosing repository is initialized, never adopted" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
// Upward discovery: the probe succeeds but names a repository ABOVE DEST.
// Adopting it aimed fast-import at that repository's refs (the CI
// workspace checkout, in the run that caught this); the session must
// initialize DEST as its own repository instead.
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .git_dir_answer = "/some/enclosing/checkout/.git" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
const outcome = try s.run();
try testing.expectEqual(@as(usize, 1), outcome.commits_emitted);
try testing.expectEqual(@as(usize, 1), git.init_calls);
}
test "export-git session: git missing refuses with a precise line and no state" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .git_missing = true };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
try testing.expectError(error.GitMissing, s.run());
try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "not runnable") != null);
try expectNoState(io, fx.tmp.dir);
}
test "export-git session: DEST neither a repository nor initializable refuses, state untouched" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .init_fails = true };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
try testing.expectError(error.DestNotARepository, s.run());
try expectNoState(io, fx.tmp.dir);
}
test "export-git session: a foreign ref moved since the recorded state refuses rather than overwriting" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
// A recorded state claiming trunk sits at oid_a...
const records = [_]export_state.Record{.{ .hash = fx.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
const refs = [_]export_state.Ref{.{ .name = "refs/heads/trunk", .oid = oid_a }};
try testing.expect(export_state.storeState(testing.allocator, io, fx.tmp.dir, fx.source_key, false, .{}, &records, &refs));
// ...while git answers a DIFFERENT OID for it.
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .rev_parse_oid = "2222222222222222222222222222222222222222" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
try testing.expectError(error.ForeignRefMoved, s.run());
try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "refs/heads/trunk") != null);
// The state file is byte-identical to what was recorded.
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
try testing.expectEqualStrings(oid_a, loaded.refs[0].oid);
}
test "export-git session: nonzero fast-import exit refuses with the stderr tail, state untouched" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .fast_import_exit = 128, .fast_import_stderr = "fatal: mark :1 not a commit" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
try testing.expectError(error.FastImportFailed, s.run());
try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "exited 128") != null);
try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "not a commit") != null);
try expectNoState(io, fx.tmp.dir);
}
test "export-git session: a marks file missing an emitted mark refuses, state untouched" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .marks_lines = ":999 " ++ oid_a ++ "\n" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
try testing.expectError(error.MarkUnresolved, s.run());
try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "never resolved") != null);
try expectNoState(io, fx.tmp.dir);
}
test "export-git session: malformed marks lines refuse, state untouched" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .marks_lines = "garbage without colon\n" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
try testing.expectError(error.MarksMalformed, s.run());
try expectNoState(io, fx.tmp.dir);
}
test "export-git session: a veto at every publish point leaves the state unchanged" {
const io = testing.io;
const Veto = struct {
var point: export_cli.PublishHooks.Point = .marks_readback;
fn check(_: ?*anyopaque, at: export_cli.PublishHooks.Point) bool {
return at != point;
}
};
inline for (.{ .marks_readback, .finalize_refs, .state_rewrite }) |point| {
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
Veto.point = point;
s.hooks = .{ .checkFn = Veto.check };
try testing.expect(std.meta.isError(s.run()));
try expectNoState(io, fx.tmp.dir);
}
}
test "export-git session: a branch with two open leaves refuses at ref finalize — Git has one tip per ref" {
const io = testing.io;
const gpa = testing.allocator;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var raw = try tmp.dir.createFile(io, ".zit", .{ .read = true });
var fs = storage.FileStorage.init(io, raw, 0);
var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
try log.putConfig(.{ .hash_policy = 0, .default_branch = "trunk", .project_id = "p" });
var blob_digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash("payload\n", &blob_digest, .{});
_ = try log.put(&blob_digest, "payload\n");
var blob_hex: [64]u8 = undefined;
hexOf(&blob_digest, &blob_hex);
var hashes: [3][64]u8 = undefined;
const specs = [_]struct { ts: []const u8, parent: ?usize }{
.{ .ts = "2020-01-01T00:00:00", .parent = null },
.{ .ts = "2020-01-02T00:00:00", .parent = 0 },
.{ .ts = "2020-01-03T00:00:00", .parent = 0 },
};
for (specs, 0..) |spec, i| {
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "c" },
.d = .{ .timestamp = spec.ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &.{.{ .filename = "a.txt", .hash = &blob_hex, .perm = null, .old_name = null, .mode = .regular }},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.t = if (spec.parent == null) &.{.{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" }} else &.{},
};
defer m.deinit();
var pbuf: [1]artifact.PCard = undefined;
if (spec.parent) |pi| {
pbuf[0] = .{ .parents = &hashes[pi], .count = 1 };
m.p = pbuf[0..1];
}
const bytes = try artifact.create(&m, gpa);
defer gpa.free(bytes);
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
_ = try log.put(&digest, bytes);
hexOf(&digest, &hashes[i]);
}
try log.commit();
const key = try log.logKey();
log.deinit(gpa);
raw.close(io);
var repo: Repository = undefined;
try Repository.open(&repo, gpa, io, tmp.dir, .{ .mode = .full });
defer repo.deinit(gpa);
var git = ScriptedGit{ .io = io, .dir = tmp.dir };
defer git.deinit();
var s2 = export_cli.Session{
.gpa = gpa,
.io = io,
.repo = &repo,
.source_log_key = key,
.dest_path = "dest",
.dest_dir = tmp.dir,
.ops = git.ops(),
};
defer s2.deinit();
try testing.expectError(error.RefFinalizeFailed, s2.run());
try testing.expect(std.mem.indexOf(u8, s2.diagnostic.items, "open leaves") != null);
try testing.expect(std.mem.indexOf(u8, s2.diagnostic.items, "trunk") != null);
try expectNoState(io, tmp.dir);
}
// --- the identity rule ----------------------------------------------------------
const root_seconds = "1577836800"; // the fixture root's D card, 2020-01-01T00:00:00
const child_seconds = "1577923200"; // the child's, a day later
test "export-git session: a mirror with no history takes the repository's rule and records it" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.initWith(io, .{ .contact = "U <[email protected]>" });
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
s.rule = .{ .domain = "example.com" };
_ = try s.run();
// The contact is not an input: the address is login@domain.
try testing.expectEqual(@as(usize, 1), git.stream_authors.items.len);
try testing.expectEqualStrings("u <[email protected]> " ++ root_seconds ++ " +0000", git.stream_authors.items[0]);
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
const rule = loaded.rule orelse return error.TestUnexpectedResult;
try testing.expectEqualStrings("example.com", rule.domain);
try testing.expect(!rule.contacts);
}
test "export-git session: --email-domain sets the rule of a mirror with no history" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.init(io);
defer fx.deinit();
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
s.rule = .{ .domain = "example.com" };
s.requested_domain = "example.net";
_ = try s.run();
try testing.expectEqualStrings("u <[email protected]> " ++ root_seconds ++ " +0000", git.stream_authors.items[0]);
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
try testing.expectEqualStrings("example.net", loaded.rule.?.domain);
}
test "export-git session: an incremental export keeps the recorded rule after the repository's changes" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.initWith(io, .{ .child = true });
defer fx.deinit();
const records = [_]export_state.Record{.{ .hash = fx.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
const refs = [_]export_state.Ref{.{ .name = "refs/heads/trunk", .oid = oid_a }};
try testing.expect(export_state.storeState(testing.allocator, io, fx.tmp.dir, fx.source_key, false, .{ .domain = "example.org" }, &records, &refs));
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
s.rule = .{ .domain = "example.com" };
const outcome = try s.run();
try testing.expectEqual(@as(usize, 1), outcome.commits_emitted);
try testing.expectEqualStrings("u <[email protected]> " ++ child_seconds ++ " +0000", git.stream_authors.items[0]);
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
try testing.expectEqualStrings("example.org", loaded.rule.?.domain);
}
test "export-git session: --email-domain disagreeing with a mirror's recorded rule refuses, state untouched" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.initWith(io, .{ .child = true });
defer fx.deinit();
const records = [_]export_state.Record{.{ .hash = fx.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
const refs = [_]export_state.Ref{.{ .name = "refs/heads/trunk", .oid = oid_a }};
try testing.expect(export_state.storeState(testing.allocator, io, fx.tmp.dir, fx.source_key, false, .{ .domain = "example.org" }, &records, &refs));
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
s.requested_domain = "example.net";
try testing.expectError(error.IdentityRuleConflict, s.run());
try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "example.org") != null);
try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "example.net") != null);
try testing.expectEqual(@as(usize, 0), git.stream_authors.items.len);
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
try testing.expectEqual(@as(usize, 1), loaded.records.len);
// Agreeing with the record is no conflict.
var git2 = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
defer git2.deinit();
var s2 = fx.session(git2.ops());
defer s2.deinit();
s2.requested_domain = "example.org";
_ = try s2.run();
try testing.expectEqualStrings("u <[email protected]> " ++ child_seconds ++ " +0000", git2.stream_authors.items[0]);
}
/// A state as written before the rule was recorded: version 1 of the file.
fn storeStateWithoutRule(io: std.Io, dir: std.Io.Dir, key: logstore.LogStore.LogKey, records: []const export_state.Record, refs: []const export_state.Ref) !void {
const gpa = testing.allocator;
try testing.expect(export_state.storeState(gpa, io, dir, key, false, .{ .domain = "" }, records, refs));
const bytes = try dir.readFileAlloc(io, export_state.state_filename, gpa, .limited(1 << 20));
defer gpa.free(bytes);
// Version 2 is version 1 plus [contacts u8][len u8 = 0] after the trees
// flag at offset 38; drop those two bytes and re-seal.
var v1: std.ArrayList(u8) = .empty;
defer v1.deinit(gpa);
try v1.appendSlice(gpa, bytes[0..39]);
try v1.appendSlice(gpa, bytes[41 .. bytes.len - 4]);
std.mem.writeInt(u16, v1.items[8..10], 1, .little);
var crc = zit.crc32.Crc32.init();
crc.update(v1.items);
var tail: [4]u8 = undefined;
std.mem.writeInt(u32, &tail, crc.final(), .little);
try v1.appendSlice(gpa, &tail);
var f = try dir.createFile(io, export_state.state_filename, .{ .truncate = true });
defer f.close(io);
try f.writePositionalAll(io, v1.items, 0);
var loaded = try export_state.loadState(gpa, io, dir);
defer loaded.deinit();
try testing.expect(loaded.rule == null);
}
test "export-git session: a state that predates the record keeps the rule its mirror shows: contacts" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.initWith(io, .{ .child = true, .contact = "U <[email protected]>" });
defer fx.deinit();
const records = [_]export_state.Record{.{ .hash = fx.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
const refs = [_]export_state.Ref{.{ .name = "refs/heads/trunk", .oid = oid_a }};
try storeStateWithoutRule(io, fx.tmp.dir, fx.source_key, &records, &refs);
// The root was published with the contact's address.
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .log_answer = oid_a ++ "\tu\[email protected]\n" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
s.rule = .{ .domain = "example.com" };
_ = try s.run();
try testing.expectEqualStrings("u <[email protected]> " ++ child_seconds ++ " +0000", git.stream_authors.items[0]);
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
const rule = loaded.rule orelse return error.TestUnexpectedResult;
try testing.expect(rule.contacts);
}
test "export-git session: a state that predates the record keeps the rule its mirror shows: a domain" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.initWith(io, .{ .child = true, .contact = "U <[email protected]>" });
defer fx.deinit();
const records = [_]export_state.Record{.{ .hash = fx.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
const refs = [_]export_state.Ref{.{ .name = "refs/heads/trunk", .oid = oid_a }};
try storeStateWithoutRule(io, fx.tmp.dir, fx.source_key, &records, &refs);
// Published as [email protected] although the login has a contact: an
// `export-git --email-domain example.org` mirror.
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .log_answer = oid_a ++ "\tu\[email protected]\n" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
s.rule = .{ .domain = "example.com" };
_ = try s.run();
try testing.expectEqualStrings("u <[email protected]> " ++ child_seconds ++ " +0000", git.stream_authors.items[0]);
var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
defer loaded.deinit();
const rule = loaded.rule orelse return error.TestUnexpectedResult;
try testing.expectEqualStrings("example.org", rule.domain);
try testing.expect(!rule.contacts);
// --email-domain against the inferred rule is the same refusal.
var fx2: Fixture = undefined;
try fx2.initWith(io, .{ .child = true });
defer fx2.deinit();
const records2 = [_]export_state.Record{.{ .hash = fx2.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
try storeStateWithoutRule(io, fx2.tmp.dir, fx2.source_key, &records2, &refs);
var git2 = ScriptedGit{ .io = io, .dir = fx2.tmp.dir, .log_answer = oid_a ++ "\tu\[email protected]\n" };
defer git2.deinit();
var s2 = fx2.session(git2.ops());
defer s2.deinit();
s2.requested_domain = "example.com";
try testing.expectError(error.IdentityRuleConflict, s2.run());
}
test "export-git session: a state that predates the record, over a mirror no rule describes, refuses" {
const io = testing.io;
var fx: Fixture = undefined;
try fx.initWith(io, .{ .child = true });
defer fx.deinit();
const records = [_]export_state.Record{.{ .hash = fx.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
const refs = [_]export_state.Ref{.{ .name = "refs/heads/trunk", .oid = oid_a }};
try storeStateWithoutRule(io, fx.tmp.dir, fx.source_key, &records, &refs);
var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .log_answer = oid_a ++ "\tsomeone\[email protected]\n" };
defer git.deinit();
var s = fx.session(git.ops());
defer s.deinit();
try testing.expectError(error.IdentityRuleUnknown, s.run());
try testing.expectEqual(@as(usize, 0), git.stream_authors.items.len);
}