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#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct File {
12 pub path: String,
13 pub contents: String,
14}
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct RustBinInput {
19 pub json: String,
20 pub objects: Vec<Vec<u8>>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct RustBinOutput {
26 pub json: String,
27 pub objects: Vec<Vec<u8>>,
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct RunRequest {
33 pub json: String,
34 pub object_ids: Vec<String>,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct RunResult {
40 pub json: String,
41 pub object_ids: Vec<String>,
42 pub diagnostics: String,
43}
44
45pub 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#[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}