lib/hypothesis/src/artifact.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2
3 const Sha256 = std.crypto.hash.sha2.Sha256;
4 const fs_io = std.Options.debug_io;
5 const magic = "JHMA";
6 const format_version: u16 = 1;
7 const header_bytes: usize = 48;
8
9 pub const Metadata = struct {
10 schema: []const u8,
11 revision: []const u8,
12 };
13
14 pub const Entry = struct {
15 metadata: Metadata,
16 payload: []const u8,
17 };
18
19 pub fn encodedSize(
20 metadata: Metadata,
21 payload_bytes: usize,
22 ) error{ArtifactTooLarge}!usize {
23 if (metadata.schema.len == 0) return error.ArtifactTooLarge;
24 if (metadata.revision.len == 0) return error.ArtifactTooLarge;
25 if (metadata.schema.len > std.math.maxInt(u16)) return error.ArtifactTooLarge;
26 if (metadata.revision.len > std.math.maxInt(u16)) return error.ArtifactTooLarge;
27 if (payload_bytes > std.math.maxInt(u32)) return error.ArtifactTooLarge;
28
29 const with_schema = std.math.add(usize, header_bytes, metadata.schema.len) catch
30 return error.ArtifactTooLarge;
31 const with_revision = std.math.add(usize, with_schema, metadata.revision.len) catch
32 return error.ArtifactTooLarge;
33 return std.math.add(usize, with_revision, payload_bytes) catch
34 return error.ArtifactTooLarge;
35 }
36
37 pub fn save(path: []const u8, metadata: Metadata, payload: []const u8) !void {
38 std.debug.assert(path.len > 0);
39 std.debug.assert(metadata.schema.len > 0);
40 std.debug.assert(metadata.revision.len > 0);
41 _ = try encodedSize(metadata, payload.len);
42
43 if (std.fs.path.dirname(path)) |parent| {
44 try std.Io.Dir.cwd().createDirPath(fs_io, parent);
45 }
46 var header: [header_bytes]u8 = @splat(0);
47 @memcpy(header[0..magic.len], magic);
48 std.mem.writeInt(u16, header[4..6], format_version, .little);
49 std.mem.writeInt(u16, header[8..10], @intCast(metadata.schema.len), .little);
50 std.mem.writeInt(u16, header[10..12], @intCast(metadata.revision.len), .little);
51 std.mem.writeInt(u32, header[12..16], @intCast(payload.len), .little);
52 contentDigest(header[0..16], metadata, payload, header[16..48]);
53
54 var atomic_file = try std.Io.Dir.cwd().createFileAtomic(fs_io, path, .{ .replace = true });
55 defer atomic_file.deinit(fs_io);
56 var offset: u64 = 0;
57 try atomic_file.file.writePositionalAll(fs_io, &header, offset);
58 offset += header.len;
59 try atomic_file.file.writePositionalAll(fs_io, metadata.schema, offset);
60 offset += metadata.schema.len;
61 try atomic_file.file.writePositionalAll(fs_io, metadata.revision, offset);
62 offset += metadata.revision.len;
63 try atomic_file.file.writePositionalAll(fs_io, payload, offset);
64 try atomic_file.replace(fs_io);
65 }
66
67 pub fn load(path: []const u8, storage: []u8) !?Entry {
68 std.debug.assert(path.len > 0);
69 var file = std.Io.Dir.cwd().openFile(fs_io, path, .{ .allow_directory = false }) catch |err| {
70 if (err == error.FileNotFound) return null;
71 return err;
72 };
73 defer file.close(fs_io);
74 const stat = try file.stat(fs_io);
75 if (stat.kind != .file) return error.InvalidArtifact;
76 const file_len = std.math.cast(usize, stat.size) orelse return error.ArtifactTooLarge;
77 if (file_len > storage.len) return error.ArtifactTooLarge;
78 const data = storage[0..file_len];
79 if (try file.readPositionalAll(fs_io, data, 0) != file_len) return error.InvalidArtifact;
80 var trailing: [1]u8 = undefined;
81 if (try file.readPositionalAll(fs_io, &trailing, file_len) != 0) {
82 return error.InvalidArtifact;
83 }
84 return try decode(data);
85 }
86
87 fn decode(data: []const u8) !Entry {
88 if (data.len < header_bytes) return error.InvalidArtifact;
89 if (!std.mem.eql(u8, data[0..magic.len], magic)) return error.InvalidArtifact;
90 if (std.mem.readInt(u16, data[4..6], .little) != format_version) {
91 return error.UnsupportedArtifactVersion;
92 }
93 if (std.mem.readInt(u16, data[6..8], .little) != 0) return error.InvalidArtifact;
94 const schema_len: usize = std.mem.readInt(u16, data[8..10], .little);
95 const revision_len: usize = std.mem.readInt(u16, data[10..12], .little);
96 const payload_len: usize = std.mem.readInt(u32, data[12..16], .little);
97 const metadata_len = std.math.add(usize, schema_len, revision_len) catch
98 return error.InvalidArtifact;
99 const body_len = std.math.add(usize, metadata_len, payload_len) catch
100 return error.InvalidArtifact;
101 const expected_len = std.math.add(usize, header_bytes, body_len) catch
102 return error.InvalidArtifact;
103 if (data.len != expected_len) return error.InvalidArtifact;
104 if (schema_len == 0 or revision_len == 0) return error.InvalidArtifact;
105
106 const schema = data[header_bytes..][0..schema_len];
107 const revision = data[header_bytes + schema_len ..][0..revision_len];
108 const payload = data[header_bytes + metadata_len ..][0..payload_len];
109 var digest: [Sha256.digest_length]u8 = undefined;
110 contentDigest(
111 data[0..16],
112 .{ .schema = schema, .revision = revision },
113 payload,
114 &digest,
115 );
116 if (!std.crypto.timing_safe.eql([Sha256.digest_length]u8, digest, data[16..48].*)) {
117 return error.InvalidArtifact;
118 }
119 return .{
120 .metadata = .{ .schema = schema, .revision = revision },
121 .payload = payload,
122 };
123 }
124
125 fn contentDigest(
126 header: []const u8,
127 metadata: Metadata,
128 payload: []const u8,
129 output: *[Sha256.digest_length]u8,
130 ) void {
131 var hasher = Sha256.init(.{});
132 hasher.update(header);
133 hasher.update(metadata.schema);
134 hasher.update(metadata.revision);
135 hasher.update(payload);
136 hasher.final(output);
137 }
138
139 test "artifact: materialized input retains schema revision and bytes" {
140 var tmp = std.testing.tmpDir(.{});
141 defer tmp.cleanup();
142 const dir_path = try tmp.dir.realPathFileAlloc(fs_io, ".", std.testing.allocator);
143 defer std.testing.allocator.free(dir_path);
144 var path_buffer: [std.fs.max_path_bytes]u8 = undefined;
145 const path = try std.fmt.bufPrint(&path_buffer, "{s}/case.jhma", .{dir_path});
146 const metadata = Metadata{
147 .schema = "example.state-machine.v1",
148 .revision = "generator-before-change",
149 };
150 try save(path, metadata, &.{ 3, 1, 4, 1, 5 });
151
152 var storage: [256]u8 = undefined;
153 try std.testing.expectEqual(
154 header_bytes + metadata.schema.len + metadata.revision.len + 5,
155 try encodedSize(metadata, 5),
156 );
157 const loaded = (try load(path, &storage)).?;
158 try std.testing.expectEqualStrings(metadata.schema, loaded.metadata.schema);
159 try std.testing.expectEqualStrings(metadata.revision, loaded.metadata.revision);
160 try std.testing.expectEqualSlices(u8, &.{ 3, 1, 4, 1, 5 }, loaded.payload);
161 }
162
163 test "artifact: payload corruption is rejected" {
164 var tmp = std.testing.tmpDir(.{});
165 defer tmp.cleanup();
166 const dir_path = try tmp.dir.realPathFileAlloc(fs_io, ".", std.testing.allocator);
167 defer std.testing.allocator.free(dir_path);
168 var path_buffer: [std.fs.max_path_bytes]u8 = undefined;
169 const path = try std.fmt.bufPrint(&path_buffer, "{s}/case.jhma", .{dir_path});
170 try save(path, .{ .schema = "example.v1", .revision = "one" }, "payload");
171 var file = try std.Io.Dir.cwd().openFile(fs_io, path, .{ .mode = .read_write });
172 defer file.close(fs_io);
173 try file.writePositionalAll(fs_io, "X", header_bytes + "example.v1".len + "one".len);
174
175 var storage: [256]u8 = undefined;
176 try std.testing.expectError(error.InvalidArtifact, load(path, &storage));
177 }