Skip to main content

kcode_rust_bins/
model.rs

1use std::collections::BTreeMap;
2use std::error::Error as StdError;
3use std::fmt;
4use std::io;
5use std::path::Path;
6
7const MAX_ERROR_BYTES: usize = 12 * 1024;
8
9/// One complete UTF-8 source file.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct File {
12    pub path: String,
13    pub contents: String,
14}
15
16/// One guest request after object IDs have been resolved to bytes.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct RustBinInput {
19    pub text: String,
20    pub objects: Vec<Vec<u8>>,
21}
22
23/// One guest response before byte objects are saved.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct RustBinOutput {
26    pub text: String,
27    pub objects: Vec<Vec<u8>>,
28}
29
30/// Host request using object IDs rather than inline bytes.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct RunRequest {
33    pub text: String,
34    pub object_ids: Vec<String>,
35}
36
37/// Host result after output objects have been saved.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct RunResult {
40    /// Exact published version selected for this call.
41    pub version: String,
42    /// Exact guest-produced UTF-8 text.
43    pub text: String,
44    pub object_ids: Vec<String>,
45    pub diagnostics: String,
46}
47
48/// A bounded displayable crate failure.
49pub struct Error {
50    category: &'static str,
51    message: String,
52}
53
54pub type Result<T> = std::result::Result<T, Error>;
55
56impl Error {
57    pub(crate) fn new(category: &'static str, message: impl Into<String>) -> Self {
58        Self {
59            category,
60            message: bound(message.into()),
61        }
62    }
63
64    pub(crate) fn io(operation: &'static str, path: impl AsRef<Path>, source: io::Error) -> Self {
65        Self::new(
66            "io",
67            format!("{operation} at {}: {source}", path.as_ref().display()),
68        )
69    }
70
71    pub(crate) fn object_store(
72        operation: &'static str,
73        object_id: Option<&str>,
74        source: ObjectStoreError,
75    ) -> Self {
76        let target = object_id.map_or_else(String::new, |id| format!(" for {id:?}"));
77        Self::new("object_store", format!("{operation}{target}: {source}"))
78    }
79}
80
81impl fmt::Display for Error {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(formatter, "{}: {}", self.category, self.message)
84    }
85}
86
87impl fmt::Debug for Error {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        formatter
90            .debug_struct("Error")
91            .field("category", &self.category)
92            .field("message", &self.message)
93            .finish()
94    }
95}
96
97impl StdError for Error {}
98
99/// Failure returned by a server-supplied object store.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct ObjectStoreError {
102    message: String,
103}
104
105pub type ObjectStoreResult<T> = std::result::Result<T, ObjectStoreError>;
106
107impl ObjectStoreError {
108    pub fn new(message: impl Into<String>) -> Self {
109        Self {
110            message: bound(message.into()),
111        }
112    }
113}
114
115impl fmt::Display for ObjectStoreError {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        formatter.write_str(&self.message)
118    }
119}
120
121impl StdError for ObjectStoreError {}
122
123pub(crate) struct ValidSource {
124    pub(crate) files: Vec<File>,
125    pub(crate) version: String,
126}
127
128pub(crate) fn validate_name(name: &str) -> Result<()> {
129    let mut bytes = name.bytes();
130    if !bytes
131        .next()
132        .is_some_and(|byte| byte.is_ascii_alphanumeric())
133        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
134    {
135        return Err(Error::new(
136            "invalid_name",
137            format!("invalid managed-binary name {name:?}"),
138        ));
139    }
140    Ok(())
141}
142
143pub(crate) fn validate_source(files: &[File], expected_name: &str) -> Result<ValidSource> {
144    let mut ordered = BTreeMap::new();
145    for file in files {
146        validate_path(&file.path)?;
147        if file.path == "Cargo.lock" {
148            return Err(Error::new(
149                "invalid_source",
150                "Cargo.lock is ephemeral and cannot be managed source",
151            ));
152        }
153        if ordered
154            .insert(file.path.clone(), file.contents.clone())
155            .is_some()
156        {
157            return Err(Error::new(
158                "invalid_source",
159                format!("duplicate source path {:?}", file.path),
160            ));
161        }
162    }
163
164    let manifest = ordered
165        .get("Cargo.toml")
166        .ok_or_else(|| Error::new("invalid_source", "root Cargo.toml is required"))?;
167    if !ordered.contains_key("Documentation.md") {
168        return Err(Error::new(
169            "invalid_source",
170            "root Documentation.md is required",
171        ));
172    }
173    let (package_name, version) = manifest_metadata(manifest)?;
174    if package_name != expected_name {
175        return Err(Error::new(
176            "invalid_metadata",
177            format!("[package].name must be {expected_name:?}, found {package_name:?}"),
178        ));
179    }
180
181    Ok(ValidSource {
182        files: ordered
183            .into_iter()
184            .map(|(path, contents)| File { path, contents })
185            .collect(),
186        version,
187    })
188}
189
190pub(crate) fn manifest_metadata(manifest: &str) -> Result<(String, String)> {
191    let mut section = String::new();
192    let mut name = None;
193    let mut version = None;
194
195    for raw_line in manifest.lines() {
196        let line = strip_comment(raw_line).trim();
197        if line.is_empty() {
198            continue;
199        }
200        if line.starts_with('[') && line.ends_with(']') {
201            section.clear();
202            section.push_str(line[1..line.len() - 1].trim());
203            continue;
204        }
205        if section != "package" {
206            continue;
207        }
208        let Some((raw_key, raw_value)) = line.split_once('=') else {
209            continue;
210        };
211        match raw_key.trim() {
212            "name" => {
213                if name.is_some() {
214                    return Err(Error::new("invalid_metadata", "duplicate [package].name"));
215                }
216                name = Some(parse_basic_string(raw_value.trim(), "name")?);
217            }
218            "version" => {
219                if version.is_some() {
220                    return Err(Error::new(
221                        "invalid_metadata",
222                        "duplicate [package].version",
223                    ));
224                }
225                version = Some(parse_basic_string(raw_value.trim(), "version")?);
226            }
227            _ => {}
228        }
229    }
230
231    let name = name.ok_or_else(|| {
232        Error::new(
233            "invalid_metadata",
234            "literal [package].name is required in root Cargo.toml",
235        )
236    })?;
237    validate_name(&name)?;
238    let version = version.ok_or_else(|| {
239        Error::new(
240            "invalid_metadata",
241            "literal [package].version is required in root Cargo.toml",
242        )
243    })?;
244    validate_version(&version)?;
245    Ok((name, version))
246}
247
248fn validate_path(path: &str) -> Result<()> {
249    if path.is_empty()
250        || path.starts_with('/')
251        || path.ends_with('/')
252        || path.contains('\\')
253        || path.contains(':')
254        || path.contains('\0')
255        || path
256            .split('/')
257            .any(|component| component.is_empty() || matches!(component, "." | ".."))
258    {
259        return Err(Error::new(
260            "unsafe_path",
261            format!("invalid relative source path {path:?}"),
262        ));
263    }
264    Ok(())
265}
266
267fn validate_version(version: &str) -> Result<()> {
268    let mut count = 0;
269    for component in version.split('.') {
270        count += 1;
271        if component.is_empty()
272            || !component.bytes().all(|byte| byte.is_ascii_digit())
273            || (component.len() > 1 && component.starts_with('0'))
274            || component.parse::<u64>().is_err()
275        {
276            return Err(Error::new(
277                "invalid_metadata",
278                format!("noncanonical stable version {version:?}"),
279            ));
280        }
281    }
282    if count != 3 {
283        return Err(Error::new(
284            "invalid_metadata",
285            format!("noncanonical stable version {version:?}"),
286        ));
287    }
288    Ok(())
289}
290
291fn parse_basic_string(value: &str, field: &str) -> Result<String> {
292    if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') {
293        return Err(Error::new(
294            "invalid_metadata",
295            format!("[package].{field} must be a literal basic string"),
296        ));
297    }
298    let inner = &value[1..value.len() - 1];
299    if inner.contains(['"', '\\', '\n', '\r']) {
300        return Err(Error::new(
301            "invalid_metadata",
302            format!("[package].{field} must not contain escapes or newlines"),
303        ));
304    }
305    Ok(inner.to_owned())
306}
307
308fn strip_comment(line: &str) -> &str {
309    let mut quoted = false;
310    let mut escaped = false;
311    for (index, character) in line.char_indices() {
312        if escaped {
313            escaped = false;
314            continue;
315        }
316        if character == '\\' && quoted {
317            escaped = true;
318        } else if character == '"' {
319            quoted = !quoted;
320        } else if character == '#' && !quoted {
321            return &line[..index];
322        }
323    }
324    line
325}
326
327fn bound(mut message: String) -> String {
328    if message.len() <= MAX_ERROR_BYTES {
329        return message;
330    }
331    let mut end = MAX_ERROR_BYTES;
332    while !message.is_char_boundary(end) {
333        end -= 1;
334    }
335    message.truncate(end);
336    message.push_str("… [truncated]");
337    message
338}
339
340#[cfg(test)]
341mod tests {
342    use super::{File, manifest_metadata, validate_source};
343
344    #[test]
345    fn parses_canonical_metadata() {
346        let manifest = "[package]\nname = \"demo\"\nversion = \"12.3.4\" # current\n";
347        assert_eq!(
348            manifest_metadata(manifest).unwrap(),
349            ("demo".to_owned(), "12.3.4".to_owned())
350        );
351    }
352
353    #[test]
354    fn rejects_noncanonical_versions_and_lockfiles() {
355        for version in ["1.2", "01.2.3", "1.2.3-beta", "1.2.3+build"] {
356            let files = [
357                File {
358                    path: "Cargo.toml".to_owned(),
359                    contents: format!("[package]\nname = \"demo\"\nversion = \"{version}\"\n"),
360                },
361                File {
362                    path: "Documentation.md".to_owned(),
363                    contents: String::new(),
364                },
365            ];
366            assert!(validate_source(&files, "demo").is_err());
367        }
368
369        let files = [
370            File {
371                path: "Cargo.toml".to_owned(),
372                contents: "[package]\nname = \"demo\"\nversion = \"1.2.3\"\n".to_owned(),
373            },
374            File {
375                path: "Documentation.md".to_owned(),
376                contents: String::new(),
377            },
378            File {
379                path: "Cargo.lock".to_owned(),
380                contents: "version = 4\n".to_owned(),
381            },
382        ];
383        assert!(validate_source(&files, "demo").is_err());
384    }
385}