use std::error::Error as StdError;
use std::fmt;
use std::fs::{self, File as FsFile, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use kcode_rust_source::{File, Source, validate_name};
const HEAD: &str = "HEAD";
const LOCK: &str = ".lock";
const GENERATIONS: &str = "generations";
static UNIQUE: AtomicU64 = AtomicU64::new(0);
pub struct Repository {
path: PathBuf,
name: String,
identity: String,
source: Source,
}
pub struct Error(String);
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
fn new(category: &str, message: impl fmt::Display) -> Self {
Self(format!("{category}: {message}"))
}
fn io(operation: &str, path: impl AsRef<Path>, source: io::Error) -> Self {
Self::new(
"io",
format!("{operation} at {}: {source}", path.as_ref().display()),
)
}
fn source(error: kcode_rust_source::Error) -> Self {
Self(error.to_string())
}
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl fmt::Debug for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_tuple("Error").field(&self.0).finish()
}
}
impl StdError for Error {}
pub fn create(root: impl AsRef<Path>, name: &str, source: &Source) -> Result<Repository> {
validate_name(name).map_err(Error::source)?;
require_source_name(name, source)?;
let root = root_path(root.as_ref(), true)?;
let _root_lock = lock(&root.join(".kcode-rust-libs-v2.lock"), true)?;
let path = root.join(name);
match fs::symlink_metadata(&path) {
Ok(_) => {
return Err(Error::new(
"already_exists",
format!("managed library {name:?} already exists"),
));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect library destination", &path, error)),
}
let staging = unique_directory(&root, &format!(".{name}.create"))?;
let result = (|| {
FsFile::create(staging.join(LOCK))
.map_err(|error| Error::io("create repository lock file", &staging, error))?;
let generations = staging.join(GENERATIONS);
fs::create_dir(&generations)
.map_err(|error| Error::io("create generations directory", &generations, error))?;
let identity = unique_id("g");
let generation = generations.join(&identity);
fs::create_dir(&generation)
.map_err(|error| Error::io("create initial generation", &generation, error))?;
materialize(&generation, source)?;
fs::write(staging.join(HEAD), format!("{identity}\n"))
.map_err(|error| Error::io("write initial repository head", &staging, error))?;
fs::rename(&staging, &path)
.map_err(|error| Error::io("commit new managed library", &path, error))?;
Ok(Repository {
path: path.clone(),
name: name.to_owned(),
identity,
source: source.clone(),
})
})();
if result.is_err() {
let _ = fs::remove_dir_all(&staging);
}
result
}
pub fn open(root: impl AsRef<Path>, name: &str) -> Result<Repository> {
validate_name(name).map_err(Error::source)?;
let root = root_path(root.as_ref(), false)?;
let path = checked_repository(&root.join(name), name)?;
require_current_layout(&path)?;
let _lock = lock(&path.join(LOCK), false)?;
read_current(path, name)
}
pub fn docs(root: impl AsRef<Path>, name: &str) -> Result<(String, String)> {
validate_name(name).map_err(Error::source)?;
let root = root_path(root.as_ref(), false)?;
let path = checked_repository(&root.join(name), name)?;
require_current_layout(&path)?;
let _lock = lock(&path.join(LOCK), false)?;
let identity = read_head(&path)?;
let generation = checked_generation(&path, &identity)?;
let manifest = read_regular_utf8(&generation.join("Cargo.toml"))?;
let documentation = read_regular_utf8(&generation.join("Documentation.md"))?;
let source = Source::validate(
&[
File {
path: "Cargo.toml".to_owned(),
contents: manifest,
},
File {
path: "Documentation.md".to_owned(),
contents: documentation.clone(),
},
],
name,
)
.map_err(Error::source)?;
Ok((source.version().to_owned(), documentation))
}
impl Repository {
pub fn source(&self) -> &Source {
&self.source
}
pub fn replace(&mut self, source: &Source) -> Result<()> {
require_source_name(&self.name, source)?;
let _lock = lock(&self.path.join(LOCK), false)?;
let current = read_head(&self.path)?;
if current != self.identity {
return Err(Error::new(
"stale_snapshot",
"the managed library changed; reopen before writing",
));
}
let generations = self.path.join(GENERATIONS);
checked_directory(&generations, "generations directory")?;
let identity = unique_id("g");
let staging = generations.join(format!(".{identity}.stage"));
fs::create_dir(&staging)
.map_err(|error| Error::io("create staged generation", &staging, error))?;
if let Err(error) = materialize(&staging, source) {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
let generation = generations.join(&identity);
if let Err(error) = fs::rename(&staging, &generation) {
let _ = fs::remove_dir_all(&staging);
return Err(Error::io("finish staged generation", &generation, error));
}
if let Err(error) = replace_head(&self.path, &identity) {
let _ = fs::remove_dir_all(&generation);
return Err(error);
}
let previous = std::mem::replace(&mut self.identity, identity);
self.source = source.clone();
let _ = fs::remove_dir_all(generations.join(previous));
Ok(())
}
}
fn require_source_name(name: &str, source: &Source) -> Result<()> {
if source.name() != name {
return Err(Error::new(
"invalid_metadata",
format!(
"source package name must be {name:?}, found {:?}",
source.name()
),
));
}
Ok(())
}
fn read_current(path: PathBuf, name: &str) -> Result<Repository> {
let identity = read_head(&path)?;
let generation = checked_generation(&path, &identity)?;
let files = read_source(&generation)?;
let source = Source::validate(&files, name).map_err(Error::source)?;
Ok(Repository {
path,
name: name.to_owned(),
identity,
source,
})
}
fn materialize(root: &Path, source: &Source) -> Result<()> {
for file in source.files() {
let destination = root.join(&file.path);
let parent = destination.parent().ok_or_else(|| {
Error::new(
"unsafe_path",
format!("source path has no parent: {:?}", file.path),
)
})?;
fs::create_dir_all(parent)
.map_err(|error| Error::io("create source parent", parent, error))?;
fs::write(&destination, file.contents.as_bytes())
.map_err(|error| Error::io("write source file", &destination, error))?;
}
Ok(())
}
fn read_source(root: &Path) -> Result<Vec<File>> {
let mut files = Vec::new();
walk_source(root, root, &mut files)?;
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
fn walk_source(root: &Path, directory: &Path, files: &mut Vec<File>) -> Result<()> {
let mut entries = fs::read_dir(directory)
.map_err(|error| Error::io("read source directory", directory, error))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| Error::io("read source entry", directory, error))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|error| Error::io("inspect source entry", &path, error))?;
if metadata.file_type().is_symlink() {
return Err(Error::new(
"unsafe_source",
format!("source symlink is not allowed: {}", path.display()),
));
}
if metadata.is_dir() {
walk_source(root, &path, files)?;
} else if metadata.is_file() {
let relative = path.strip_prefix(root).map_err(|_| {
Error::new("invalid_repository", "source entry escaped its generation")
})?;
let relative = relative.to_str().ok_or_else(|| {
Error::new(
"unsafe_source",
format!("non-UTF-8 source path: {}", path.display()),
)
})?;
let relative = relative.replace(std::path::MAIN_SEPARATOR, "/");
if relative == "Cargo.lock" {
continue;
}
let bytes =
fs::read(&path).map_err(|error| Error::io("read source file", &path, error))?;
let contents = String::from_utf8(bytes).map_err(|_| {
Error::new(
"unsafe_source",
format!("non-UTF-8 source file: {}", path.display()),
)
})?;
files.push(File {
path: relative,
contents,
});
} else {
return Err(Error::new(
"unsafe_source",
format!("special source entry is not allowed: {}", path.display()),
));
}
}
Ok(())
}
fn require_current_layout(path: &Path) -> Result<()> {
let head = exists(&path.join(HEAD))?;
let lock_file = exists(&path.join(LOCK))?;
let generations = exists(&path.join(GENERATIONS))?;
if head && lock_file && generations {
Ok(())
} else {
Err(Error::new(
"invalid_repository",
"repository is flat or contains a partial generation layout",
))
}
}
fn root_path(path: &Path, create: bool) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| Error::io("read current directory", ".", error))?
.join(path)
};
if create {
fs::create_dir_all(&absolute)
.map_err(|error| Error::io("create managed-library root", &absolute, error))?;
}
checked_directory(&absolute, "managed-library root")?;
fs::canonicalize(&absolute)
.map_err(|error| Error::io("canonicalize managed-library root", &absolute, error))
}
fn checked_repository(path: &Path, name: &str) -> Result<PathBuf> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::new(
"unsafe_source",
format!("managed library {name:?} is a symlink"),
)),
Ok(metadata) if metadata.is_dir() => Ok(path.to_path_buf()),
Ok(_) => Err(Error::new(
"invalid_repository",
format!("managed library {name:?} is not a directory"),
)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::new(
"not_found",
format!("managed library {name:?} does not exist"),
)),
Err(error) => Err(Error::io("inspect managed library", path, error)),
}
}
fn checked_generation(repository: &Path, identity: &str) -> Result<PathBuf> {
let generations = repository.join(GENERATIONS);
checked_directory(&generations, "generations directory")?;
let generation = generations.join(identity);
checked_directory(&generation, "repository generation")?;
Ok(generation)
}
fn checked_directory(path: &Path, label: &str) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::new(
"unsafe_source",
format!("{label} is a symlink: {}", path.display()),
)),
Ok(metadata) if metadata.is_dir() => Ok(()),
Ok(_) => Err(Error::new(
"invalid_repository",
format!("{label} is not a directory: {}", path.display()),
)),
Err(error) => Err(Error::io("inspect directory", path, error)),
}
}
fn read_head(repository: &Path) -> Result<String> {
let head = read_regular_utf8(&repository.join(HEAD))?;
let identity = head.trim();
if identity.is_empty()
|| identity.len() > 96
|| !identity
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(Error::new(
"invalid_repository",
"repository HEAD contains an invalid generation identity",
));
}
Ok(identity.to_owned())
}
fn read_regular_utf8(path: &Path) -> Result<String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| Error::io("inspect required source file", path, error))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(Error::new(
"unsafe_source",
format!("required source is not a regular file: {}", path.display()),
));
}
let bytes = fs::read(path).map_err(|error| Error::io("read source file", path, error))?;
String::from_utf8(bytes).map_err(|_| {
Error::new(
"unsafe_source",
format!("source file is not UTF-8: {}", path.display()),
)
})
}
fn replace_head(repository: &Path, identity: &str) -> Result<()> {
let temporary = repository.join(format!(".HEAD.{}.tmp", unique_id("h")));
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|error| Error::io("create temporary repository head", &temporary, error))?;
file.write_all(format!("{identity}\n").as_bytes())
.map_err(|error| Error::io("write temporary repository head", &temporary, error))?;
file.sync_all()
.map_err(|error| Error::io("sync temporary repository head", &temporary, error))?;
fs::rename(&temporary, repository.join(HEAD))
.map_err(|error| Error::io("replace repository head", repository, error))
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
fn lock(path: &Path, create: bool) -> Result<CallLock> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(Error::new(
"unsafe_source",
format!("lock path is not a regular file: {}", path.display()),
));
}
Ok(_) => {}
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect lock file", path, error)),
}
let mut options = OpenOptions::new();
options.read(true).write(true);
if create {
options.create(true).truncate(false);
}
let file = options
.open(path)
.map_err(|error| Error::io("open lock file", path, error))?;
if !file
.metadata()
.map_err(|error| Error::io("inspect opened lock file", path, error))?
.is_file()
{
return Err(Error::new(
"unsafe_source",
format!("opened lock path is not a regular file: {}", path.display()),
));
}
FsFile::lock(&file).map_err(|error| Error::io("lock managed library", path, error))?;
Ok(CallLock(file))
}
struct CallLock(FsFile);
impl Drop for CallLock {
fn drop(&mut self) {
let _ = FsFile::unlock(&self.0);
}
}
fn exists(path: &Path) -> Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(Error::io("inspect repository path", path, error)),
}
}
fn unique_directory(parent: &Path, prefix: &str) -> Result<PathBuf> {
loop {
let path = parent.join(format!("{prefix}-{}", unique_id("d")));
match fs::create_dir(&path) {
Ok(()) => return Ok(path),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(Error::io("create unique directory", path, error)),
}
}
}
fn unique_id(prefix: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let counter = UNIQUE.fetch_add(1, Ordering::Relaxed);
format!("{prefix}-{:x}-{nanos:x}-{counter:x}", std::process::id())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use kcode_rust_source::{File, Source};
use super::{create, docs, open};
struct Root(PathBuf);
impl Root {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"kcode-rust-library-repository-{label}-{}-{}",
std::process::id(),
super::UNIQUE.fetch_add(1, super::Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Root {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn source(documentation: &str) -> Source {
Source::validate(
&[
File {
path: "Cargo.toml".to_owned(),
contents:
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"
.to_owned(),
},
File {
path: "Documentation.md".to_owned(),
contents: documentation.to_owned(),
},
File {
path: "src/lib.rs".to_owned(),
contents: String::new(),
},
],
"demo",
)
.unwrap()
}
#[test]
fn current_repositories_replace_completely_and_fence_stale_snapshots() {
let root = Root::new("replace");
create(root.path(), "demo", &source("old")).unwrap();
let mut first = open(root.path(), "demo").unwrap();
let mut stale = open(root.path(), "demo").unwrap();
first.replace(&source("first")).unwrap();
let error = stale.replace(&source("second")).unwrap_err();
assert!(error.to_string().starts_with("stale_snapshot:"));
assert_eq!(docs(root.path(), "demo").unwrap().1, "first");
}
#[test]
fn flat_and_partial_layouts_fail_without_mutation() {
let root = Root::new("layouts");
let flat = root.path().join("flat");
fs::create_dir(&flat).unwrap();
fs::write(flat.join("Cargo.toml"), "untouched").unwrap();
assert!(open(root.path(), "flat").is_err());
assert_eq!(
fs::read_to_string(flat.join("Cargo.toml")).unwrap(),
"untouched"
);
assert!(!flat.join("HEAD").exists());
assert!(!flat.join(".lock").exists());
let partial = root.path().join("partial");
fs::create_dir(&partial).unwrap();
fs::write(partial.join(".lock"), "").unwrap();
assert!(open(root.path(), "partial").is_err());
assert!(!partial.join("HEAD").exists());
assert!(!partial.join("generations").exists());
}
#[cfg(unix)]
#[test]
fn generations_directory_symlinks_are_not_followed() {
use std::os::unix::fs::symlink;
let root = Root::new("generations-symlink");
create(root.path(), "demo", &source("docs")).unwrap();
let repository = root.path().join("demo");
let escaped = root.path().join("escaped-generations");
fs::rename(repository.join("generations"), &escaped).unwrap();
symlink(&escaped, repository.join("generations")).unwrap();
assert!(open(root.path(), "demo").is_err());
assert!(docs(root.path(), "demo").is_err());
assert_eq!(
fs::read_to_string(
escaped
.join(fs::read_to_string(repository.join("HEAD")).unwrap().trim())
.join("Documentation.md")
)
.unwrap(),
"docs"
);
}
#[cfg(unix)]
#[test]
fn docs_is_narrow_but_complete_open_rejects_source_symlinks() {
use std::os::unix::fs::symlink;
let root = Root::new("symlink");
create(root.path(), "demo", &source("docs")).unwrap();
let repository = root.path().join("demo");
let identity = fs::read_to_string(repository.join("HEAD")).unwrap();
let generation = repository.join("generations").join(identity.trim());
symlink(root.path(), generation.join("src/link")).unwrap();
assert_eq!(docs(root.path(), "demo").unwrap().1, "docs");
assert!(open(root.path(), "demo").is_err());
}
}