active_infinity/protobuf.rs
1//! Protobuf-wire interop projection.
2//!
3//! Active Infinity is the canonical form, and it is the form peers should
4//! exchange. The rest of the world, however, speaks Protobuf: dashboards,
5//! queues, analytics, tracing pipelines and every language with a generated
6//! client. This module renders a module into protobuf wire format so those
7//! consumers can read Active data without implementing the AINF layout, and so
8//! an Active producer can feed existing infrastructure directly.
9//!
10//! The projection is **redacted by construction**: it is built from the same
11//! [`crate::interop::Projected`] tree as the JSON projection, so secrets are
12//! replaced by [`crate::interop::SECRET_MARKER`] before a single byte is
13//! written. Blob payloads never leave the module; only their identity does.
14//!
15//! # Schema
16//!
17//! ```proto
18//! syntax = "proto3";
19//! package active.infinity.v1;
20//!
21//! message Module {
22//! bytes content_hash = 1; // Blake3-256 of the canonical AINF image
23//! uint64 total_size = 2; // canonical AINF image size in bytes
24//! Value root = 3; // omitted when the module has no root
25//! repeated Binding bindings = 4;
26//! Provenance provenance = 5; // omitted when absent
27//! repeated Annotation annotations = 6;
28//! repeated Extension extensions = 7;
29//! }
30//!
31//! message Binding { string name = 1; Value value = 2; }
32//!
33//! message Annotation {
34//! uint32 subject = 1; // node id
35//! oneof kind {
36//! string intent = 2; // documentation only
37//! Constraint constraint = 3;
38//! bool ai_immutable = 4;
39//! }
40//! }
41//! message Constraint { string source = 1; uint32 expr = 2; }
42//!
43//! message Provenance {
44//! string producer = 1;
45//! uint64 timestamp_ms = 2;
46//! string model = 3;
47//! string tool = 4;
48//! bytes parent = 5; // omitted when absent (32 bytes when present)
49//! repeated Entry entries = 6;
50//! message Entry {
51//! string key = 1;
52//! oneof value {
53//! string text = 2;
54//! sint64 integer = 3;
55//! bool flag = 4;
56//! bytes hash = 5; // 32 bytes
57//! }
58//! }
59//! }
60//!
61//! message Extension { uint64 tag = 1; bytes payload = 2; }
62//!
63//! message Value {
64//! oneof kind {
65//! Null null_value = 1;
66//! bool boolean = 2;
67//! sint64 integer = 3;
68//! double float = 4;
69//! string text = 5;
70//! Ms duration_ms = 6;
71//! Quantity quantity = 7;
72//! List list = 8;
73//! Record record = 9;
74//! Blob blob = 10;
75//! Env env = 11;
76//! Expr expr = 12;
77//! }
78//! }
79//! message Null {}
80//! message Ms { uint64 ms = 1; }
81//! message Quantity { Value value = 1; string unit = 2; }
82//! message List { repeated Value items = 1; }
83//! message Record { repeated Field fields = 1; }
84//! message Field { string name = 1; Value value = 2; }
85//! message Blob {
86//! bytes blake3 = 1; // 32 bytes
87//! uint64 size = 2;
88//! string media_type = 3;
89//! bool tainted = 4;
90//! }
91//! message Env { string name = 1; }
92//! message Expr { string kind = 1; }
93//! ```
94//!
95//! # Encoding rules
96//!
97//! Rewriting a module always produces identical bytes, which lets consumers
98//! hash, cache and diff the projection:
99//!
100//! - fields are emitted in ascending field-number order (`#[prost]`-generated
101//! structs parse it, and so does every conformant reader);
102//! - scalar fields are emitted even when they equal the proto3 default, so the
103//! encoding is injective for the projected tree;
104//! - absent optional messages (`root`, `provenance`) and empty repeated fields
105//! are omitted;
106//! - varints are minimal, `sint64` uses zig-zag, `double` is little-endian
107//! IEEE-754, and every length prefix is exact;
108//! - the projection is built from the *canonical* module (the decode of its own
109//! encoded image), so logically equal modules project to identical bytes and
110//! bindings, annotations and extensions come out in canonical order;
111//! - a module whose opaque extension payload contains a secret literal is
112//! refused: the projection must not copy secret material it cannot redact
113//! without corrupting an opaque payload.
114
115use crate::encode::{encode, hash_image};
116use crate::error::{Error, Result};
117use crate::interop::{
118 first_entry_per_key, project_node_with, redacted_key, redacted_value, secret_table, Projected,
119};
120use crate::ir::{AnnotationKind, InfinityModule, ProvValue};
121
122// ---------------------------------------------------------------------------
123// wire primitives
124// ---------------------------------------------------------------------------
125
126const WIRE_VARINT: u8 = 0;
127const WIRE_DOUBLE: u8 = 1;
128const WIRE_LEN: u8 = 2;
129
130fn varint(out: &mut Vec<u8>, mut value: u64) {
131 while value >= 0x80 {
132 out.push((value as u8) | 0x80);
133 value >>= 7;
134 }
135 out.push(value as u8);
136}
137
138fn tag(out: &mut Vec<u8>, field: u32, wire: u8) {
139 varint(out, (u64::from(field) << 3) | u64::from(wire));
140}
141
142fn uint(out: &mut Vec<u8>, field: u32, value: u64) {
143 tag(out, field, WIRE_VARINT);
144 varint(out, value);
145}
146
147fn sint(out: &mut Vec<u8>, field: u32, value: i64) {
148 tag(out, field, WIRE_VARINT);
149 varint(out, ((value << 1) ^ (value >> 63)) as u64);
150}
151
152fn boolean(out: &mut Vec<u8>, field: u32, value: bool) {
153 tag(out, field, WIRE_VARINT);
154 varint(out, u64::from(value));
155}
156
157fn double(out: &mut Vec<u8>, field: u32, value: f64) {
158 tag(out, field, WIRE_DOUBLE);
159 out.extend_from_slice(&value.to_bits().to_le_bytes());
160}
161
162fn bytes(out: &mut Vec<u8>, field: u32, value: &[u8]) {
163 tag(out, field, WIRE_LEN);
164 varint(out, value.len() as u64);
165 out.extend_from_slice(value);
166}
167
168fn string(out: &mut Vec<u8>, field: u32, value: &str) {
169 bytes(out, field, value.as_bytes());
170}
171
172fn message(out: &mut Vec<u8>, field: u32, body: &[u8]) {
173 bytes(out, field, body);
174}
175
176// ---------------------------------------------------------------------------
177// value tree
178// ---------------------------------------------------------------------------
179
180fn encode_value(value: &Projected, out: &mut Vec<u8>) {
181 match value {
182 Projected::Null => message(out, 1, &[]),
183 Projected::Bool(flag) => boolean(out, 2, *flag),
184 Projected::Int(number) => sint(out, 3, *number),
185 Projected::Float(number) => double(out, 4, *number),
186 Projected::Text(text) => string(out, 5, text),
187 Projected::DurationMs(ms) => {
188 let mut body = Vec::new();
189 uint(&mut body, 1, *ms);
190 message(out, 6, &body);
191 }
192 Projected::Quantity(value, unit) => {
193 let mut body = Vec::new();
194 let mut inner = Vec::new();
195 encode_value(value, &mut inner);
196 message(&mut body, 1, &inner);
197 string(&mut body, 2, unit);
198 message(out, 7, &body);
199 }
200 Projected::Array(items) => {
201 let mut body = Vec::new();
202 for item in items {
203 let mut encoded = Vec::new();
204 encode_value(item, &mut encoded);
205 message(&mut body, 1, &encoded);
206 }
207 message(out, 8, &body);
208 }
209 Projected::Record(fields) => {
210 let mut body = Vec::new();
211 for (name, value) in fields {
212 let mut field = Vec::new();
213 string(&mut field, 1, name);
214 let mut encoded = Vec::new();
215 encode_value(value, &mut encoded);
216 message(&mut field, 2, &encoded);
217 message(&mut body, 1, &field);
218 }
219 message(out, 9, &body);
220 }
221 Projected::Blob {
222 hash,
223 size,
224 media_type,
225 tainted,
226 } => {
227 let mut body = Vec::new();
228 bytes(&mut body, 1, hash);
229 uint(&mut body, 2, *size);
230 string(&mut body, 3, media_type);
231 boolean(&mut body, 4, *tainted);
232 message(out, 10, &body);
233 }
234 Projected::Env(name) => {
235 let mut body = Vec::new();
236 string(&mut body, 1, name);
237 message(out, 11, &body);
238 }
239 Projected::Expr(kind) => {
240 let mut body = Vec::new();
241 string(&mut body, 1, kind);
242 message(out, 12, &body);
243 }
244 }
245}
246
247// ---------------------------------------------------------------------------
248// module
249// ---------------------------------------------------------------------------
250
251/// Project `module` into protobuf wire format.
252///
253/// The result is deterministic and redacted. The `content_hash` field is the
254/// Blake3-256 of the canonical AINF image, so a consumer can request or verify
255/// the authoritative bytes with [`crate::validate`] and the usual content
256/// addressing.
257///
258/// The projection is built from the *canonical* module (the decode of its own
259/// encoded image), so two logically equal modules — which share their AINF
260/// bytes and hash — also share their projection bytes. Bindings, annotations and
261/// extensions therefore come out in canonical order.
262///
263/// A module that cannot be encoded canonically (a cycle, a dangling reference,
264/// a duplicate binding) is refused instead of being projected approximately, and
265/// so is a module whose opaque extension payload contains a copy of a secret
266/// literal: that copy would otherwise leave through the projection.
267pub fn to_protobuf(module: &InfinityModule) -> Result<Vec<u8>> {
268 let image = encode(module)?;
269 let hash = hash_image(&image)?;
270 let canonical = crate::decode::decode(&image)?;
271 let module = &canonical;
272 let secrets = secret_table(module);
273
274 let mut out = Vec::new();
275 bytes(&mut out, 1, &hash);
276 uint(&mut out, 2, image.len() as u64);
277
278 if let Some(root) = module.root() {
279 let value = project_node_with(module, root, &secrets)?;
280 let mut body = Vec::new();
281 encode_value(&value, &mut body);
282 message(&mut out, 3, &body);
283 }
284
285 for (name, id) in module.bindings() {
286 let value = project_node_with(module, *id, &secrets)?;
287 let mut binding = Vec::new();
288 string(&mut binding, 1, &redacted_key(name, &secrets));
289 let mut encoded = Vec::new();
290 encode_value(&value, &mut encoded);
291 message(&mut binding, 2, &encoded);
292 message(&mut out, 4, &binding);
293 }
294
295 if let Some(prov) = module.provenance() {
296 message(&mut out, 5, &encode_provenance(prov, &secrets));
297 }
298
299 for annotation in module.annotations() {
300 message(&mut out, 6, &encode_annotation(annotation, &secrets));
301 }
302
303 for (tag, payload) in module.extensions() {
304 if carries_secret(payload, &secrets) {
305 return Err(Error::Encode(
306 "extension payload carries secret material; a projection must not copy it",
307 ));
308 }
309 let mut extension = Vec::new();
310 uint(&mut extension, 1, *tag);
311 bytes(&mut extension, 2, payload);
312 message(&mut out, 7, &extension);
313 }
314
315 Ok(out)
316}
317
318/// Whether an opaque payload contains a secret literal.
319fn carries_secret(payload: &[u8], secrets: &[String]) -> bool {
320 secrets.iter().any(|secret| {
321 let bytes = secret.as_bytes();
322 !bytes.is_empty()
323 && bytes.len() <= payload.len()
324 && payload.windows(bytes.len()).any(|window| window == bytes)
325 })
326}
327
328fn encode_annotation(annotation: &crate::ir::Annotation, secrets: &[String]) -> Vec<u8> {
329 let mut body = Vec::new();
330 uint(&mut body, 1, annotation.node.index() as u64);
331 match &annotation.kind {
332 AnnotationKind::Intent(text) => string(&mut body, 2, &redacted_value(text, secrets)),
333 AnnotationKind::Constraint { source, expr } => {
334 let mut constraint = Vec::new();
335 string(&mut constraint, 1, &redacted_value(source, secrets));
336 uint(&mut constraint, 2, expr.index() as u64);
337 message(&mut body, 3, &constraint);
338 }
339 AnnotationKind::AiImmutable(flag) => boolean(&mut body, 4, *flag),
340 }
341 body
342}
343
344fn encode_provenance(prov: &crate::ir::Provenance, secrets: &[String]) -> Vec<u8> {
345 let mut body = Vec::new();
346 string(
347 &mut body,
348 1,
349 &redacted_value(prov.producer.as_deref().unwrap_or(""), secrets),
350 );
351 uint(&mut body, 2, prov.timestamp_ms);
352 string(
353 &mut body,
354 3,
355 &redacted_value(prov.model.as_deref().unwrap_or(""), secrets),
356 );
357 string(
358 &mut body,
359 4,
360 &redacted_value(prov.tool.as_deref().unwrap_or(""), secrets),
361 );
362 if let Some(parent) = &prov.parent {
363 bytes(&mut body, 5, parent);
364 }
365 for entry in first_entry_per_key(&prov.entries) {
366 let mut encoded = Vec::new();
367 string(&mut encoded, 1, &redacted_key(&entry.key, secrets));
368 match &entry.value {
369 ProvValue::Text(text) => string(&mut encoded, 2, &redacted_value(text, secrets)),
370 ProvValue::Int(number) => sint(&mut encoded, 3, *number),
371 ProvValue::Flag(flag) => boolean(&mut encoded, 4, *flag),
372 ProvValue::Hash(hash) => bytes(&mut encoded, 5, hash),
373 }
374 message(&mut body, 6, &encoded);
375 }
376 body
377}