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