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#[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#[derive(Clone, Eq, PartialEq)]
57pub struct UiAsset {
58 kind: UiAssetKind,
59 content: Arc<str>,
60 digest: Sha256Digest,
61}
62
63impl UiAsset {
64 pub fn new(kind: UiAssetKind, content: impl AsRef<str>) -> Result<Self, UiBindingError> {
66 Self::build(kind, content, None)
67 }
68
69 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#[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#[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#[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}