lib/closure/src/receipt/encode.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const json = @import("json.zig");
3 const closure = @import("../root.zig");
4 const model = @import("model.zig");
5 const rows = @import("rows.zig");
6 const schema = @import("../schema/root.zig");
7
8 const limits = closure.limits;
9
10 const receipt_length_attempts_max: u8 = 4;
11
12 const ValidationError = error{
13 AbsolutePathNotAllowed,
14 InputCapacityExceeded,
15 InvalidContext,
16 SelectedClaimNotFound,
17 };
18
19 pub const Error = json.Error || ValidationError;
20
21 pub fn encode(
22 output: []u8,
23 input: schema.GenerationInput,
24 selected_claim: schema.ClaimId,
25 context: model.Context,
26 ) Error![]const u8 {
27 try validate(input, context);
28 const selected = findClaim(input.claims, selected_claim) orelse
29 return error.SelectedClaimNotFound;
30 var normalized = context;
31 const length = try fixedReceiptLength(input, selected, &normalized);
32 if (length > context.limits.receipt_bytes or
33 length > context.capacity.receipt_bytes or
34 length > output.len)
35 {
36 return error.ReceiptCapacityExceeded;
37 }
38 return try json.render(
39 output,
40 length,
41 rows.emit,
42 .{ input, selected, normalized },
43 );
44 }
45
46 fn fixedReceiptLength(
47 input: schema.GenerationInput,
48 selected: *const schema.Claim,
49 context: *model.Context,
50 ) Error!usize {
51 var expected: usize = 0;
52 var attempt: u8 = 0;
53 while (attempt < receipt_length_attempts_max) : (attempt += 1) {
54 context.occupancy.receipt_bytes = expected;
55 context.high_water.receipt_bytes = expected;
56 const measured = try json.measure(
57 rows.emit,
58 .{ input, selected, context.* },
59 );
60 if (measured == expected) return expected;
61 expected = measured;
62 }
63 return error.InvalidContext;
64 }
65
66 fn validate(
67 input: schema.GenerationInput,
68 context: model.Context,
69 ) Error!void {
70 if (context.allocation_count != 0) return error.InvalidContext;
71 if (!context.after_root.eql(&input.root)) return error.InvalidContext;
72 const derived = limits.Capacity.derive(context.limits) catch
73 return error.InvalidContext;
74 if (!std.meta.eql(derived, context.capacity)) {
75 return error.InvalidContext;
76 }
77 if (!boundedText(context.reason) or
78 !boundedText(context.rejection) or
79 !boundedText(context.unknown))
80 {
81 return error.InputCapacityExceeded;
82 }
83 const counts = model.Counts.fromInput(input, 0);
84 const capacity = model.Counts.fromCapacity(context.capacity);
85 inline for (comptime std.meta.fieldNames(model.Counts)) |name| {
86 if (comptime std.mem.eql(u8, name, "receipt_bytes")) continue;
87 if (@field(counts, name) > @field(capacity, name)) {
88 return error.InputCapacityExceeded;
89 }
90 if (@field(counts, name) != @field(context.occupancy, name)) {
91 return error.InvalidContext;
92 }
93 if (@field(context.high_water, name) < @field(context.occupancy, name) or
94 @field(context.high_water, name) > @field(capacity, name))
95 {
96 return error.InvalidContext;
97 }
98 }
99 if (context.occupancy.receipt_bytes > context.capacity.receipt_bytes or
100 context.high_water.receipt_bytes < context.occupancy.receipt_bytes or
101 context.high_water.receipt_bytes > context.capacity.receipt_bytes)
102 {
103 return error.InvalidContext;
104 }
105 if (context.work > context.capacity.projection_steps_max or
106 context.visited_high_water > context.capacity.nodes or
107 context.queue_high_water > context.capacity.nodes)
108 {
109 return error.InvalidContext;
110 }
111 for (input.source_records) |*source| {
112 if (absolutePath(source.path.slice())) {
113 return error.AbsolutePathNotAllowed;
114 }
115 }
116 }
117
118 fn boundedText(value: ?[]const u8) bool {
119 if (value) |present| {
120 return present.len <= schema.value.descriptor_bytes_max;
121 }
122 return true;
123 }
124
125 fn absolutePath(path: []const u8) bool {
126 if (path.len == 0) return false;
127 if (path[0] == '/' or path[0] == '\\') return true;
128 if (path.len < 3 or path[1] != ':') return false;
129 const letter = (path[0] >= 'a' and path[0] <= 'z') or
130 (path[0] >= 'A' and path[0] <= 'Z');
131 return letter and (path[2] == '/' or path[2] == '\\');
132 }
133
134 fn findClaim(
135 claims: []const schema.Claim,
136 selected: schema.ClaimId,
137 ) ?*const schema.Claim {
138 for (claims) |*claim| {
139 if (claim.id == selected) return claim;
140 }
141 return null;
142 }
143
144 const Fixture = struct {
145 graph: GraphFacts,
146 artifact: ArtifactFacts,
147 evidence: EvidenceFacts,
148 policy: PolicyFacts,
149 claim: ClaimFacts,
150
151 fn init() !Fixture {
152 return .{
153 .graph = try graphFacts(),
154 .artifact = try artifactFacts(),
155 .evidence = try evidenceFacts(),
156 .policy = try policyFacts(),
157 .claim = try claimFacts(),
158 };
159 }
160
161 fn input(self: *const Fixture) schema.GenerationInput {
162 return .{
163 .id = 42,
164 .root = filledDigest(0xfa),
165 .nodes = &self.graph.nodes,
166 .edges = &self.graph.edges,
167 .artifacts = &self.artifact.artifacts,
168 .ranges = &self.artifact.ranges,
169 .digests = &self.artifact.digests,
170 .provenance_parents = &self.evidence.provenance,
171 .source_records = &self.evidence.sources,
172 .build_records = &self.evidence.builds,
173 .authorities = &self.policy.authorities,
174 .lineage_references = &self.policy.lineages,
175 .service_descriptors = &self.policy.services,
176 .residual_roots = &self.policy.roots,
177 .claims = &self.claim.claims,
178 .claim_nodes = &self.claim.nodes,
179 };
180 }
181 };
182
183 const GraphFacts = struct {
184 nodes: [2]schema.Node,
185 edges: [1]schema.Edge,
186 };
187
188 fn graphFacts() !GraphFacts {
189 return .{
190 .nodes = .{ .{
191 .id = 1,
192 .descriptor = try schema.Descriptor.init("owned-source"),
193 .identity = filledDigest(0xab),
194 .subject_kind = .source,
195 .material_role = .platform,
196 .origin = .owned_source,
197 .phases = schema.phaseBit(.source) | schema.phaseBit(.build),
198 .execution_locus = .nonexecuting,
199 .owner = try schema.Name.init("tiny"),
200 .authority = .platform_control,
201 .artifact_kind = .source,
202 }, .{
203 .id = 2,
204 .descriptor = try schema.Descriptor.init("unknown-node"),
205 .identity = schema.Digest.zero(),
206 .subject_kind = .unknown,
207 .material_role = .unknown,
208 .origin = .unknown,
209 .phases = 0,
210 .execution_locus = .unknown,
211 .owner = try schema.Name.init("unknown"),
212 .authority = .unknown,
213 .artifact_kind = .unknown,
214 } },
215 .edges = .{.{
216 .id = 10,
217 .source = 1,
218 .target = 2,
219 .kind = .derivation,
220 }},
221 };
222 }
223
224 const ArtifactFacts = struct {
225 artifacts: [1]schema.Artifact,
226 ranges: [1]schema.ByteRange,
227 digests: [1]schema.DigestRecord,
228 };
229
230 fn artifactFacts() !ArtifactFacts {
231 return .{
232 .artifacts = .{.{
233 .id = 20,
234 .node = 1,
235 .byte_length = 4096,
236 .digest = filledDigest(0xcd),
237 .witness = .owned,
238 }},
239 .ranges = .{.{
240 .id = 21,
241 .artifact = 20,
242 .offset = 3,
243 .length = 17,
244 .digest = filledDigest(0xde),
245 .executable = true,
246 .witness = .differential,
247 }},
248 .digests = .{.{
249 .id = 22,
250 .node = 1,
251 .purpose = try schema.Name.init("identity"),
252 .digest = filledDigest(0xef),
253 .witness = .foreign,
254 }},
255 };
256 }
257
258 const EvidenceFacts = struct {
259 provenance: [1]schema.ProvenanceParent,
260 sources: [1]schema.SourceRecord,
261 builds: [1]schema.BuildRecord,
262 };
263
264 fn evidenceFacts() !EvidenceFacts {
265 return .{
266 .provenance = .{.{ .id = 23, .child = 2, .parent = 1 }},
267 .sources = .{.{
268 .id = 24,
269 .node = 1,
270 .path = try schema.Descriptor.init("src/quote\"\\file.zig"),
271 .digest = filledDigest(0x9a),
272 .witness = .owned,
273 }},
274 .builds = .{.{
275 .id = 25,
276 .node = 2,
277 .tool = 1,
278 .option = try schema.Descriptor.init("-Dmode=small"),
279 .digest = filledDigest(0x8b),
280 .witness = .differential,
281 }},
282 };
283 }
284
285 const PolicyFacts = struct {
286 authorities: [1]schema.Authority,
287 lineages: [1]schema.LineageReference,
288 services: [1]schema.ServiceDescriptor,
289 roots: [1]schema.ResidualRoot,
290 };
291
292 fn policyFacts() !PolicyFacts {
293 return .{
294 .authorities = .{.{
295 .id = 26,
296 .source = 1,
297 .target = 2,
298 .granted = .admission,
299 }},
300 .lineages = .{.{
301 .id = 27,
302 .node = 2,
303 .descriptor = try schema.Descriptor.init("parent-receipt"),
304 .digest = filledDigest(0x7c),
305 .witness = .foreign,
306 }},
307 .services = .{.{
308 .id = 28,
309 .node = 2,
310 .provider = try schema.Descriptor.init("provider"),
311 .protocol = try schema.Descriptor.init("https"),
312 .endpoint_rule = try schema.Descriptor.init("pinned"),
313 .trust_anchor = try schema.Descriptor.init("root-key"),
314 .failure_contract = try schema.Descriptor.init("fail-closed"),
315 .requirement = .required,
316 }},
317 .roots = .{.{
318 .id = 29,
319 .claim = 7,
320 .node = 2,
321 .treatment = .residual_assumption,
322 }},
323 };
324 }
325
326 const ClaimFacts = struct {
327 claims: [1]schema.Claim,
328 nodes: [1]schema.ClaimNode,
329 };
330
331 fn claimFacts() !ClaimFacts {
332 const profiles = schema.ProfileSet{
333 .executable = .{
334 .required = true,
335 .id = try schema.Name.init("exec-profile"),
336 .source = try schema.Name.init("policy-source"),
337 .body_sha256 = filledDigest(0xab),
338 },
339 .service_trust = schema.ProfileRef.absent(),
340 .model_origin = schema.ProfileRef.absent(),
341 .bootstrap = schema.ProfileRef.absent(),
342 };
343 return .{
344 .claims = .{.{
345 .id = 7,
346 .name = try schema.Name.init("closure"),
347 .artifact = 20,
348 .artifact_digest = filledDigest(0xcd),
349 .profiles = profiles,
350 .traversed_edges = schema.edgeBit(.derivation),
351 }},
352 .nodes = .{.{
353 .id = 30,
354 .claim = 7,
355 .node = 2,
356 .treatment = .unknown,
357 }},
358 };
359 }
360
361 fn testContext(
362 input: schema.GenerationInput,
363 verdict: schema.Verdict,
364 ) !model.Context {
365 const requested = limits.Limits{};
366 const capacity = try limits.Capacity.derive(requested);
367 const occupancy = model.Counts.fromInput(input, 0);
368 return .{
369 .limits = requested,
370 .capacity = capacity,
371 .occupancy = occupancy,
372 .high_water = occupancy,
373 .before_root = filledDigest(0x11),
374 .after_root = input.root,
375 .verdict = verdict,
376 .reason = null,
377 .rejection = null,
378 .unknown = null,
379 .work = 73,
380 .visited_high_water = 2,
381 .queue_high_water = 1,
382 .allocation_count = 0,
383 };
384 }
385
386 fn filledDigest(byte: u8) schema.Digest {
387 return .{ .bytes = @splat(byte) };
388 }
389
390 fn expectInvalidContextUnpublished(
391 input: schema.GenerationInput,
392 context: model.Context,
393 ) !void {
394 var buffer: [256]u8 = @splat(0x6d);
395 try std.testing.expectError(
396 error.InvalidContext,
397 encode(&buffer, input, 7, context),
398 );
399 for (buffer) |byte| {
400 try std.testing.expectEqual(@as(u8, 0x6d), byte);
401 }
402 }
403
404 test "receipt repeats deterministically with every canonical row family" {
405 const fixture = try Fixture.init();
406 const input = fixture.input();
407 const context = try testContext(input, .pass);
408 var first_buffer: [32 * 1024]u8 = undefined;
409 var second_buffer: [32 * 1024]u8 = undefined;
410 const first = try encode(&first_buffer, input, 7, context);
411 const second = try encode(&second_buffer, input, 7, context);
412 try std.testing.expectEqualStrings(first, second);
413 inline for (.{
414 "generation", "node", "edge", "artifact",
415 "range", "digest", "provenance", "source",
416 "build", "authority", "lineage", "service",
417 "root", "claim", "claim-node",
418 }) |family| {
419 var expected: [64]u8 = undefined;
420 const needle = try std.fmt.bufPrint(
421 &expected,
422 "\"schema\":\"tiny.closure.{s}/v1\"",
423 .{family},
424 );
425 try std.testing.expect(std.mem.indexOf(u8, first, needle) != null);
426 }
427 try std.testing.expectEqual(@as(u8, '\n'), first[first.len - 1]);
428 try std.testing.expect(first.len != context.occupancy.receipt_bytes);
429 var receipt_bytes_buffer: [64]u8 = undefined;
430 const receipt_bytes = try std.fmt.bufPrint(
431 &receipt_bytes_buffer,
432 "\"receipt_bytes\":{d}",
433 .{first.len},
434 );
435 try std.testing.expectEqual(
436 @as(usize, 2),
437 std.mem.count(u8, first, receipt_bytes),
438 );
439 }
440
441 test "receipt preflight accepts exact C and preserves short buffer" {
442 const fixture = try Fixture.init();
443 const input = fixture.input();
444 const context = try testContext(input, .pass);
445 var full_buffer: [32 * 1024]u8 = undefined;
446 const full = try encode(&full_buffer, input, 7, context);
447 var exact_buffer: [32 * 1024]u8 = undefined;
448 const exact = try encode(exact_buffer[0..full.len], input, 7, context);
449 try std.testing.expectEqualStrings(full, exact);
450 var short_buffer: [32 * 1024]u8 = @splat(0xa5);
451 try std.testing.expectError(
452 error.ReceiptCapacityExceeded,
453 encode(short_buffer[0 .. full.len - 1], input, 7, context),
454 );
455 for (short_buffer[0 .. full.len - 1]) |byte| {
456 try std.testing.expectEqual(@as(u8, 0xa5), byte);
457 }
458 var limited = context;
459 limited.limits.receipt_bytes = 1;
460 limited.capacity = try limits.Capacity.derive(limited.limits);
461 var capacity_buffer: [32 * 1024]u8 = @splat(0x3c);
462 try std.testing.expectError(
463 error.ReceiptCapacityExceeded,
464 encode(&capacity_buffer, input, 7, limited),
465 );
466 for (capacity_buffer) |byte| {
467 try std.testing.expectEqual(@as(u8, 0x3c), byte);
468 }
469 }
470
471 test "receipt escapes JSON bytes and emits lowercase digest hex" {
472 const fixture = try Fixture.init();
473 const input = fixture.input();
474 var context = try testContext(input, .refuted);
475 context.reason = "quote\" slash\\ line\n tab\t low\x01 high\xff";
476 var buffer: [32 * 1024]u8 = undefined;
477 const receipt = try encode(&buffer, input, 7, context);
478 try std.testing.expect(std.mem.indexOf(
479 u8,
480 receipt,
481 "\"reason\":\"quote\\\" slash\\\\ line\\n tab\\t low\\u0001 high\\u00ff\"",
482 ) != null);
483 try std.testing.expect(std.mem.indexOf(
484 u8,
485 receipt,
486 "\"path\":\"src/quote\\\"\\\\file.zig\"",
487 ) != null);
488 try std.testing.expect(std.mem.indexOf(
489 u8,
490 receipt,
491 "\"body_sha256\":\"abababababababab",
492 ) != null);
493 }
494
495 test "receipt exposes unknown axes and selected refuted claim" {
496 const fixture = try Fixture.init();
497 const input = fixture.input();
498 var context = try testContext(input, .refuted);
499 context.reason = "unknown evidence";
500 context.rejection = "origin rejected";
501 context.unknown = "node 2 origin";
502 var buffer: [32 * 1024]u8 = undefined;
503 const receipt = try encode(&buffer, input, 7, context);
504 try std.testing.expect(std.mem.indexOf(
505 u8,
506 receipt,
507 "\"subject_kind\":\"unknown\",\"material_role\":\"unknown\"," ++ "\"origin\":\"unknown\"",
508 ) != null);
509 try std.testing.expect(std.mem.indexOf(
510 u8,
511 receipt,
512 "\"schema\":\"tiny.closure.claim/v1\"",
513 ) != null);
514 try std.testing.expect(std.mem.indexOf(
515 u8,
516 receipt,
517 "\"selected\":true,\"verdict\":\"refuted\"," ++
518 "\"reason\":\"unknown evidence\"," ++
519 "\"rejection\":\"origin rejected\"," ++
520 "\"unknown\":\"node 2 origin\"",
521 ) != null);
522 }
523
524 test "receipt rejects absolute host source paths before publication" {
525 var fixture = try Fixture.init();
526 fixture.evidence.sources[0].path =
527 try schema.Descriptor.init("/home/host/source.zig");
528 const input = fixture.input();
529 const context = try testContext(input, .pass);
530 var buffer: [32 * 1024]u8 = @splat(0x5a);
531 try std.testing.expectError(
532 error.AbsolutePathNotAllowed,
533 encode(&buffer, input, 7, context),
534 );
535 for (buffer) |byte| {
536 try std.testing.expectEqual(@as(u8, 0x5a), byte);
537 }
538 }
539
540 test "receipt rejects inconsistent context without publication" {
541 const fixture = try Fixture.init();
542 const input = fixture.input();
543 const valid = try testContext(input, .pass);
544 var mismatched_capacity = valid;
545 mismatched_capacity.capacity.receipt_rows_max += 1;
546 try expectInvalidContextUnpublished(input, mismatched_capacity);
547 var mismatched_occupancy = valid;
548 mismatched_occupancy.occupancy.nodes += 1;
549 try expectInvalidContextUnpublished(input, mismatched_occupancy);
550 var low_high_water = valid;
551 low_high_water.high_water.nodes = valid.occupancy.nodes - 1;
552 try expectInvalidContextUnpublished(input, low_high_water);
553 var excessive_high_water = valid;
554 excessive_high_water.high_water.nodes = valid.capacity.nodes + 1;
555 try expectInvalidContextUnpublished(input, excessive_high_water);
556 var low_receipt_high_water = valid;
557 low_receipt_high_water.occupancy.receipt_bytes = 2;
558 low_receipt_high_water.high_water.receipt_bytes = 1;
559 try expectInvalidContextUnpublished(input, low_receipt_high_water);
560 var excessive_receipt_high_water = valid;
561 excessive_receipt_high_water.high_water.receipt_bytes =
562 valid.capacity.receipt_bytes + 1;
563 try expectInvalidContextUnpublished(input, excessive_receipt_high_water);
564 var excessive_visited = valid;
565 excessive_visited.visited_high_water = valid.capacity.nodes + 1;
566 try expectInvalidContextUnpublished(input, excessive_visited);
567 var excessive_queue = valid;
568 excessive_queue.queue_high_water = valid.capacity.nodes + 1;
569 try expectInvalidContextUnpublished(input, excessive_queue);
570 }