Skip to main content

a3s_code_core/tools/
immutable_content.rs

1//! Host-injected retention for authorized immutable Tool content.
2//!
3//! The adapter is deliberately narrower than an object-store client. A host
4//! binds it to an already-authorized content authority, while Code owns exact
5//! byte measurement, digest validation, cancellation, and the Tool-result
6//! reference that enters replayable metadata. Provider credentials, tenant
7//! resolution, retention policy, and object lifecycle stay outside Core.
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::sync::Arc;
12use thiserror::Error;
13
14pub const IMMUTABLE_CONTENT_ADAPTER_BINDING_SCHEMA_V1: &str =
15    "a3s.code.immutable-content-adapter-binding.v1";
16pub const IMMUTABLE_CONTENT_DESCRIPTOR_SCHEMA_V1: &str = "a3s.code.immutable-content-descriptor.v1";
17pub const IMMUTABLE_CONTENT_REFERENCE_SCHEMA_V1: &str = "a3s.code.immutable-content-reference.v1";
18pub const TOOL_RESULT_CONTENT_MEDIA_TYPE: &str = "text/plain; charset=utf-8";
19
20const BINDING_DIGEST_DOMAIN: &str = "a3s.code.immutable-content-adapter-binding.v1";
21const DESCRIPTOR_DIGEST_DOMAIN: &str = "a3s.code.immutable-content-descriptor.v1";
22const REFERENCE_DIGEST_DOMAIN: &str = "a3s.code.immutable-content-reference.v1";
23const MAX_PROVIDER_NAME_BYTES: usize = 128;
24const MAX_REFERENCE_URI_BYTES: usize = 4 * 1024;
25const MAX_MEDIA_TYPE_BYTES: usize = 255;
26
27/// Validation and provider failures at the immutable-content boundary.
28#[derive(Debug, Clone, PartialEq, Eq, Error)]
29pub enum ImmutableContentError {
30    #[error("invalid immutable content adapter binding: {0}")]
31    InvalidBinding(String),
32    #[error("invalid immutable content descriptor: {0}")]
33    InvalidDescriptor(String),
34    #[error("immutable content adapter failed: {0}")]
35    Provider(String),
36    #[error("immutable content reference drifted: {0}")]
37    ReferenceDrift(String),
38    #[error("immutable content retention was cancelled")]
39    Cancelled,
40}
41
42pub type ImmutableContentResult<T> = std::result::Result<T, ImmutableContentError>;
43
44impl ImmutableContentError {
45    /// Bounded message safe for Tool errors and telemetry. Provider-supplied
46    /// detail is intentionally excluded because it could repeat raw content.
47    pub fn redacted_message(&self) -> &'static str {
48        match self {
49            Self::InvalidBinding(_) => "invalid immutable content adapter binding",
50            Self::InvalidDescriptor(_) => "invalid immutable content descriptor",
51            Self::Provider(_) => "immutable content provider failure",
52            Self::ReferenceDrift(_) => "immutable content reference drift",
53            Self::Cancelled => "immutable content retention cancelled",
54        }
55    }
56}
57
58/// Secret-free identity of the host authority bound to one Code session.
59///
60/// `authority_digest` is opaque to Code. The embedding host computes it from
61/// its authorized provider/namespace/profile binding and must not include
62/// plaintext tenant identifiers, credentials, endpoints, or object paths.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ImmutableContentAdapterBindingV1 {
66    pub schema: String,
67    pub authority_digest: String,
68    pub maximum_bytes: u64,
69    pub binding_digest: String,
70}
71
72impl ImmutableContentAdapterBindingV1 {
73    pub fn new(
74        authority_digest: impl Into<String>,
75        maximum_bytes: u64,
76    ) -> ImmutableContentResult<Self> {
77        let mut binding = Self {
78            schema: IMMUTABLE_CONTENT_ADAPTER_BINDING_SCHEMA_V1.to_string(),
79            authority_digest: authority_digest.into(),
80            maximum_bytes,
81            binding_digest: String::new(),
82        };
83        binding.binding_digest = binding.expected_digest()?;
84        binding.validate()?;
85        Ok(binding)
86    }
87
88    pub fn validate(&self) -> ImmutableContentResult<()> {
89        if self.schema != IMMUTABLE_CONTENT_ADAPTER_BINDING_SCHEMA_V1 {
90            return Err(invalid_binding("schema is unsupported"));
91        }
92        if !valid_sha256(&self.authority_digest) {
93            return Err(invalid_binding(
94                "authority_digest must be canonical lowercase SHA-256",
95            ));
96        }
97        if self.maximum_bytes == 0 {
98            return Err(invalid_binding("maximum_bytes must be positive"));
99        }
100        if !valid_sha256(&self.binding_digest) {
101            return Err(invalid_binding(
102                "binding_digest must be canonical lowercase SHA-256",
103            ));
104        }
105        if self.binding_digest != self.expected_digest()? {
106            return Err(invalid_binding(
107                "binding_digest does not bind the authority and byte ceiling",
108            ));
109        }
110        Ok(())
111    }
112
113    fn expected_digest(&self) -> ImmutableContentResult<String> {
114        #[derive(Serialize)]
115        struct DigestInput<'a> {
116            schema: &'a str,
117            authority_digest: &'a str,
118            maximum_bytes: u64,
119        }
120
121        canonical_digest(
122            BINDING_DIGEST_DOMAIN,
123            &DigestInput {
124                schema: &self.schema,
125                authority_digest: &self.authority_digest,
126                maximum_bytes: self.maximum_bytes,
127            },
128        )
129        .map_err(invalid_binding)
130    }
131}
132
133/// Closed purpose for one retained original-content object.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum ImmutableContentKindV1 {
137    ToolResultOriginal,
138    ToolChangeBefore,
139    ToolChangeAfter,
140}
141
142/// Content identity computed by Code before a provider is called.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct ImmutableContentDescriptorV1 {
146    pub schema: String,
147    pub kind: ImmutableContentKindV1,
148    pub media_type: String,
149    pub size_bytes: u64,
150    pub content_digest: String,
151    pub descriptor_digest: String,
152}
153
154impl ImmutableContentDescriptorV1 {
155    pub fn new(
156        kind: ImmutableContentKindV1,
157        media_type: impl Into<String>,
158        content: &[u8],
159    ) -> ImmutableContentResult<Self> {
160        let size_bytes = u64::try_from(content.len()).map_err(|_| {
161            invalid_descriptor("content size cannot be represented by the v1 byte counter")
162        })?;
163        let mut descriptor = Self {
164            schema: IMMUTABLE_CONTENT_DESCRIPTOR_SCHEMA_V1.to_string(),
165            kind,
166            media_type: media_type.into(),
167            size_bytes,
168            content_digest: sha256(content),
169            descriptor_digest: String::new(),
170        };
171        descriptor.descriptor_digest = descriptor.expected_digest()?;
172        descriptor.validate_for(content)?;
173        Ok(descriptor)
174    }
175
176    pub fn validate(&self) -> ImmutableContentResult<()> {
177        if self.schema != IMMUTABLE_CONTENT_DESCRIPTOR_SCHEMA_V1 {
178            return Err(invalid_descriptor("schema is unsupported"));
179        }
180        if self.media_type != TOOL_RESULT_CONTENT_MEDIA_TYPE
181            || !valid_plain_value(&self.media_type, MAX_MEDIA_TYPE_BYTES)
182        {
183            return Err(invalid_descriptor(
184                "media_type is not the exact v1 UTF-8 Tool-content type",
185            ));
186        }
187        if !valid_sha256(&self.content_digest) || !valid_sha256(&self.descriptor_digest) {
188            return Err(invalid_descriptor(
189                "content and descriptor digests must be canonical lowercase SHA-256",
190            ));
191        }
192        if self.descriptor_digest != self.expected_digest()? {
193            return Err(invalid_descriptor(
194                "descriptor_digest does not bind the exact content identity",
195            ));
196        }
197        Ok(())
198    }
199
200    pub fn validate_for(&self, content: &[u8]) -> ImmutableContentResult<()> {
201        self.validate()?;
202        let size_bytes = u64::try_from(content.len()).map_err(|_| {
203            invalid_descriptor("content size cannot be represented by the v1 byte counter")
204        })?;
205        if self.size_bytes != size_bytes || self.content_digest != sha256(content) {
206            return Err(invalid_descriptor(
207                "descriptor does not match the exact supplied content",
208            ));
209        }
210        Ok(())
211    }
212
213    fn expected_digest(&self) -> ImmutableContentResult<String> {
214        #[derive(Serialize)]
215        struct DigestInput<'a> {
216            schema: &'a str,
217            kind: ImmutableContentKindV1,
218            media_type: &'a str,
219            size_bytes: u64,
220            content_digest: &'a str,
221        }
222
223        canonical_digest(
224            DESCRIPTOR_DIGEST_DOMAIN,
225            &DigestInput {
226                schema: &self.schema,
227                kind: self.kind,
228                media_type: &self.media_type,
229                size_bytes: self.size_bytes,
230                content_digest: &self.content_digest,
231            },
232        )
233        .map_err(invalid_descriptor)
234    }
235}
236
237/// Provider-neutral immutable reference returned by the authorized adapter.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct ImmutableContentReferenceV1 {
241    pub schema: String,
242    pub binding_digest: String,
243    pub uri: String,
244    pub content_digest: String,
245    pub media_type: String,
246    pub size_bytes: u64,
247    pub reference_digest: String,
248}
249
250impl ImmutableContentReferenceV1 {
251    pub fn new(
252        binding: &ImmutableContentAdapterBindingV1,
253        descriptor: &ImmutableContentDescriptorV1,
254        uri: impl Into<String>,
255    ) -> ImmutableContentResult<Self> {
256        binding.validate()?;
257        descriptor.validate()?;
258        ensure_within_binding(binding, descriptor)?;
259        let mut reference = Self {
260            schema: IMMUTABLE_CONTENT_REFERENCE_SCHEMA_V1.to_string(),
261            binding_digest: binding.binding_digest.clone(),
262            uri: uri.into(),
263            content_digest: descriptor.content_digest.clone(),
264            media_type: descriptor.media_type.clone(),
265            size_bytes: descriptor.size_bytes,
266            reference_digest: String::new(),
267        };
268        reference.reference_digest = reference.expected_digest()?;
269        reference.validate_for(binding, descriptor)?;
270        Ok(reference)
271    }
272
273    pub fn validate(&self) -> ImmutableContentResult<()> {
274        if self.schema != IMMUTABLE_CONTENT_REFERENCE_SCHEMA_V1 {
275            return Err(reference_drift("schema is unsupported"));
276        }
277        if !valid_reference_uri(&self.uri) {
278            return Err(reference_drift(
279                "uri must be a bounded absolute logical reference without userinfo, query, fragment, whitespace, control, or backslash characters",
280            ));
281        }
282        let content_digest = self
283            .content_digest
284            .strip_prefix("sha256:")
285            .unwrap_or_default();
286        if !self.uri.contains(content_digest) {
287            return Err(reference_drift(
288                "uri is not content-addressed by the exact SHA-256 digest",
289            ));
290        }
291        if !valid_sha256(&self.binding_digest)
292            || !valid_sha256(&self.content_digest)
293            || !valid_sha256(&self.reference_digest)
294        {
295            return Err(reference_drift(
296                "binding, content, and reference digests must be canonical lowercase SHA-256",
297            ));
298        }
299        if self.media_type != TOOL_RESULT_CONTENT_MEDIA_TYPE
300            || !valid_plain_value(&self.media_type, MAX_MEDIA_TYPE_BYTES)
301        {
302            return Err(reference_drift(
303                "media_type is not the exact v1 UTF-8 Tool-content type",
304            ));
305        }
306        if self.reference_digest != self.expected_digest()? {
307            return Err(reference_drift(
308                "reference_digest does not bind the logical URI and content identity",
309            ));
310        }
311        Ok(())
312    }
313
314    pub fn validate_for(
315        &self,
316        binding: &ImmutableContentAdapterBindingV1,
317        descriptor: &ImmutableContentDescriptorV1,
318    ) -> ImmutableContentResult<()> {
319        binding.validate()?;
320        descriptor.validate()?;
321        ensure_within_binding(binding, descriptor)?;
322        self.validate()?;
323        if self.binding_digest != binding.binding_digest
324            || self.content_digest != descriptor.content_digest
325            || self.media_type != descriptor.media_type
326            || self.size_bytes != descriptor.size_bytes
327        {
328            return Err(reference_drift(
329                "reference does not match the session binding and exact content descriptor",
330            ));
331        }
332        Ok(())
333    }
334
335    fn expected_digest(&self) -> ImmutableContentResult<String> {
336        #[derive(Serialize)]
337        struct DigestInput<'a> {
338            schema: &'a str,
339            binding_digest: &'a str,
340            uri: &'a str,
341            content_digest: &'a str,
342            media_type: &'a str,
343            size_bytes: u64,
344        }
345
346        canonical_digest(
347            REFERENCE_DIGEST_DOMAIN,
348            &DigestInput {
349                schema: &self.schema,
350                binding_digest: &self.binding_digest,
351                uri: &self.uri,
352                content_digest: &self.content_digest,
353                media_type: &self.media_type,
354                size_bytes: self.size_bytes,
355            },
356        )
357        .map_err(reference_drift)
358    }
359}
360
361/// Borrowed provider request. Its `Debug` representation excludes content.
362pub struct ImmutableContentWriteRequestV1<'a> {
363    binding: &'a ImmutableContentAdapterBindingV1,
364    descriptor: &'a ImmutableContentDescriptorV1,
365    content: &'a [u8],
366}
367
368impl std::fmt::Debug for ImmutableContentWriteRequestV1<'_> {
369    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        formatter
371            .debug_struct("ImmutableContentWriteRequestV1")
372            .field("binding", self.binding)
373            .field("descriptor", self.descriptor)
374            .field("content", &"<redacted>")
375            .finish()
376    }
377}
378
379impl<'a> ImmutableContentWriteRequestV1<'a> {
380    fn new(
381        binding: &'a ImmutableContentAdapterBindingV1,
382        descriptor: &'a ImmutableContentDescriptorV1,
383        content: &'a [u8],
384    ) -> ImmutableContentResult<Self> {
385        binding.validate()?;
386        descriptor.validate_for(content)?;
387        ensure_within_binding(binding, descriptor)?;
388        Ok(Self {
389            binding,
390            descriptor,
391            content,
392        })
393    }
394
395    pub fn binding(&self) -> &ImmutableContentAdapterBindingV1 {
396        self.binding
397    }
398
399    pub fn descriptor(&self) -> &ImmutableContentDescriptorV1 {
400        self.descriptor
401    }
402
403    pub fn content(&self) -> &[u8] {
404        self.content
405    }
406}
407
408/// Host port for create-only, exact-replay immutable content retention.
409///
410/// The host must scope this object to an authorization already resolved
411/// outside Code. Repeating an identical descriptor must either return the
412/// byte-equivalent reference or fail; it must never overwrite another object.
413#[async_trait::async_trait]
414pub trait ImmutableContentAdapter: Send + Sync {
415    fn name(&self) -> &str;
416
417    async fn put(
418        &self,
419        request: &ImmutableContentWriteRequestV1<'_>,
420    ) -> ImmutableContentResult<ImmutableContentReferenceV1>;
421}
422
423/// Runtime pairing of a durable authority binding with one host adapter.
424#[derive(Clone)]
425pub struct ImmutableContentAdapterSession {
426    binding: ImmutableContentAdapterBindingV1,
427    adapter: Arc<dyn ImmutableContentAdapter>,
428}
429
430impl std::fmt::Debug for ImmutableContentAdapterSession {
431    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        formatter
433            .debug_struct("ImmutableContentAdapterSession")
434            .field("binding", &self.binding)
435            .field("adapter", &self.adapter.name())
436            .finish()
437    }
438}
439
440impl ImmutableContentAdapterSession {
441    pub fn new(
442        binding: ImmutableContentAdapterBindingV1,
443        adapter: Arc<dyn ImmutableContentAdapter>,
444    ) -> ImmutableContentResult<Self> {
445        binding.validate()?;
446        if !valid_plain_value(adapter.name(), MAX_PROVIDER_NAME_BYTES) {
447            return Err(ImmutableContentError::Provider(
448                "adapter name is empty, unbounded, or contains control characters".to_string(),
449            ));
450        }
451        Ok(Self { binding, adapter })
452    }
453
454    pub fn binding(&self) -> &ImmutableContentAdapterBindingV1 {
455        &self.binding
456    }
457
458    pub fn adapter_name(&self) -> &str {
459        self.adapter.name()
460    }
461
462    pub async fn put(
463        &self,
464        kind: ImmutableContentKindV1,
465        media_type: &str,
466        content: &[u8],
467    ) -> ImmutableContentResult<ImmutableContentReferenceV1> {
468        let descriptor = ImmutableContentDescriptorV1::new(kind, media_type, content)?;
469        let request = ImmutableContentWriteRequestV1::new(&self.binding, &descriptor, content)?;
470        let reference = self.adapter.put(&request).await?;
471        reference.validate_for(&self.binding, &descriptor)?;
472        Ok(reference)
473    }
474}
475
476fn ensure_within_binding(
477    binding: &ImmutableContentAdapterBindingV1,
478    descriptor: &ImmutableContentDescriptorV1,
479) -> ImmutableContentResult<()> {
480    if descriptor.size_bytes > binding.maximum_bytes {
481        return Err(invalid_descriptor(
482            "content exceeds the session's immutable-content byte ceiling",
483        ));
484    }
485    Ok(())
486}
487
488fn canonical_digest(value_domain: &str, value: &impl Serialize) -> Result<String, String> {
489    let encoded = serde_json::to_vec(value)
490        .map_err(|error| format!("could not encode canonical digest input: {error}"))?;
491    let mut hasher = Sha256::new();
492    hasher.update(value_domain.as_bytes());
493    hasher.update([0]);
494    hasher.update(encoded);
495    Ok(format!("sha256:{:x}", hasher.finalize()))
496}
497
498fn sha256(content: &[u8]) -> String {
499    format!("sha256:{:x}", Sha256::digest(content))
500}
501
502fn valid_sha256(value: &str) -> bool {
503    value.strip_prefix("sha256:").is_some_and(|hex| {
504        hex.len() == 64
505            && hex
506                .bytes()
507                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
508    })
509}
510
511fn valid_plain_value(value: &str, maximum_bytes: usize) -> bool {
512    !value.is_empty()
513        && value.len() <= maximum_bytes
514        && value.trim() == value
515        && !value.chars().any(char::is_control)
516}
517
518fn valid_reference_uri(value: &str) -> bool {
519    if !valid_plain_value(value, MAX_REFERENCE_URI_BYTES)
520        || value.chars().any(char::is_whitespace)
521        || value.contains(['?', '#', '@', '\\'])
522    {
523        return false;
524    }
525    let Some((scheme, remainder)) = value.split_once("://") else {
526        return false;
527    };
528    let mut characters = scheme.chars();
529    characters
530        .next()
531        .is_some_and(|first| first.is_ascii_alphabetic())
532        && characters.all(|character| {
533            character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
534        })
535        && !remainder.is_empty()
536}
537
538fn invalid_binding(message: impl Into<String>) -> ImmutableContentError {
539    ImmutableContentError::InvalidBinding(message.into())
540}
541
542fn invalid_descriptor(message: impl Into<String>) -> ImmutableContentError {
543    ImmutableContentError::InvalidDescriptor(message.into())
544}
545
546fn reference_drift(message: impl Into<String>) -> ImmutableContentError {
547    ImmutableContentError::ReferenceDrift(message.into())
548}