//! The quarry attachment stub.
//!
//! quarry never attaches file bytes to a hosted repository. It attaches a
//! small text blob that says where the bytes are — a content hash, a size,
//! a mimetype, a name — so the A card stays a valid fossil artifact, the
//! store stays small, and nothing is a phantom on sync. This module only
//! RECOGNISES that shape: `attach list` and `zit artifact` report such a
//! blob as external instead of as its own hundred-odd bytes. A reader that
//! does not recognise it sees a small text file under the attachment's name,
//! which is the degraded-but-honest case. Nothing here writes one; quarry
//! writes it through `zit attach add - --name NAME --mimetype TYPE < stub`.
//!
//! ```
//! quarry-attachment 1
//! sha256 4f9a…e01c
//! size 18349022
//! type video/mp4
//! name screen-recording.mp4
//! ```
//!
//! One header line naming the scheme and version, then `key value` lines.
//! Unknown keys are ignored (forward compatibility); a missing required key
//! or a malformed value is not a stub.
const std = @import("std");
const repeat = @import("repeat.zig").repeat;
pub const header = "quarry-attachment 1\n";
/// A stub is a pointer, so it is small; anything larger that happens to
/// begin with the header is a text file, not a stub.
pub const max_len: usize = 4096;
pub const Stub = struct {
/// 64 lowercase hex — the external bytes' SHA-256.
sha256: []const u8,
size: u64,
/// May be empty when the stub carries no `type` line.
mimetype: []const u8,
name: []const u8,
};
/// Recognise `bytes` as a stub, borrowing its fields, or null.
pub fn parse(bytes: []const u8) ?Stub {
if (bytes.len > max_len or !std.mem.startsWith(u8, bytes, header)) return null;
var sha: ?[]const u8 = null;
var size: ?u64 = null;
var mimetype: []const u8 = "";
var name: ?[]const u8 = null;
var it = std.mem.splitScalar(u8, bytes[header.len..], '\n');
while (it.next()) |line| {
if (line.len == 0) continue;
const sp = std.mem.indexOfScalar(u8, line, ' ') orelse return null;
const key = line[0..sp];
const value = line[sp + 1 ..];
if (value.len == 0) return null;
if (std.mem.eql(u8, key, "sha256")) {
if (value.len != 64) return null;
for (value) |c| if (!std.ascii.isHex(c) or std.ascii.isUpper(c)) return null;
sha = value;
} else if (std.mem.eql(u8, key, "size")) {
size = std.fmt.parseInt(u64, value, 10) catch return null;
} else if (std.mem.eql(u8, key, "type")) {
mimetype = value;
} else if (std.mem.eql(u8, key, "name")) {
name = value;
}
}
return .{
.sha256 = sha orelse return null,
.size = size orelse return null,
.mimetype = mimetype,
.name = name orelse return null,
};
}
test "a well-formed stub parses; unknown keys are ignored" {
const s = parse(header ++ "sha256 " ++ (repeat("4f", 32)) ++ "\nsize 18349022\ntype video/mp4\nname screen-recording.mp4\nfuture yes\n").?;
try std.testing.expectEqualStrings(repeat("4f", 32), s.sha256);
try std.testing.expectEqual(@as(u64, 18349022), s.size);
try std.testing.expectEqualStrings("video/mp4", s.mimetype);
try std.testing.expectEqualStrings("screen-recording.mp4", s.name);
}
test "not a stub: wrong header, missing key, bad hash, bad size, too large" {
try std.testing.expect(parse("quarry-attachment 2\nsha256 " ++ (repeat("4f", 32)) ++ "\nsize 1\nname a\n") == null);
try std.testing.expect(parse(header ++ "size 1\nname a\n") == null); // no sha256
try std.testing.expect(parse(header ++ "sha256 abc\nsize 1\nname a\n") == null);
try std.testing.expect(parse(header ++ "sha256 " ++ (repeat("4F", 32)) ++ "\nsize 1\nname a\n") == null); // uppercase
try std.testing.expect(parse(header ++ "sha256 " ++ (repeat("4f", 32)) ++ "\nsize big\nname a\n") == null);
try std.testing.expect(parse(header ++ "sha256 " ++ (repeat("4f", 32)) ++ "\nsize 1\n") == null); // no name
try std.testing.expect(parse("PNG\x89 not text") == null);
const big = header ++ "sha256 " ++ (repeat("4f", 32)) ++ "\nsize 1\nname a\n" ++ (repeat("x", max_len));
try std.testing.expect(parse(big) == null);
}