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 text: String,
20 pub objects: Vec<Vec<u8>>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct RustBinOutput {
26 pub text: String,
27 pub objects: Vec<Vec<u8>>,
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct RunRequest {
33 pub text: String,
34 pub object_ids: Vec<String>,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct RunResult {
40 pub version: String,
42 pub text: String,
44 pub object_ids: Vec<String>,
45 pub diagnostics: String,
46}
47
48pub 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#[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}