use std::collections::BTreeMap;
use std::error::Error as StdError;
use std::fmt;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct File {
pub path: String,
pub contents: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Source {
files: Vec<File>,
name: String,
version: String,
}
pub struct Error {
category: &'static str,
message: String,
}
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
fn new(category: &'static str, message: impl Into<String>) -> Self {
Self {
category,
message: message.into(),
}
}
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.category, self.message)
}
}
impl fmt::Debug for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Error")
.field("category", &self.category)
.field("message", &self.message)
.finish()
}
}
impl StdError for Error {}
pub fn validate_name(name: &str) -> Result<()> {
let mut bytes = name.bytes();
if !bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
|| !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(Error::new(
"invalid_name",
format!("invalid managed-package name {name:?}"),
));
}
Ok(())
}
impl Source {
pub fn validate(files: &[File], expected_name: &str) -> Result<Self> {
validate_name(expected_name)?;
let mut ordered = BTreeMap::new();
for file in files {
validate_path(&file.path)?;
if file.path == "Cargo.lock" {
return Err(Error::new(
"invalid_source",
"Cargo.lock is ephemeral and cannot be managed source",
));
}
if ordered
.insert(file.path.clone(), file.contents.clone())
.is_some()
{
return Err(Error::new(
"invalid_source",
format!("duplicate source path {:?}", file.path),
));
}
}
let manifest = ordered
.get("Cargo.toml")
.ok_or_else(|| Error::new("invalid_source", "root Cargo.toml is required"))?;
if !ordered.contains_key("Documentation.md") {
return Err(Error::new(
"invalid_source",
"root Documentation.md is required",
));
}
let (name, version) = manifest_metadata(manifest)?;
if name != expected_name {
return Err(Error::new(
"invalid_metadata",
format!("[package].name must be {expected_name:?}, found {name:?}"),
));
}
Ok(Self {
files: ordered
.into_iter()
.map(|(path, contents)| File { path, contents })
.collect(),
name,
version,
})
}
pub fn files(&self) -> &[File] {
&self.files
}
pub fn name(&self) -> &str {
&self.name
}
pub fn version(&self) -> &str {
&self.version
}
}
fn manifest_metadata(manifest: &str) -> Result<(String, String)> {
let mut section = String::new();
let mut name = None;
let mut version = None;
for raw_line in manifest.lines() {
let line = strip_comment(raw_line).trim();
if line.is_empty() {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
section.clear();
section.push_str(line[1..line.len() - 1].trim());
continue;
}
if section != "package" {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
match raw_key.trim() {
"name" => {
if name.is_some() {
return Err(Error::new("invalid_metadata", "duplicate [package].name"));
}
name = Some(parse_basic_string(raw_value.trim(), "name")?);
}
"version" => {
if version.is_some() {
return Err(Error::new(
"invalid_metadata",
"duplicate [package].version",
));
}
version = Some(parse_basic_string(raw_value.trim(), "version")?);
}
_ => {}
}
}
let name = name.ok_or_else(|| {
Error::new(
"invalid_metadata",
"literal [package].name is required in root Cargo.toml",
)
})?;
validate_name(&name)?;
let version = version.ok_or_else(|| {
Error::new(
"invalid_metadata",
"literal [package].version is required in root Cargo.toml",
)
})?;
validate_version(&version)?;
Ok((name, version))
}
fn validate_path(path: &str) -> Result<()> {
if path.is_empty()
|| path.starts_with('/')
|| path.ends_with('/')
|| path.contains('\\')
|| path.contains(':')
|| path.contains('\0')
|| path
.split('/')
.any(|component| component.is_empty() || matches!(component, "." | ".."))
{
return Err(Error::new(
"unsafe_path",
format!("invalid relative source path {path:?}"),
));
}
Ok(())
}
fn validate_version(version: &str) -> Result<()> {
let components = version.split('.').collect::<Vec<_>>();
if components.len() != 3
|| components.iter().any(|component| {
component.is_empty()
|| !component.bytes().all(|byte| byte.is_ascii_digit())
|| (component.len() > 1 && component.starts_with('0'))
|| component.parse::<u64>().is_err()
})
{
return Err(Error::new(
"invalid_metadata",
format!("noncanonical stable version {version:?}"),
));
}
Ok(())
}
fn parse_basic_string(value: &str, field: &str) -> Result<String> {
if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') {
return Err(Error::new(
"invalid_metadata",
format!("[package].{field} must be a literal basic string"),
));
}
let inner = &value[1..value.len() - 1];
if inner.contains(['"', '\\', '\n', '\r']) {
return Err(Error::new(
"invalid_metadata",
format!("[package].{field} must not contain escapes or newlines"),
));
}
Ok(inner.to_owned())
}
fn strip_comment(line: &str) -> &str {
let mut quoted = false;
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
escaped = false;
} else if character == '\\' && quoted {
escaped = true;
} else if character == '"' {
quoted = !quoted;
} else if character == '#' && !quoted {
return &line[..index];
}
}
line
}
#[cfg(test)]
mod tests {
use super::{File, Source};
fn files(version: &str) -> Vec<File> {
vec![
File {
path: "src/lib.rs".to_owned(),
contents: String::new(),
},
File {
path: "Documentation.md".to_owned(),
contents: "API\n".to_owned(),
},
File {
path: "Cargo.toml".to_owned(),
contents: format!(
"[workspace]\nresolver = \"3\"\n\n[package]\nname = \"demo\"\nversion = \"{version}\" # current\n"
),
},
]
}
#[test]
fn validates_metadata_and_canonicalizes_complete_source() {
let source = Source::validate(&files("12.3.4"), "demo").unwrap();
assert_eq!(source.name(), "demo");
assert_eq!(source.version(), "12.3.4");
assert_eq!(
source
.files()
.iter()
.map(|file| file.path.as_str())
.collect::<Vec<_>>(),
["Cargo.toml", "Documentation.md", "src/lib.rs"]
);
}
#[test]
fn rejects_noncanonical_versions_unsafe_paths_and_lockfiles() {
for version in [
"1.2",
"01.2.3",
"1.2.3-beta",
"1.2.3+build",
"123456789012345678901234567890.2.3",
] {
assert!(Source::validate(&files(version), "demo").is_err());
}
let mut unsafe_files = files("1.2.3");
unsafe_files.push(File {
path: "../escape".to_owned(),
contents: String::new(),
});
assert!(Source::validate(&unsafe_files, "demo").is_err());
let mut locked_files = files("1.2.3");
locked_files.push(File {
path: "Cargo.lock".to_owned(),
contents: "version = 4\n".to_owned(),
});
assert!(Source::validate(&locked_files, "demo").is_err());
}
#[test]
fn requires_unique_files_docs_and_matching_literal_name() {
let mut duplicate = files("1.2.3");
duplicate.push(duplicate[0].clone());
assert!(Source::validate(&duplicate, "demo").is_err());
let mut no_docs = files("1.2.3");
no_docs.retain(|file| file.path != "Documentation.md");
assert!(Source::validate(&no_docs, "demo").is_err());
let inherited = "[package]\nname.workspace = true\nversion = \"1.2.3\"\n".to_owned();
let mut invalid = files("1.2.3");
invalid[2].contents = inherited;
assert!(Source::validate(&invalid, "demo").is_err());
}
}