Skip to main content

systemprompt_models/managed/
assets.rs

1//! Bounded exact file bytes with portable paths and verified content hashes.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}