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 crate::model::{Error, File, Result, ValidSource, validate_source};
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) struct Snapshot {
pub(crate) repository: PathBuf,
pub(crate) identity: String,
pub(crate) files: Vec<File>,
}
pub(crate) fn create(root: &Path, name: &str, source: &ValidSource) -> Result<Snapshot> {
let root = root_path(root, true)?;
let _root_lock = lock_file(&root.join(".kcode-rust-libs-v2.lock"))?;
let repository = root.join(name);
match fs::symlink_metadata(&repository) {
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", &repository, 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_source(&generation, &source.files)?;
fs::write(staging.join("HEAD"), format!("{identity}\n"))
.map_err(|error| Error::io("write initial repository head", &staging, error))?;
fs::rename(&staging, &repository)
.map_err(|error| Error::io("commit new managed library", &repository, error))?;
Ok(Snapshot {
repository: repository.clone(),
identity,
files: source.files.clone(),
})
})();
if result.is_err() {
let _ = fs::remove_dir_all(&staging);
}
result
}
pub(crate) fn open(root: &Path, name: &str) -> Result<Snapshot> {
let root = root_path(root, false)?;
let repository = checked_repository(&root.join(name), name)?;
let _lock = lock_file(&repository.join(".lock"))?;
read_current(&repository, name)
}
pub(crate) fn docs(root: &Path, name: &str) -> Result<(String, String)> {
let root = root_path(root, false)?;
let repository = checked_repository(&root.join(name), name)?;
let _lock = lock_file(&repository.join(".lock"))?;
let identity = read_head(&repository)?;
let generation = checked_generation(&repository, &identity)?;
let manifest = read_regular_utf8(&generation.join("Cargo.toml"))?;
let documentation = read_regular_utf8(&generation.join("Documentation.md"))?;
let source = validate_source(
&[
File {
path: "Cargo.toml".to_owned(),
contents: manifest,
},
File {
path: "Documentation.md".to_owned(),
contents: documentation.clone(),
},
],
name,
)?;
Ok((source.version, documentation))
}
pub(crate) fn replace(
repository: &Path,
expected_identity: &str,
source: &ValidSource,
) -> Result<Snapshot> {
let _lock = lock_file(&repository.join(".lock"))?;
let current = read_head(repository)?;
if current != expected_identity {
return Err(Error::new(
"stale_snapshot",
"the managed library changed; reopen before writing",
));
}
let identity = commit_locked(repository, ¤t, source, false)?;
Ok(Snapshot {
repository: repository.to_path_buf(),
identity,
files: source.files.clone(),
})
}
pub(crate) fn materialize_source(root: &Path, files: &[File]) -> Result<()> {
for file in 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_current(repository: &Path, name: &str) -> Result<Snapshot> {
let identity = read_head(repository)?;
let generation = checked_generation(repository, &identity)?;
let files = read_source(&generation)?;
let source = validate_source(&files, name)?;
Ok(Snapshot {
repository: repository.to_path_buf(),
identity,
files: source.files,
})
}
fn commit_locked(
repository: &Path,
previous_identity: &str,
source: &ValidSource,
fail_before_head: bool,
) -> Result<String> {
let generations = repository.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_source(&staging, &source.files) {
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 fail_before_head {
return Err(Error::new(
"injected_failure",
"staged generation was not made current",
));
}
if let Err(error) = replace_head(repository, &identity) {
let _ = fs::remove_dir_all(&generation);
return Err(error);
}
if previous_identity != identity {
let _ = fs::remove_dir_all(generations.join(previous_identity));
}
Ok(identity)
}
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 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 generation = repository.join("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_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)?;
continue;
}
if !metadata.is_file() {
return Err(Error::new(
"unsafe_source",
format!("special source entry is not allowed: {}", path.display()),
));
}
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,
});
}
Ok(())
}
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 lock_file(path: &Path) -> 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 error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect lock file", path, error)),
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.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 unique_directory(parent: &Path, prefix: &str) -> Result<PathBuf> {
for _ in 0..100 {
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 => continue,
Err(error) => return Err(Error::io("create unique directory", path, error)),
}
}
Err(Error::new("io", "could not allocate a unique directory"))
}
fn unique_id(prefix: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let counter = UNIQUE_COUNTER.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 std::sync::atomic::{AtomicU64, Ordering};
use crate::model::{File, validate_source};
use super::{commit_locked, create, docs, open, read_head};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestRoot(PathBuf);
impl TestRoot {
fn new(label: &str) -> Self {
let counter = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"kcode-rust-libs-v2-{label}-{}-{counter}",
std::process::id()
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn source(documentation: &str) -> crate::model::ValidSource {
validate_source(
&[
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 a_staged_failure_does_not_advance_head() {
let root = TestRoot::new("staged-failure");
let snapshot = create(root.path(), "demo", &source("old")).unwrap();
let old_head = read_head(&snapshot.repository).unwrap();
let error =
commit_locked(&snapshot.repository, &old_head, &source("new"), true).unwrap_err();
assert!(error.to_string().starts_with("injected_failure:"));
assert_eq!(read_head(&snapshot.repository).unwrap(), old_head);
assert_eq!(open(root.path(), "demo").unwrap().files[1].contents, "old");
}
#[cfg(unix)]
#[test]
fn docs_does_not_traverse_unrelated_source_but_open_rejects_symlinks() {
use std::os::unix::fs::symlink;
let root = TestRoot::new("docs-only");
let snapshot = create(root.path(), "demo", &source("docs")).unwrap();
let identity = read_head(&snapshot.repository).unwrap();
let generation = snapshot.repository.join("generations").join(identity);
symlink(root.path(), generation.join("src/bad-link")).unwrap();
assert_eq!(
docs(root.path(), "demo").unwrap(),
("0.1.0".to_owned(), "docs".to_owned())
);
assert!(open(root.path(), "demo").is_err());
}
#[test]
fn open_omits_a_legacy_root_lockfile() {
let root = TestRoot::new("legacy-lock");
let snapshot = create(root.path(), "demo", &source("docs")).unwrap();
let identity = read_head(&snapshot.repository).unwrap();
fs::write(
snapshot
.repository
.join("generations")
.join(identity)
.join("Cargo.lock"),
"version = 4\n",
)
.unwrap();
assert!(
open(root.path(), "demo")
.unwrap()
.files
.iter()
.all(|file| file.path != "Cargo.lock")
);
}
}