Skip to main content

a3s_code_core/capability/
ui_binding.rs

1use std::fmt;
2use std::sync::Arc;
3
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6use thiserror::Error;
7
8use super::{CapabilitySetError, Sha256Digest};
9
10pub const UI_DOCUMENT_SCHEMA: &str = "a3s.code.ui-document.v1";
11pub const UI_BINDING_SCHEMA: &str = "a3s.code.ui-binding.v1";
12pub const MAX_UI_ASSET_BYTES: usize = 2 * 1024 * 1024;
13pub const MAX_UI_ASSETS_PER_KIND: usize = 16;
14pub const MAX_UI_DOCUMENT_BYTES: usize = 16 * 1024 * 1024;
15
16const MAX_UI_PUBLIC_NAME_BYTES: usize = 256;
17const MAX_UI_TITLE_BYTES: usize = 256;
18const MAX_UI_DESCRIPTION_BYTES: usize = 1_024;
19const MAX_UI_ICON_BYTES: usize = 64;
20const UI_DIGEST_PREFIX: &[u8] = b"a3s-code-ui\0";
21
22/// Closed static asset roles accepted by the renderer-neutral UI contract.
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum UiAssetKind {
26    Html,
27    Style,
28    Script,
29}
30
31impl UiAssetKind {
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Html => "html",
35            Self::Style => "style",
36            Self::Script => "script",
37        }
38    }
39
40    pub const fn media_type(self) -> &'static str {
41        match self {
42            Self::Html => "text/html",
43            Self::Style => "text/css",
44            Self::Script => "text/javascript",
45        }
46    }
47}
48
49impl fmt::Display for UiAssetKind {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        formatter.write_str(self.as_str())
52    }
53}
54
55/// A bounded, path-free UTF-8 asset with a content-derived identity.
56#[derive(Clone, Eq, PartialEq)]
57pub struct UiAsset {
58    kind: UiAssetKind,
59    content: Arc<str>,
60    digest: Sha256Digest,
61}
62
63impl UiAsset {
64    /// Copy bounded UTF-8 content into an immutable asset and derive its digest.
65    pub fn new(kind: UiAssetKind, content: impl AsRef<str>) -> Result<Self, UiBindingError> {
66        Self::build(kind, content, None)
67    }
68
69    /// Construct an asset only when the supplied bytes match reviewed evidence.
70    pub fn new_verified(
71        kind: UiAssetKind,
72        content: impl AsRef<str>,
73        expected_digest: Sha256Digest,
74    ) -> Result<Self, UiBindingError> {
75        Self::build(kind, content, Some(expected_digest))
76    }
77
78    fn build(
79        kind: UiAssetKind,
80        content: impl AsRef<str>,
81        expected_digest: Option<Sha256Digest>,
82    ) -> Result<Self, UiBindingError> {
83        let content = content.as_ref();
84        if content.is_empty() {
85            return Err(UiBindingError::EmptyAsset { kind });
86        }
87        if content.len() > MAX_UI_ASSET_BYTES {
88            return Err(UiBindingError::AssetTooLarge {
89                kind,
90                max: MAX_UI_ASSET_BYTES,
91            });
92        }
93        let digest = digest_bytes(content.as_bytes())?;
94        if let Some(expected) = expected_digest {
95            if expected != digest {
96                return Err(UiBindingError::AssetDigestMismatch {
97                    kind,
98                    expected: expected.to_string(),
99                    actual: digest.to_string(),
100                });
101            }
102        }
103        Ok(Self {
104            kind,
105            content: Arc::from(content),
106            digest,
107        })
108    }
109
110    pub const fn kind(&self) -> UiAssetKind {
111        self.kind
112    }
113
114    pub const fn media_type(&self) -> &'static str {
115        self.kind.media_type()
116    }
117
118    pub fn content(&self) -> &str {
119        &self.content
120    }
121
122    pub fn digest(&self) -> &Sha256Digest {
123        &self.digest
124    }
125
126    pub fn len(&self) -> usize {
127        self.content.len()
128    }
129
130    pub fn is_empty(&self) -> bool {
131        self.content.is_empty()
132    }
133}
134
135impl fmt::Debug for UiAsset {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter
138            .debug_struct("UiAsset")
139            .field("kind", &self.kind)
140            .field("bytes", &self.len())
141            .field("digest", &self.digest)
142            .finish()
143    }
144}
145
146/// Exact path-free document bytes selected for one UI generation.
147#[derive(Clone, Eq, PartialEq)]
148pub struct UiDocument {
149    entry: UiAsset,
150    styles: Vec<UiAsset>,
151    scripts: Vec<UiAsset>,
152    total_bytes: usize,
153    digest: Sha256Digest,
154}
155
156impl UiDocument {
157    pub fn new(
158        entry: UiAsset,
159        styles: impl IntoIterator<Item = UiAsset>,
160        scripts: impl IntoIterator<Item = UiAsset>,
161    ) -> Result<Self, UiBindingError> {
162        ensure_asset_kind("entry", &entry, UiAssetKind::Html)?;
163        let styles = styles.into_iter().collect::<Vec<_>>();
164        let scripts = scripts.into_iter().collect::<Vec<_>>();
165        if styles.len() > MAX_UI_ASSETS_PER_KIND {
166            return Err(UiBindingError::AssetCountExceeded {
167                kind: UiAssetKind::Style,
168                max: MAX_UI_ASSETS_PER_KIND,
169            });
170        }
171        if scripts.len() > MAX_UI_ASSETS_PER_KIND {
172            return Err(UiBindingError::AssetCountExceeded {
173                kind: UiAssetKind::Script,
174                max: MAX_UI_ASSETS_PER_KIND,
175            });
176        }
177        for style in &styles {
178            ensure_asset_kind("styles", style, UiAssetKind::Style)?;
179        }
180        for script in &scripts {
181            ensure_asset_kind("scripts", script, UiAssetKind::Script)?;
182        }
183        let total_bytes = styles
184            .iter()
185            .chain(&scripts)
186            .try_fold(entry.len(), |total, asset| total.checked_add(asset.len()))
187            .ok_or(UiBindingError::DocumentTooLarge {
188                max: MAX_UI_DOCUMENT_BYTES,
189            })?;
190        if total_bytes > MAX_UI_DOCUMENT_BYTES {
191            return Err(UiBindingError::DocumentTooLarge {
192                max: MAX_UI_DOCUMENT_BYTES,
193            });
194        }
195        let digest = document_digest(&entry, &styles, &scripts)?;
196        Ok(Self {
197            entry,
198            styles,
199            scripts,
200            total_bytes,
201            digest,
202        })
203    }
204
205    pub fn entry(&self) -> &UiAsset {
206        &self.entry
207    }
208
209    pub fn styles(&self) -> &[UiAsset] {
210        &self.styles
211    }
212
213    pub fn scripts(&self) -> &[UiAsset] {
214        &self.scripts
215    }
216
217    pub const fn total_bytes(&self) -> usize {
218        self.total_bytes
219    }
220
221    pub fn digest(&self) -> &Sha256Digest {
222        &self.digest
223    }
224}
225
226impl fmt::Debug for UiDocument {
227    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
228        formatter
229            .debug_struct("UiDocument")
230            .field("total_bytes", &self.total_bytes)
231            .field("styles", &self.styles.len())
232            .field("scripts", &self.scripts.len())
233            .field("digest", &self.digest)
234            .finish_non_exhaustive()
235    }
236}
237
238/// Renderer-neutral metadata and exact static document selected by a host.
239#[derive(Clone, Debug, Eq, PartialEq)]
240pub struct UiBindingSpec {
241    pub public_name: String,
242    pub title: String,
243    pub description: String,
244    pub icon: String,
245    pub order: i32,
246    pub document: UiDocument,
247}
248
249/// Immutable UI value accepted by the Code capability projection kernel.
250///
251/// The binding deliberately contains no filesystem path, URL, renderer,
252/// credential, state store, or ambient backend authority. Its descriptor owns
253/// exact Tool, Skill, MCP, and Flow dependency edges; the embedding host owns
254/// sandbox, origin, CSP, navigation, state, and message-protocol policy.
255#[derive(Clone, Eq, PartialEq)]
256pub struct UiBinding {
257    public_name: Box<str>,
258    title: Box<str>,
259    description: Box<str>,
260    icon: Box<str>,
261    order: i32,
262    document: UiDocument,
263    surface_digest: Sha256Digest,
264}
265
266impl UiBinding {
267    pub fn new(spec: UiBindingSpec) -> Result<Self, UiBindingError> {
268        validate_required_text("public_name", &spec.public_name, MAX_UI_PUBLIC_NAME_BYTES)?;
269        validate_required_text("title", &spec.title, MAX_UI_TITLE_BYTES)?;
270        validate_optional_text("description", &spec.description, MAX_UI_DESCRIPTION_BYTES)?;
271        validate_icon(&spec.icon)?;
272        let surface_digest = binding_digest(&spec)?;
273        Ok(Self {
274            public_name: spec.public_name.into_boxed_str(),
275            title: spec.title.into_boxed_str(),
276            description: spec.description.into_boxed_str(),
277            icon: spec.icon.into_boxed_str(),
278            order: spec.order,
279            document: spec.document,
280            surface_digest,
281        })
282    }
283
284    pub fn public_name(&self) -> &str {
285        &self.public_name
286    }
287
288    pub fn title(&self) -> &str {
289        &self.title
290    }
291
292    pub fn description(&self) -> &str {
293        &self.description
294    }
295
296    pub fn icon(&self) -> &str {
297        &self.icon
298    }
299
300    pub const fn order(&self) -> i32 {
301        self.order
302    }
303
304    pub fn document(&self) -> &UiDocument {
305        &self.document
306    }
307
308    pub fn surface_digest(&self) -> &Sha256Digest {
309        &self.surface_digest
310    }
311}
312
313impl fmt::Debug for UiBinding {
314    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
315        formatter
316            .debug_struct("UiBinding")
317            .field("public_name", &self.public_name)
318            .field("title", &self.title)
319            .field("icon", &self.icon)
320            .field("order", &self.order)
321            .field("document", &self.document)
322            .field("surface_digest", &self.surface_digest)
323            .finish_non_exhaustive()
324    }
325}
326
327#[derive(Clone, Debug, Eq, Error, PartialEq)]
328pub enum UiBindingError {
329    #[error("UI field '{field}' is invalid: {reason}")]
330    InvalidText {
331        field: &'static str,
332        reason: &'static str,
333    },
334    #[error("UI field '{field}' exceeds its byte bound of {max}")]
335    TextTooLarge { field: &'static str, max: usize },
336    #[error("UI icon must be a bounded lowercase icon identifier")]
337    InvalidIcon,
338    #[error("UI {kind} asset is empty")]
339    EmptyAsset { kind: UiAssetKind },
340    #[error("UI {kind} asset exceeds its byte bound of {max}")]
341    AssetTooLarge { kind: UiAssetKind, max: usize },
342    #[error(
343        "UI {kind} asset digest does not match reviewed evidence (expected {expected}, found {actual})"
344    )]
345    AssetDigestMismatch {
346        kind: UiAssetKind,
347        expected: String,
348        actual: String,
349    },
350    #[error("UI document field '{field}' requires {expected}, found {actual}")]
351    AssetKindMismatch {
352        field: &'static str,
353        expected: UiAssetKind,
354        actual: UiAssetKind,
355    },
356    #[error("UI document contains more than {max} {kind} assets")]
357    AssetCountExceeded { kind: UiAssetKind, max: usize },
358    #[error("UI document exceeds its aggregate byte bound of {max}")]
359    DocumentTooLarge { max: usize },
360    #[error("UI digest construction violated the canonical SHA-256 invariant")]
361    DigestInvariant,
362}
363
364fn ensure_asset_kind(
365    field: &'static str,
366    asset: &UiAsset,
367    expected: UiAssetKind,
368) -> Result<(), UiBindingError> {
369    if asset.kind() == expected {
370        return Ok(());
371    }
372    Err(UiBindingError::AssetKindMismatch {
373        field,
374        expected,
375        actual: asset.kind(),
376    })
377}
378
379fn validate_required_text(
380    field: &'static str,
381    value: &str,
382    max: usize,
383) -> Result<(), UiBindingError> {
384    if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) {
385        return Err(UiBindingError::InvalidText {
386            field,
387            reason: "it is empty, padded, or contains control characters",
388        });
389    }
390    if value.len() > max {
391        return Err(UiBindingError::TextTooLarge { field, max });
392    }
393    Ok(())
394}
395
396fn validate_optional_text(
397    field: &'static str,
398    value: &str,
399    max: usize,
400) -> Result<(), UiBindingError> {
401    if value.len() > max {
402        return Err(UiBindingError::TextTooLarge { field, max });
403    }
404    if !value.is_empty() && (value.trim() != value || value.chars().any(char::is_control)) {
405        return Err(UiBindingError::InvalidText {
406            field,
407            reason: "it is padded or contains control characters",
408        });
409    }
410    Ok(())
411}
412
413fn validate_icon(value: &str) -> Result<(), UiBindingError> {
414    let valid = !value.is_empty()
415        && value.len() <= MAX_UI_ICON_BYTES
416        && value
417            .bytes()
418            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
419        && value
420            .as_bytes()
421            .first()
422            .is_some_and(u8::is_ascii_alphanumeric)
423        && value
424            .as_bytes()
425            .last()
426            .is_some_and(u8::is_ascii_alphanumeric);
427    if valid {
428        Ok(())
429    } else {
430        Err(UiBindingError::InvalidIcon)
431    }
432}
433
434fn document_digest(
435    entry: &UiAsset,
436    styles: &[UiAsset],
437    scripts: &[UiAsset],
438) -> Result<Sha256Digest, UiBindingError> {
439    let mut digest = UiDigest::new(UI_DOCUMENT_SCHEMA);
440    digest.asset(entry);
441    digest.count(styles.len());
442    for style in styles {
443        digest.asset(style);
444    }
445    digest.count(scripts.len());
446    for script in scripts {
447        digest.asset(script);
448    }
449    digest.finish()
450}
451
452fn binding_digest(spec: &UiBindingSpec) -> Result<Sha256Digest, UiBindingError> {
453    let mut digest = UiDigest::new(UI_BINDING_SCHEMA);
454    digest.field(spec.public_name.as_bytes());
455    digest.field(spec.title.as_bytes());
456    digest.field(spec.description.as_bytes());
457    digest.field(spec.icon.as_bytes());
458    digest.field(&spec.order.to_be_bytes());
459    digest.field(spec.document.digest().as_str().as_bytes());
460    digest.finish()
461}
462
463fn digest_bytes(value: &[u8]) -> Result<Sha256Digest, UiBindingError> {
464    Sha256Digest::new(format!("sha256:{:x}", Sha256::digest(value))).map_err(map_digest_error)
465}
466
467fn map_digest_error(_error: CapabilitySetError) -> UiBindingError {
468    UiBindingError::DigestInvariant
469}
470
471struct UiDigest(Sha256);
472
473impl UiDigest {
474    fn new(domain: &str) -> Self {
475        let mut hasher = Sha256::new();
476        hasher.update(UI_DIGEST_PREFIX);
477        hash_field(&mut hasher, domain.as_bytes());
478        Self(hasher)
479    }
480
481    fn field(&mut self, value: &[u8]) {
482        hash_field(&mut self.0, value);
483    }
484
485    fn count(&mut self, value: usize) {
486        self.field(&(value as u64).to_be_bytes());
487    }
488
489    fn asset(&mut self, asset: &UiAsset) {
490        self.field(asset.kind().as_str().as_bytes());
491        self.field(asset.digest().as_str().as_bytes());
492    }
493
494    fn finish(self) -> Result<Sha256Digest, UiBindingError> {
495        Sha256Digest::new(format!("sha256:{:x}", self.0.finalize())).map_err(map_digest_error)
496    }
497}
498
499fn hash_field(hasher: &mut Sha256, value: &[u8]) {
500    hasher.update((value.len() as u64).to_be_bytes());
501    hasher.update(value);
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn ui_content_and_surface_digests_have_a_stable_golden_identity() {
510        let document = UiDocument::new(
511            UiAsset::new(UiAssetKind::Html, "<!doctype html><main>exact</main>").unwrap(),
512            [UiAsset::new(UiAssetKind::Style, "main { display: grid; }").unwrap()],
513            [UiAsset::new(UiAssetKind::Script, "globalThis.ready = true;").unwrap()],
514        )
515        .unwrap();
516        assert_eq!(
517            document.entry().digest().as_str(),
518            "sha256:8a9058b3f3c8402c616024c84410de04b23f7a9280552c3b1c05b9fd386fe763"
519        );
520        assert_eq!(
521            document.digest().as_str(),
522            "sha256:4286fdb205409b4cd204cefdd7e1e8fe0d45a4a269193ff656a976a2353a0114"
523        );
524        let binding = UiBinding::new(UiBindingSpec {
525            public_name: "panel".to_owned(),
526            title: "Evidence".to_owned(),
527            description: "Exact.".to_owned(),
528            icon: "panel-top".to_owned(),
529            order: 20,
530            document,
531        })
532        .unwrap();
533        assert_eq!(
534            binding.surface_digest().as_str(),
535            "sha256:c78cab3d09b64058f352bb84a10f749707b2cd84d1acfb780e77bee6fe3cae27"
536        );
537    }
538
539    #[test]
540    fn ui_assets_and_documents_enforce_role_and_memory_bounds() {
541        assert!(matches!(
542            UiAsset::new(UiAssetKind::Script, ""),
543            Err(UiBindingError::EmptyAsset {
544                kind: UiAssetKind::Script
545            })
546        ));
547        assert!(matches!(
548            UiAsset::new(UiAssetKind::Style, "x".repeat(MAX_UI_ASSET_BYTES + 1)),
549            Err(UiBindingError::AssetTooLarge {
550                kind: UiAssetKind::Style,
551                max: MAX_UI_ASSET_BYTES
552            })
553        ));
554        let wrong_entry = UiAsset::new(UiAssetKind::Script, "globalThis.ready = true;").unwrap();
555        assert!(matches!(
556            UiDocument::new(wrong_entry, [], []),
557            Err(UiBindingError::AssetKindMismatch {
558                field: "entry",
559                expected: UiAssetKind::Html,
560                actual: UiAssetKind::Script
561            })
562        ));
563    }
564
565    #[test]
566    fn ui_debug_output_never_embeds_executable_content() {
567        let secret_marker = "DO_NOT_EMBED_UI_SOURCE_IN_DEBUG";
568        let asset = UiAsset::new(UiAssetKind::Script, secret_marker).unwrap();
569        assert!(!format!("{asset:?}").contains(secret_marker));
570    }
571}