systemprompt_models/managed/
assets.rs1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::error::{RevisionBundleError, invalid};
12
13type Result<T> = std::result::Result<T, RevisionBundleError>;
14
15#[derive(
16 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
17)]
18#[serde(try_from = "String", into = "String")]
19pub struct AssetDigest(String);
20
21impl AssetDigest {
22 pub fn of(bytes: &[u8]) -> Self {
23 Self(hex::encode(Sha256::digest(bytes)))
24 }
25
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29}
30
31impl TryFrom<String> for AssetDigest {
32 type Error = RevisionBundleError;
33
34 fn try_from(value: String) -> Result<Self> {
35 if value.len() != 64
36 || !value
37 .bytes()
38 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
39 {
40 return Err(invalid("Expected a lowercase SHA-256 digest"));
41 }
42 Ok(Self(value))
43 }
44}
45
46impl From<AssetDigest> for String {
47 fn from(value: AssetDigest) -> Self {
48 value.0
49 }
50}
51
52#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
53#[serde(deny_unknown_fields)]
54pub struct AssetFile {
55 pub bytes: Vec<u8>,
56 pub media_type: String,
57 pub executable: bool,
58}
59
60impl std::fmt::Debug for AssetFile {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("AssetFile")
63 .field("bytes", &self.bytes.len())
64 .field("media_type", &self.media_type)
65 .field("executable", &self.executable)
66 .finish()
67 }
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
71#[serde(transparent)]
72pub struct RevisionFiles(pub BTreeMap<String, AssetFile>);
73
74impl RevisionFiles {
75 #[must_use]
76 pub fn same_content(&self, other: &Self) -> bool {
77 self.0.len() == other.0.len()
78 && self.0.iter().all(|(path, file)| {
79 other.0.get(path).is_some_and(|expected| {
80 expected.bytes == file.bytes && expected.executable == file.executable
81 })
82 })
83 }
84
85 pub fn validate(&self) -> Result<()> {
86 if self.0.is_empty() || self.0.len() > 256 {
87 return Err(invalid("Expected 1–256 revision files"));
88 }
89 let mut bytes = 0usize;
90 for (path, file) in &self.0 {
91 validate_path(path)?;
92 bytes = bytes
93 .checked_add(file.bytes.len())
94 .ok_or_else(|| invalid("File size overflow"))?;
95 if bytes > 8 * 1024 * 1024 {
96 return Err(invalid("Revision files exceed 8 MiB"));
97 }
98 if file.media_type.is_empty()
99 || file.media_type.len() > 128
100 || !file
101 .media_type
102 .bytes()
103 .all(|b| b.is_ascii_graphic() || b == b' ')
104 {
105 return Err(invalid("Invalid file media type"));
106 }
107 }
108 Ok(())
109 }
110}
111
112pub fn validate_path(path: &str) -> Result<()> {
113 if path.is_empty()
114 || path.len() > 1024
115 || path.starts_with('/')
116 || path.contains(['\\', ':'])
117 || path.chars().any(char::is_control)
118 || path.split('/').any(|part| matches!(part, "" | "." | ".."))
119 {
120 return Err(invalid("File paths must be portable relative paths"));
121 }
122 Ok(())
123}