1use 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#[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 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#[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#[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#[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#[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
361pub 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#[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#[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}