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