use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::files::{
absolute_lexical, load_files, paths_overlap, validate_rust_lib_name, validate_write_target,
write_file,
};
use crate::sandbox;
use crate::{CheckResult, Error, Result, RustLibDocs, RustLibFile};
const DOCUMENTATION_FILE: &str = "Documentation.md";
const VERSION_FILE: &str = "Version.txt";
const MANIFEST_FILE: &str = "Cargo.toml";
const REGISTRY_TOKEN_FILE: &str = "cargo_registry_token.txt";
#[derive(Clone, Debug)]
pub struct KcodeRustLibs {
rust_libs_root: PathBuf,
work_root: PathBuf,
}
impl KcodeRustLibs {
pub fn new(rust_libs_root: impl Into<PathBuf>) -> Result<Self> {
let rust_libs_lexical = absolute_lexical(&rust_libs_root.into())?;
let work_lexical = absolute_lexical(
&std::env::temp_dir().join(format!("kcode-rust-libs-work-{}", std::process::id())),
)?;
if paths_overlap(&rust_libs_lexical, &work_lexical) {
return Err(Error::RootsOverlap {
rust_libs_root: rust_libs_lexical,
work_root: work_lexical,
});
}
create_root(&rust_libs_lexical, "create Rust libraries root")?;
create_root(&work_lexical, "create work root")?;
let rust_libs_root = fs::canonicalize(&rust_libs_lexical).map_err(|source| {
Error::io(
"canonicalize Rust libraries root",
&rust_libs_lexical,
source,
)
})?;
let work_root = fs::canonicalize(&work_lexical)
.map_err(|source| Error::io("canonicalize work root", &work_lexical, source))?;
if paths_overlap(&rust_libs_root, &work_root) {
return Err(Error::RootsOverlap {
rust_libs_root,
work_root,
});
}
Ok(Self {
rust_libs_root,
work_root,
})
}
pub fn create_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
validate_rust_lib_name(name)?;
let root = self.rust_libs_root.join(name);
match fs::create_dir(&root) {
Ok(()) => {}
Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
return Err(Error::RustLibAlreadyExists(name.to_owned()));
}
Err(source) => return Err(Error::io("create Rust library", root, source)),
}
let created = (|| {
let source_directory = root.join("src");
fs::create_dir(&source_directory).map_err(|source| {
Error::io(
"create Rust library source directory",
&source_directory,
source,
)
})?;
let manifest = format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n"
);
write_new_file(&root.join(MANIFEST_FILE), &manifest, "write Cargo manifest")?;
write_new_file(
&root.join(DOCUMENTATION_FILE),
"",
"write Rust library documentation",
)?;
write_new_file(
&root.join(VERSION_FILE),
"0.1.0\n",
"write Rust library version",
)?;
write_new_file(&source_directory.join("lib.rs"), "", "write library source")?;
Ok(())
})();
if let Err(error) = created {
let _ = fs::remove_dir_all(&root);
return Err(error);
}
self.open_validated(name, root)
}
pub fn open_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
validate_rust_lib_name(name)?;
let root = self.rust_libs_root.join(name);
let metadata = match fs::symlink_metadata(&root) {
Ok(metadata) => metadata,
Err(source) if source.kind() == io::ErrorKind::NotFound => {
return Err(Error::RustLibNotFound(name.to_owned()));
}
Err(source) => return Err(Error::io("inspect Rust library", root, source)),
};
if metadata.file_type().is_symlink() {
return Err(Error::SymlinkNotAllowed(root));
}
if !metadata.is_dir() {
return Err(Error::RustLibIsNotDirectory(name.to_owned()));
}
self.open_validated(name, root)
}
fn open_validated(&self, name: &str, root: PathBuf) -> Result<OpenedRustLib> {
let files = load_files(&root)?;
let docs = validate_rust_lib_metadata(&files)?;
Ok(OpenedRustLib {
name: name.to_owned(),
root,
work_root: self.work_root.clone(),
registry_token_path: self.rust_libs_root.join(REGISTRY_TOKEN_FILE),
files,
docs,
})
}
}
fn create_root(path: &Path, action: &'static str) -> Result<()> {
fs::create_dir_all(path).map_err(|source| Error::io(action, path, source))?;
let metadata = fs::metadata(path).map_err(|source| Error::io("inspect root", path, source))?;
if !metadata.is_dir() {
return Err(Error::io(
"use root as a directory",
path,
io::Error::new(io::ErrorKind::NotADirectory, "path is not a directory"),
));
}
Ok(())
}
fn write_new_file(path: &Path, contents: &str, action: &'static str) -> Result<()> {
fs::write(path, contents).map_err(|source| Error::io(action, path, source))
}
#[derive(Debug)]
pub struct OpenedRustLib {
name: String,
root: PathBuf,
work_root: PathBuf,
registry_token_path: PathBuf,
files: Vec<RustLibFile>,
docs: RustLibDocs,
}
impl OpenedRustLib {
pub fn name(&self) -> &str {
&self.name
}
pub fn files(&self) -> &[RustLibFile] {
&self.files
}
pub fn docs(&self) -> &RustLibDocs {
&self.docs
}
pub fn write(&mut self, files: &[RustLibFile]) -> Result<()> {
let mut unique_paths = HashSet::with_capacity(files.len());
for file in files {
if !unique_paths.insert(file.path.clone()) {
return Err(Error::DuplicateWritePath(file.path.to_string()));
}
validate_write_target(&self.root, &file.path)?;
}
let current_files = load_files(&self.root)?;
validate_projected_metadata(¤t_files, files)?;
for file in files {
write_file(&self.root, file)?;
}
let reloaded = load_files(&self.root)?;
let docs = validate_rust_lib_metadata(&reloaded)?;
self.files = reloaded;
self.docs = docs;
Ok(())
}
pub fn check(&self) -> Result<CheckResult> {
sandbox::check(&self.root, &self.work_root)
}
pub fn publish(&self) -> Result<()> {
validate_manifest_version(&self.files, &self.docs.version)?;
let check = self.check()?;
if !check.passed() {
return Err(Error::CheckFailed(check));
}
let registry_token = read_registry_token(&self.registry_token_path)?;
sandbox::publish(&self.root, &self.work_root, ®istry_token)
}
}
fn validate_projected_metadata(
current: &[RustLibFile],
writes: &[RustLibFile],
) -> Result<RustLibDocs> {
let projected = |path: &str| {
writes
.iter()
.find(|file| file.path.as_str() == path)
.or_else(|| current.iter().find(|file| file.path.as_str() == path))
.map(|file| file.contents.as_str())
};
validate_metadata_text(projected(DOCUMENTATION_FILE), projected(VERSION_FILE))
}
fn validate_rust_lib_metadata(files: &[RustLibFile]) -> Result<RustLibDocs> {
let contents = |path: &str| {
files
.iter()
.find(|file| file.path.as_str() == path)
.map(|file| file.contents.as_str())
};
validate_metadata_text(contents(DOCUMENTATION_FILE), contents(VERSION_FILE))
}
fn validate_metadata_text(
documentation: Option<&str>,
version: Option<&str>,
) -> Result<RustLibDocs> {
let documentation = documentation.ok_or(Error::MissingRequiredFile(DOCUMENTATION_FILE))?;
let version = version.ok_or(Error::MissingRequiredFile(VERSION_FILE))?;
Ok(RustLibDocs {
version: parse_version(version)?,
documentation: documentation.to_owned(),
})
}
fn parse_version(contents: &str) -> Result<String> {
let value = contents.strip_suffix('\n').unwrap_or(contents);
let components = value.split('.').collect::<Vec<_>>();
let valid_component = |component: &str| {
!component.is_empty()
&& component.bytes().all(|byte| byte.is_ascii_digit())
&& (component == "0" || !component.starts_with('0'))
};
if components.len() != 3
|| !components.into_iter().all(valid_component)
|| (contents != value && contents != format!("{value}\n"))
{
return Err(Error::InvalidVersion(contents.to_owned()));
}
Ok(value.to_owned())
}
fn validate_manifest_version(files: &[RustLibFile], version: &str) -> Result<()> {
let manifest = files
.iter()
.find(|file| file.path.as_str() == MANIFEST_FILE)
.ok_or(Error::MissingRequiredFile(MANIFEST_FILE))?;
let manifest_version = manifest_package_version(&manifest.contents)?;
if manifest_version != version {
return Err(Error::VersionMismatch {
version_file: version.to_owned(),
cargo_manifest: manifest_version,
});
}
Ok(())
}
fn manifest_package_version(manifest: &str) -> Result<String> {
let mut in_package = false;
let mut version = None;
for raw_line in manifest.lines() {
let line = strip_toml_comment(raw_line).trim();
if line.is_empty() {
continue;
}
if line.starts_with('[') {
in_package = line
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.is_some_and(|value| value.trim() == "package");
continue;
}
if !in_package {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim() != "version" {
continue;
}
if version.is_some() {
return Err(Error::InvalidCargoManifest(
"[package] declares version more than once".to_owned(),
));
}
let value = value.trim();
let unquoted = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.filter(|value| !value.contains(['"', '\'', '\\']))
.ok_or_else(|| {
Error::InvalidCargoManifest(
"[package].version must be a single-line literal string".to_owned(),
)
})?;
version = Some(unquoted.to_owned());
}
version.ok_or_else(|| {
Error::InvalidCargoManifest(
"the root manifest needs a literal version in [package]".to_owned(),
)
})
}
fn strip_toml_comment(line: &str) -> &str {
let mut quote = None;
let mut escaped = false;
for (index, character) in line.char_indices() {
match quote {
Some('"') if escaped => escaped = false,
Some('"') if character == '\\' => escaped = true,
Some(active) if character == active => quote = None,
Some(_) => {}
None if character == '"' || character == '\'' => quote = Some(character),
None if character == '#' => return &line[..index],
None => {}
}
}
line
}
fn read_registry_token(path: &Path) -> Result<String> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(source) if source.kind() == io::ErrorKind::NotFound => {
return Err(Error::MissingRegistryToken(path.to_path_buf()));
}
Err(source) if source.kind() == io::ErrorKind::InvalidData => {
return Err(Error::InvalidRegistryToken(path.to_path_buf()));
}
Err(source) => return Err(Error::io("read crates.io registry token", path, source)),
};
let token = contents.trim();
if token.is_empty() {
return Err(Error::InvalidRegistryToken(path.to_path_buf()));
}
Ok(token.to_owned())
}
#[cfg(test)]
mod tests {
use super::{parse_version, read_registry_token, validate_manifest_version};
use crate::{Error, RustLibFile, RustLibPath};
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
static TOKEN_TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn file(path: &str, contents: &str) -> RustLibFile {
RustLibFile::new(RustLibPath::new(path).unwrap(), contents)
}
#[test]
fn accepts_only_canonical_stable_versions() {
for value in ["0.1.0", "0.1.0\n", "12.34.56"] {
assert!(parse_version(value).is_ok(), "{value:?}");
}
for value in [
"v0.1.0",
"01.2.3",
"0.1",
"0.1.0-beta.1",
"0.1.0+build",
"0.1.0\n\n",
" 0.1.0 ",
] {
assert!(
matches!(parse_version(value), Err(Error::InvalidVersion(_))),
"{value:?}"
);
}
}
#[test]
fn compares_manifest_and_version_file_versions() {
let matching = vec![file(
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"1.2.3\"\n",
)];
assert!(validate_manifest_version(&matching, "1.2.3").is_ok());
assert!(matches!(
validate_manifest_version(&matching, "1.2.4"),
Err(Error::VersionMismatch { .. })
));
let commented = vec![file(
"Cargo.toml",
"[package]\nname = \"demo#one\"\nversion = '2.0.0' # current\n",
)];
assert!(validate_manifest_version(&commented, "2.0.0").is_ok());
}
#[test]
fn reads_and_trims_registry_token_file() {
let path = std::env::temp_dir().join(format!(
"kcode-rust-libs-token-test-{}-{}",
std::process::id(),
TOKEN_TEST_COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::write(&path, " secret-token\n").unwrap();
assert_eq!(read_registry_token(&path).unwrap(), "secret-token");
fs::remove_file(path).unwrap();
}
}