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 objects: Vec<Vec<u8>>,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct RunResult {
40 pub version: String,
42 pub text: String,
44 pub objects: Vec<Vec<u8>>,
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
72impl fmt::Display for Error {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 write!(formatter, "{}: {}", self.category, self.message)
75 }
76}
77
78impl fmt::Debug for Error {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 formatter
81 .debug_struct("Error")
82 .field("category", &self.category)
83 .field("message", &self.message)
84 .finish()
85 }
86}
87
88impl StdError for Error {}
89
90pub(crate) struct ValidSource {
91 pub(crate) files: Vec<File>,
92 pub(crate) version: String,
93}
94
95pub(crate) fn validate_name(name: &str) -> Result<()> {
96 let mut bytes = name.bytes();
97 if !bytes
98 .next()
99 .is_some_and(|byte| byte.is_ascii_alphanumeric())
100 || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
101 {
102 return Err(Error::new(
103 "invalid_name",
104 format!("invalid managed-binary name {name:?}"),
105 ));
106 }
107 Ok(())
108}
109
110pub(crate) fn validate_source(files: &[File], expected_name: &str) -> Result<ValidSource> {
111 let mut ordered = BTreeMap::new();
112 for file in files {
113 validate_path(&file.path)?;
114 if file.path == "Cargo.lock" {
115 return Err(Error::new(
116 "invalid_source",
117 "Cargo.lock is ephemeral and cannot be managed source",
118 ));
119 }
120 if ordered
121 .insert(file.path.clone(), file.contents.clone())
122 .is_some()
123 {
124 return Err(Error::new(
125 "invalid_source",
126 format!("duplicate source path {:?}", file.path),
127 ));
128 }
129 }
130
131 let manifest = ordered
132 .get("Cargo.toml")
133 .ok_or_else(|| Error::new("invalid_source", "root Cargo.toml is required"))?;
134 if !ordered.contains_key("Documentation.md") {
135 return Err(Error::new(
136 "invalid_source",
137 "root Documentation.md is required",
138 ));
139 }
140 let (package_name, version) = manifest_metadata(manifest)?;
141 if package_name != expected_name {
142 return Err(Error::new(
143 "invalid_metadata",
144 format!("[package].name must be {expected_name:?}, found {package_name:?}"),
145 ));
146 }
147
148 Ok(ValidSource {
149 files: ordered
150 .into_iter()
151 .map(|(path, contents)| File { path, contents })
152 .collect(),
153 version,
154 })
155}
156
157pub(crate) fn manifest_metadata(manifest: &str) -> Result<(String, String)> {
158 let mut section = String::new();
159 let mut name = None;
160 let mut version = None;
161
162 for raw_line in manifest.lines() {
163 let line = strip_comment(raw_line).trim();
164 if line.is_empty() {
165 continue;
166 }
167 if line.starts_with('[') && line.ends_with(']') {
168 section.clear();
169 section.push_str(line[1..line.len() - 1].trim());
170 continue;
171 }
172 if section != "package" {
173 continue;
174 }
175 let Some((raw_key, raw_value)) = line.split_once('=') else {
176 continue;
177 };
178 match raw_key.trim() {
179 "name" => {
180 if name.is_some() {
181 return Err(Error::new("invalid_metadata", "duplicate [package].name"));
182 }
183 name = Some(parse_basic_string(raw_value.trim(), "name")?);
184 }
185 "version" => {
186 if version.is_some() {
187 return Err(Error::new(
188 "invalid_metadata",
189 "duplicate [package].version",
190 ));
191 }
192 version = Some(parse_basic_string(raw_value.trim(), "version")?);
193 }
194 _ => {}
195 }
196 }
197
198 let name = name.ok_or_else(|| {
199 Error::new(
200 "invalid_metadata",
201 "literal [package].name is required in root Cargo.toml",
202 )
203 })?;
204 validate_name(&name)?;
205 let version = version.ok_or_else(|| {
206 Error::new(
207 "invalid_metadata",
208 "literal [package].version is required in root Cargo.toml",
209 )
210 })?;
211 validate_version(&version)?;
212 Ok((name, version))
213}
214
215fn validate_path(path: &str) -> Result<()> {
216 if path.is_empty()
217 || path.starts_with('/')
218 || path.ends_with('/')
219 || path.contains('\\')
220 || path.contains(':')
221 || path.contains('\0')
222 || path
223 .split('/')
224 .any(|component| component.is_empty() || matches!(component, "." | ".."))
225 {
226 return Err(Error::new(
227 "unsafe_path",
228 format!("invalid relative source path {path:?}"),
229 ));
230 }
231 Ok(())
232}
233
234fn validate_version(version: &str) -> Result<()> {
235 let mut count = 0;
236 for component in version.split('.') {
237 count += 1;
238 if component.is_empty()
239 || !component.bytes().all(|byte| byte.is_ascii_digit())
240 || (component.len() > 1 && component.starts_with('0'))
241 || component.parse::<u64>().is_err()
242 {
243 return Err(Error::new(
244 "invalid_metadata",
245 format!("noncanonical stable version {version:?}"),
246 ));
247 }
248 }
249 if count != 3 {
250 return Err(Error::new(
251 "invalid_metadata",
252 format!("noncanonical stable version {version:?}"),
253 ));
254 }
255 Ok(())
256}
257
258fn parse_basic_string(value: &str, field: &str) -> Result<String> {
259 if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') {
260 return Err(Error::new(
261 "invalid_metadata",
262 format!("[package].{field} must be a literal basic string"),
263 ));
264 }
265 let inner = &value[1..value.len() - 1];
266 if inner.contains(['"', '\\', '\n', '\r']) {
267 return Err(Error::new(
268 "invalid_metadata",
269 format!("[package].{field} must not contain escapes or newlines"),
270 ));
271 }
272 Ok(inner.to_owned())
273}
274
275fn strip_comment(line: &str) -> &str {
276 let mut quoted = false;
277 let mut escaped = false;
278 for (index, character) in line.char_indices() {
279 if escaped {
280 escaped = false;
281 continue;
282 }
283 if character == '\\' && quoted {
284 escaped = true;
285 } else if character == '"' {
286 quoted = !quoted;
287 } else if character == '#' && !quoted {
288 return &line[..index];
289 }
290 }
291 line
292}
293
294fn bound(mut message: String) -> String {
295 if message.len() <= MAX_ERROR_BYTES {
296 return message;
297 }
298 let mut end = MAX_ERROR_BYTES;
299 while !message.is_char_boundary(end) {
300 end -= 1;
301 }
302 message.truncate(end);
303 message.push_str("… [truncated]");
304 message
305}
306
307#[cfg(test)]
308mod tests {
309 use super::{File, manifest_metadata, validate_source};
310
311 #[test]
312 fn parses_canonical_metadata() {
313 let manifest = "[package]\nname = \"demo\"\nversion = \"12.3.4\" # current\n";
314 assert_eq!(
315 manifest_metadata(manifest).unwrap(),
316 ("demo".to_owned(), "12.3.4".to_owned())
317 );
318 }
319
320 #[test]
321 fn rejects_noncanonical_versions_and_lockfiles() {
322 for version in ["1.2", "01.2.3", "1.2.3-beta", "1.2.3+build"] {
323 let files = [
324 File {
325 path: "Cargo.toml".to_owned(),
326 contents: format!("[package]\nname = \"demo\"\nversion = \"{version}\"\n"),
327 },
328 File {
329 path: "Documentation.md".to_owned(),
330 contents: String::new(),
331 },
332 ];
333 assert!(validate_source(&files, "demo").is_err());
334 }
335
336 let files = [
337 File {
338 path: "Cargo.toml".to_owned(),
339 contents: "[package]\nname = \"demo\"\nversion = \"1.2.3\"\n".to_owned(),
340 },
341 File {
342 path: "Documentation.md".to_owned(),
343 contents: String::new(),
344 },
345 File {
346 path: "Cargo.lock".to_owned(),
347 contents: "version = 4\n".to_owned(),
348 },
349 ];
350 assert!(validate_source(&files, "demo").is_err());
351 }
352}