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