use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::{Error, Result, RustLibFile, RustLibPath};
pub(crate) struct RepositorySnapshot {
pub version: String,
pub files: Vec<RustLibFile>,
}
pub(crate) fn validate_name(name: &str) -> Result<()> {
let mut characters = name.chars();
let Some(first) = characters.next() else {
return Err(Error::InvalidRustLibName(name.to_owned()));
};
if !first.is_ascii_alphanumeric()
|| !characters.all(|character| {
character.is_ascii_alphanumeric() || character == '-' || character == '_'
})
{
return Err(Error::InvalidRustLibName(name.to_owned()));
}
Ok(())
}
pub(crate) fn read_repository(root: &Path) -> Result<RepositorySnapshot> {
let mut files = Vec::new();
visit_directory(root, "", &mut files)?;
files.sort_by(|left, right| left.path.cmp(&right.path));
let version = validate_metadata(&files)?;
Ok(RepositorySnapshot { version, files })
}
fn visit_directory(root: &Path, relative: &str, files: &mut Vec<RustLibFile>) -> Result<()> {
let directory = if relative.is_empty() {
root.to_path_buf()
} else {
root.join(relative)
};
let entries = fs::read_dir(&directory)
.map_err(|source| Error::io("read directory", &directory, source))?;
for entry in entries {
let entry =
entry.map_err(|source| Error::io("read directory entry", &directory, source))?;
let name = entry
.file_name()
.into_string()
.map_err(|name| Error::NonUtf8Path(PathBuf::from(name)))?;
let child_relative = if relative.is_empty() {
name
} else {
format!("{relative}/{name}")
};
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|source| Error::io("inspect library entry", &path, source))?;
if metadata.file_type().is_symlink() {
return Err(Error::SymlinkNotAllowed(path));
}
if metadata.is_dir() {
visit_directory(root, &child_relative, files)?;
} else if metadata.is_file() {
let bytes = fs::read(&path).map_err(|source| Error::io("read file", &path, source))?;
let contents = String::from_utf8(bytes).map_err(|_| Error::NonUtf8File(path))?;
files.push(RustLibFile::new(
RustLibPath::new(child_relative)?,
contents,
));
} else {
return Err(Error::UnsupportedFileType(path));
}
}
Ok(())
}
pub(crate) fn write_repository(
root: &Path,
current: &[RustLibFile],
writes: &[RustLibFile],
) -> Result<RepositorySnapshot> {
let mut seen = BTreeSet::new();
for file in writes {
if !seen.insert(file.path.clone()) {
return Err(Error::DuplicateWritePath(file.path.as_str().to_owned()));
}
}
let mut projected = current.to_vec();
for write in writes {
if let Some(existing) = projected.iter_mut().find(|file| file.path == write.path) {
existing.contents.clone_from(&write.contents);
} else {
projected.push(write.clone());
}
}
projected.sort_by(|left, right| left.path.cmp(&right.path));
validate_metadata(&projected)?;
for write in writes {
let destination = prepare_destination(root, &write.path)?;
fs::write(&destination, write.contents.as_bytes())
.map_err(|source| Error::io("write file", &destination, source))?;
}
read_repository(root)
}
fn prepare_destination(root: &Path, path: &RustLibPath) -> Result<PathBuf> {
let components = path.as_str().split('/').collect::<Vec<_>>();
let mut parent = root.to_path_buf();
for component in &components[..components.len() - 1] {
parent.push(component);
match fs::symlink_metadata(&parent) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(Error::SymlinkNotAllowed(parent));
}
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return Err(Error::UnsupportedFileType(parent)),
Err(source) if source.kind() == io::ErrorKind::NotFound => {
fs::create_dir(&parent)
.map_err(|source| Error::io("create parent directory", &parent, source))?;
}
Err(source) => return Err(Error::io("inspect parent directory", &parent, source)),
}
}
let destination = root.join(path.as_str());
match fs::symlink_metadata(&destination) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(Error::SymlinkNotAllowed(destination))
}
Ok(metadata) if metadata.is_file() => Ok(destination),
Ok(_) => Err(Error::UnsupportedFileType(destination)),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(destination),
Err(source) => Err(Error::io("inspect write destination", destination, source)),
}
}
pub(crate) fn copy_repository(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)
.map_err(|error| Error::io("create copied repository", destination, error))?;
copy_directory(source, destination)
}
fn copy_directory(source: &Path, destination: &Path) -> Result<()> {
let entries =
fs::read_dir(source).map_err(|error| Error::io("read source directory", source, error))?;
for entry in entries {
let entry = entry.map_err(|error| Error::io("read source entry", source, error))?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
let metadata = fs::symlink_metadata(&source_path)
.map_err(|error| Error::io("inspect source entry", &source_path, error))?;
if metadata.file_type().is_symlink() {
return Err(Error::SymlinkNotAllowed(source_path));
}
if metadata.is_dir() {
fs::create_dir(&destination_path)
.map_err(|error| Error::io("create copied directory", &destination_path, error))?;
copy_directory(&source_path, &destination_path)?;
} else if metadata.is_file() {
fs::copy(&source_path, &destination_path)
.map_err(|error| Error::io("copy source file", &source_path, error))?;
} else {
return Err(Error::UnsupportedFileType(source_path));
}
}
Ok(())
}
fn validate_metadata(files: &[RustLibFile]) -> Result<String> {
let manifest = required_file(files, "Cargo.toml")?;
required_file(files, "Documentation.md")?;
parse_manifest_version(&manifest.contents)
}
fn required_file<'a>(files: &'a [RustLibFile], path: &'static str) -> Result<&'a RustLibFile> {
files
.iter()
.find(|file| file.path.as_str() == path)
.ok_or(Error::MissingRequiredFile(path))
}
fn parse_manifest_version(manifest: &str) -> Result<String> {
let mut in_package = false;
let mut found = None;
for raw_line in manifest.lines() {
let line = strip_comment(raw_line).trim();
if line.is_empty() {
continue;
}
if line.starts_with('[') {
in_package = line == "[package]";
continue;
}
if !in_package {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim() != "version" {
continue;
}
if found.is_some() {
return Err(Error::InvalidCargoManifest(
"[package].version is assigned more than once".to_owned(),
));
}
found = Some(parse_basic_string(value.trim())?);
}
let version = found.ok_or_else(|| {
Error::InvalidCargoManifest(
"root [package] must contain a literal version string".to_owned(),
)
})?;
validate_version(&version)?;
Ok(version)
}
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;
continue;
}
if quoted && character == '\\' {
escaped = true;
} else if character == '"' {
quoted = !quoted;
} else if character == '#' && !quoted {
return &line[..index];
}
}
line
}
fn parse_basic_string(value: &str) -> Result<String> {
if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') {
return Err(Error::InvalidCargoManifest(
"[package].version must be a direct basic string".to_owned(),
));
}
let inner = &value[1..value.len() - 1];
if inner.contains('"') || inner.contains('\\') {
return Err(Error::InvalidCargoManifest(
"[package].version must not use escapes".to_owned(),
));
}
Ok(inner.to_owned())
}
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::InvalidVersion(version.to_owned()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::parse_manifest_version;
#[test]
fn parses_one_canonical_root_package_version() {
let manifest = "[workspace]\nresolver = \"3\"\n\n[package]\nname = \"demo\"\nversion = \"12.3.4\" # current\n";
assert_eq!(parse_manifest_version(manifest).unwrap(), "12.3.4");
}
#[test]
fn rejects_noncanonical_and_inherited_versions() {
for manifest in [
"[package]\nversion.workspace = true\n",
"[package]\nversion = \"1.2\"\n",
"[package]\nversion = \"01.2.3\"\n",
"[package]\nversion = \"1.2.3-beta\"\n",
] {
assert!(parse_manifest_version(manifest).is_err());
}
}
}