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 }