Skip to main content

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_bytes,
119    secret_table, Projected,
120};
121use crate::ir::{AnnotationKind, InfinityModule, ProvValue};
122
123// ---------------------------------------------------------------------------
124// wire primitives
125// ---------------------------------------------------------------------------
126
127const WIRE_VARINT: u8 = 0;
128const WIRE_DOUBLE: u8 = 1;
129const WIRE_LEN: u8 = 2;
130
131fn varint(out: &mut Vec<u8>, mut value: u64) {
132    while value >= 0x80 {
133        out.push((value as u8) | 0x80);
134        value >>= 7;
135    }
136    out.push(value as u8);
137}
138
139fn tag(out: &mut Vec<u8>, field: u32, wire: u8) {
140    varint(out, (u64::from(field) << 3) | u64::from(wire));
141}
142
143fn uint(out: &mut Vec<u8>, field: u32, value: u64) {
144    tag(out, field, WIRE_VARINT);
145    varint(out, value);
146}
147
148fn sint(out: &mut Vec<u8>, field: u32, value: i64) {
149    tag(out, field, WIRE_VARINT);
150    varint(out, ((value << 1) ^ (value >> 63)) as u64);
151}
152
153fn boolean(out: &mut Vec<u8>, field: u32, value: bool) {
154    tag(out, field, WIRE_VARINT);
155    varint(out, u64::from(value));
156}
157
158fn double(out: &mut Vec<u8>, field: u32, value: f64) {
159    tag(out, field, WIRE_DOUBLE);
160    out.extend_from_slice(&value.to_bits().to_le_bytes());
161}
162
163fn bytes(out: &mut Vec<u8>, field: u32, value: &[u8]) {
164    tag(out, field, WIRE_LEN);
165    varint(out, value.len() as u64);
166    out.extend_from_slice(value);
167}
168
169fn string(out: &mut Vec<u8>, field: u32, value: &str) {
170    bytes(out, field, value.as_bytes());
171}
172
173fn message(out: &mut Vec<u8>, field: u32, body: &[u8]) {
174    bytes(out, field, body);
175}
176
177// ---------------------------------------------------------------------------
178// value tree
179// ---------------------------------------------------------------------------
180
181fn encode_value(value: &Projected, out: &mut Vec<u8>) {
182    match value {
183        Projected::Null => message(out, 1, &[]),
184        Projected::Bool(flag) => boolean(out, 2, *flag),
185        Projected::Int(number) => sint(out, 3, *number),
186        Projected::Float(number) => double(out, 4, *number),
187        Projected::Text(text) => string(out, 5, text),
188        Projected::DurationMs(ms) => {
189            let mut body = Vec::new();
190            uint(&mut body, 1, *ms);
191            message(out, 6, &body);
192        }
193        Projected::Quantity(value, unit) => {
194            let mut body = Vec::new();
195            let mut inner = Vec::new();
196            encode_value(value, &mut inner);
197            message(&mut body, 1, &inner);
198            string(&mut body, 2, unit);
199            message(out, 7, &body);
200        }
201        Projected::Array(items) => {
202            let mut body = Vec::new();
203            for item in items {
204                let mut encoded = Vec::new();
205                encode_value(item, &mut encoded);
206                message(&mut body, 1, &encoded);
207            }
208            message(out, 8, &body);
209        }
210        Projected::Record(fields) => {
211            let mut body = Vec::new();
212            for (name, value) in fields {
213                let mut field = Vec::new();
214                string(&mut field, 1, name);
215                let mut encoded = Vec::new();
216                encode_value(value, &mut encoded);
217                message(&mut field, 2, &encoded);
218                message(&mut body, 1, &field);
219            }
220            message(out, 9, &body);
221        }
222        Projected::Blob {
223            hash,
224            size,
225            media_type,
226            tainted,
227        } => {
228            let mut body = Vec::new();
229            bytes(&mut body, 1, hash);
230            uint(&mut body, 2, *size);
231            string(&mut body, 3, media_type);
232            boolean(&mut body, 4, *tainted);
233            message(out, 10, &body);
234        }
235        Projected::Env(name) => {
236            let mut body = Vec::new();
237            string(&mut body, 1, name);
238            message(out, 11, &body);
239        }
240        Projected::Expr(kind) => {
241            let mut body = Vec::new();
242            string(&mut body, 1, kind);
243            message(out, 12, &body);
244        }
245    }
246}
247
248// ---------------------------------------------------------------------------
249// module
250// ---------------------------------------------------------------------------
251
252/// Project `module` into protobuf wire format.
253///
254/// The result is deterministic and redacted. The `content_hash` field is the
255/// Blake3-256 of the canonical AINF image, so a consumer can request or verify
256/// the authoritative bytes with [`validate()`](crate::validate::validate) and the
257/// usual content addressing.
258///
259/// The projection is built from the *canonical* module (the decode of its own
260/// encoded image), so two logically equal modules — which share their AINF
261/// bytes and hash — also share their projection bytes. Bindings, annotations and
262/// extensions therefore come out in canonical order.
263///
264/// A module that cannot be encoded canonically (a cycle, a dangling reference,
265/// a duplicate binding) is refused instead of being projected approximately, and
266/// so is a module whose opaque extension payload contains a copy of a secret
267/// literal: that copy would otherwise leave through the projection.
268pub fn to_protobuf(module: &InfinityModule) -> Result<Vec<u8>> {
269    let image = encode(module)?;
270    let hash = hash_image(&image)?;
271    let canonical = crate::decode::decode(&image)?;
272    let module = &canonical;
273    let secrets = secret_table(module);
274    // The extension check is byte-level, so it compares against the raw secret
275    // bytes, not their lossy text form.
276    let raw_secrets = secret_bytes(module);
277
278    let mut out = Vec::new();
279    bytes(&mut out, 1, &hash);
280    uint(&mut out, 2, image.len() as u64);
281
282    if let Some(root) = module.root() {
283        let value = project_node_with(module, root, &secrets)?;
284        let mut body = Vec::new();
285        encode_value(&value, &mut body);
286        message(&mut out, 3, &body);
287    }
288
289    for (name, id) in module.bindings() {
290        let value = project_node_with(module, *id, &secrets)?;
291        let mut binding = Vec::new();
292        string(&mut binding, 1, &redacted_key(name, &secrets));
293        let mut encoded = Vec::new();
294        encode_value(&value, &mut encoded);
295        message(&mut binding, 2, &encoded);
296        message(&mut out, 4, &binding);
297    }
298
299    if let Some(prov) = module.provenance() {
300        message(&mut out, 5, &encode_provenance(prov, &secrets));
301    }
302
303    for annotation in module.annotations() {
304        message(&mut out, 6, &encode_annotation(annotation, &secrets));
305    }
306
307    for (tag, payload) in module.extensions() {
308        if carries_secret(payload, &raw_secrets) {
309            return Err(Error::Encode(
310                "extension payload carries secret material; a projection must not copy it",
311            ));
312        }
313        let mut extension = Vec::new();
314        uint(&mut extension, 1, *tag);
315        bytes(&mut extension, 2, payload);
316        message(&mut out, 7, &extension);
317    }
318
319    Ok(out)
320}
321
322/// Whether an opaque payload contains a secret literal, comparing raw bytes.
323fn carries_secret(payload: &[u8], secrets: &[Vec<u8>]) -> bool {
324    secrets.iter().any(|secret| {
325        !secret.is_empty()
326            && secret.len() <= payload.len()
327            && payload.windows(secret.len()).any(|window| window == secret)
328    })
329}
330
331fn encode_annotation(annotation: &crate::ir::Annotation, secrets: &[String]) -> Vec<u8> {
332    let mut body = Vec::new();
333    uint(&mut body, 1, annotation.node.index() as u64);
334    match &annotation.kind {
335        AnnotationKind::Intent(text) => string(&mut body, 2, &redacted_value(text, secrets)),
336        AnnotationKind::Constraint { source, expr } => {
337            let mut constraint = Vec::new();
338            string(&mut constraint, 1, &redacted_value(source, secrets));
339            uint(&mut constraint, 2, expr.index() as u64);
340            message(&mut body, 3, &constraint);
341        }
342        AnnotationKind::AiImmutable(flag) => boolean(&mut body, 4, *flag),
343    }
344    body
345}
346
347fn encode_provenance(prov: &crate::ir::Provenance, secrets: &[String]) -> Vec<u8> {
348    let mut body = Vec::new();
349    string(
350        &mut body,
351        1,
352        &redacted_value(prov.producer.as_deref().unwrap_or(""), secrets),
353    );
354    uint(&mut body, 2, prov.timestamp_ms);
355    string(
356        &mut body,
357        3,
358        &redacted_value(prov.model.as_deref().unwrap_or(""), secrets),
359    );
360    string(
361        &mut body,
362        4,
363        &redacted_value(prov.tool.as_deref().unwrap_or(""), secrets),
364    );
365    if let Some(parent) = &prov.parent {
366        bytes(&mut body, 5, parent);
367    }
368    for entry in first_entry_per_key(&prov.entries) {
369        let mut encoded = Vec::new();
370        string(&mut encoded, 1, &redacted_key(&entry.key, secrets));
371        match &entry.value {
372            ProvValue::Text(text) => string(&mut encoded, 2, &redacted_value(text, secrets)),
373            ProvValue::Int(number) => sint(&mut encoded, 3, *number),
374            ProvValue::Flag(flag) => boolean(&mut encoded, 4, *flag),
375            ProvValue::Hash(hash) => bytes(&mut encoded, 5, hash),
376        }
377        message(&mut body, 6, &encoded);
378    }
379    body
380}