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};
const HEAD_FILE: &str = "HEAD";
const LOCK_FILE: &str = ".lock";
const GENERATIONS_DIRECTORY: &str = "generations";
const MIGRATION_FILE: &str = ".kcode-rust-bins-migration";
const MIGRATION_MAGIC: &str = "kcode-rust-bins-migration-v1";
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-bins.lock"))?;
let repository = root.join(name);
match fs::symlink_metadata(&repository) {
Ok(_) => {
return Err(Error::new(
"already_exists",
format!("managed binary {name:?} already exists"),
));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect binary destination", &repository, error)),
}
let staging = unique_directory(&root, &format!(".{name}.create"))?;
let result = (|| {
FsFile::create(staging.join(LOCK_FILE))
.map_err(|error| Error::io("create repository lock", &staging, error))?;
let generations = staging.join(GENERATIONS_DIRECTORY);
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)?;
write_sync(&staging.join(HEAD_FILE), format!("{identity}\n").as_bytes())?;
fs::rename(&staging, &repository)
.map_err(|error| Error::io("commit new managed binary", &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 = root.join(name);
let metadata = fs::symlink_metadata(&repository)
.map_err(|error| Error::io("inspect managed binary", &repository, error))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(Error::new(
"invalid_repository",
format!(
"managed binary is not a regular directory: {}",
repository.display()
),
));
}
prepare_repository(&root, name, &repository)?;
read_snapshot(&repository, name)
}
pub(crate) fn docs(root: &Path, name: &str) -> Result<(String, String)> {
let snapshot = open(root, name)?;
let source = validate_source(&snapshot.files, name)?;
let documentation = source
.files
.iter()
.find(|file| file.path == "Documentation.md")
.expect("validated source has Documentation.md")
.contents
.clone();
Ok((source.version, documentation))
}
pub(crate) fn replace(
repository: &Path,
expected_identity: &str,
source: &ValidSource,
) -> Result<Snapshot> {
let _lock = lock_file(&repository.join(LOCK_FILE))?;
let current = read_head(repository)?;
if current != expected_identity {
return Err(Error::new(
"stale_snapshot",
format!("expected generation {expected_identity}, current generation is {current}"),
));
}
let generations = repository.join(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))?;
let generation = generations.join(&identity);
let result = (|| {
materialize_source(&staging, &source.files)?;
fs::rename(&staging, &generation)
.map_err(|error| Error::io("commit immutable generation", &generation, error))?;
replace_head(repository, &identity)?;
Ok(Snapshot {
repository: repository.to_path_buf(),
identity,
files: source.files.clone(),
})
})();
if result.is_err() {
let _ = fs::remove_dir_all(&staging);
}
result
}
pub(crate) fn materialize_source(root: &Path, files: &[File]) -> Result<()> {
for file in files {
let path = root.join(&file.path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| Error::io("create source directory", parent, error))?;
}
write_sync(&path, file.contents.as_bytes())?;
}
Ok(())
}
fn prepare_repository(root: &Path, name: &str, repository: &Path) -> Result<()> {
let _root_lock = lock_file(&root.join(".kcode-rust-bins.lock"))?;
if repository.join(MIGRATION_FILE).exists() {
recover_migration(repository)?;
}
let head = repository.join(HEAD_FILE).exists();
let lock = repository.join(LOCK_FILE).exists();
let generations = repository.join(GENERATIONS_DIRECTORY).exists();
match (head, lock, generations) {
(true, true, true) => Ok(()),
(false, false, false) => migrate_legacy(root, name, repository),
_ => Err(Error::new(
"invalid_repository",
"partial or conflicting managed-binary repository metadata",
)),
}
}
fn migrate_legacy(root: &Path, name: &str, repository: &Path) -> Result<()> {
let files = read_tree(repository, true)?;
let source = validate_source(&files, name)?;
let identity = unique_id("g");
let staging = unique_directory(root, &format!(".{name}.migration"))?;
let staging_name = staging
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| Error::new("migration", "migration staging name is not UTF-8"))?
.to_owned();
let generations = staging.join(GENERATIONS_DIRECTORY);
fs::create_dir(&generations)
.map_err(|error| Error::io("create migration generations", &generations, error))?;
let generation = generations.join(&identity);
fs::create_dir(&generation)
.map_err(|error| Error::io("create migration generation", &generation, error))?;
materialize_source(&generation, &source.files)?;
FsFile::create(staging.join(LOCK_FILE))
.map_err(|error| Error::io("create staged migration lock", &staging, error))?;
let marker = format!("{MIGRATION_MAGIC}\n{identity}\n{staging_name}\n");
write_sync(&repository.join(MIGRATION_FILE), marker.as_bytes())?;
fs::rename(
staging.join(GENERATIONS_DIRECTORY),
repository.join(GENERATIONS_DIRECTORY),
)
.map_err(|error| Error::io("install migrated generations", repository, error))?;
fs::rename(staging.join(LOCK_FILE), repository.join(LOCK_FILE))
.map_err(|error| Error::io("install migrated lock", repository, error))?;
replace_head(repository, &identity)?;
let _ = fs::remove_dir_all(&staging);
fs::remove_file(repository.join(MIGRATION_FILE))
.map_err(|error| Error::io("complete migration", repository, error))?;
Ok(())
}
fn recover_migration(repository: &Path) -> Result<()> {
let marker_path = repository.join(MIGRATION_FILE);
let marker = fs::read_to_string(&marker_path)
.map_err(|error| Error::io("read migration marker", &marker_path, error))?;
let mut lines = marker.lines();
if lines.next() != Some(MIGRATION_MAGIC) {
return Err(Error::new("migration", "invalid migration marker magic"));
}
let identity = lines
.next()
.ok_or_else(|| Error::new("migration", "migration marker lacks identity"))?;
validate_identity(identity)?;
let staging_name = lines
.next()
.ok_or_else(|| Error::new("migration", "migration marker lacks staging name"))?;
if lines.next().is_some()
|| !staging_name.starts_with('.')
|| staging_name.contains(['/', '\\'])
|| staging_name.contains("..")
{
return Err(Error::new("migration", "invalid migration marker shape"));
}
let parent = repository
.parent()
.ok_or_else(|| Error::new("migration", "repository has no parent"))?;
let staging = parent.join(staging_name);
if repository.join(HEAD_FILE).exists() {
if read_head(repository)? != identity
|| !repository.join(LOCK_FILE).is_file()
|| !repository
.join(GENERATIONS_DIRECTORY)
.join(identity)
.is_dir()
{
return Err(Error::new(
"migration",
"committed migration does not match its recovery marker",
));
}
let _ = fs::remove_dir_all(staging);
fs::remove_file(marker_path)
.map_err(|error| Error::io("finalize migration recovery", repository, error))?;
return Ok(());
}
if repository.join(GENERATIONS_DIRECTORY).exists() {
fs::remove_dir_all(repository.join(GENERATIONS_DIRECTORY))
.map_err(|error| Error::io("roll back migrated generations", repository, error))?;
}
if repository.join(LOCK_FILE).exists() {
fs::remove_file(repository.join(LOCK_FILE))
.map_err(|error| Error::io("roll back migrated lock", repository, error))?;
}
let _ = fs::remove_dir_all(staging);
fs::remove_file(marker_path)
.map_err(|error| Error::io("roll back migration marker", repository, error))?;
Ok(())
}
fn read_snapshot(repository: &Path, name: &str) -> Result<Snapshot> {
let identity = read_head(repository)?;
let generation = repository.join(GENERATIONS_DIRECTORY).join(&identity);
let metadata = fs::symlink_metadata(&generation)
.map_err(|error| Error::io("inspect current generation", &generation, error))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(Error::new(
"invalid_repository",
"current generation is not a regular directory",
));
}
let source = validate_source(&read_tree(&generation, false)?, name)?;
Ok(Snapshot {
repository: repository.to_path_buf(),
identity,
files: source.files,
})
}
fn read_head(repository: &Path) -> Result<String> {
let path = repository.join(HEAD_FILE);
let contents = fs::read_to_string(&path)
.map_err(|error| Error::io("read repository head", &path, error))?;
let identity = contents.trim();
if identity.is_empty() || contents.lines().count() != 1 {
return Err(Error::new("invalid_repository", "invalid repository HEAD"));
}
validate_identity(identity)?;
Ok(identity.to_owned())
}
fn validate_identity(identity: &str) -> Result<()> {
if !identity.starts_with("g-")
|| !identity[2..]
.bytes()
.all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
{
return Err(Error::new(
"invalid_repository",
format!("invalid generation identity {identity:?}"),
));
}
Ok(())
}
fn replace_head(repository: &Path, identity: &str) -> Result<()> {
let temporary = repository.join(format!(".HEAD.{}.tmp", unique_id("h")));
write_sync(&temporary, format!("{identity}\n").as_bytes())?;
fs::rename(&temporary, repository.join(HEAD_FILE))
.map_err(|error| Error::io("replace repository head", repository, error))
}
fn read_tree(root: &Path, omit_root_lockfile: bool) -> Result<Vec<File>> {
let mut files = Vec::new();
collect_tree(root, root, omit_root_lockfile, &mut files)?;
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
fn collect_tree(
root: &Path,
directory: &Path,
omit_root_lockfile: bool,
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 name = entry
.file_name()
.into_string()
.map_err(|_| Error::new("invalid_source", "source path is not UTF-8"))?;
if directory == root && omit_root_lockfile && name == "Cargo.lock" {
continue;
}
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(
"invalid_source",
format!("symlink source entry is forbidden: {}", path.display()),
));
}
if metadata.is_dir() {
collect_tree(root, &path, omit_root_lockfile, files)?;
} else if metadata.is_file() {
let relative = path
.strip_prefix(root)
.map_err(|_| Error::new("invalid_source", "source path escaped its root"))?;
let relative = relative
.components()
.map(|component| component.as_os_str().to_str())
.collect::<Option<Vec<_>>>()
.ok_or_else(|| Error::new("invalid_source", "source path is not UTF-8"))?
.join("/");
let contents = fs::read_to_string(&path)
.map_err(|error| Error::io("read UTF-8 source file", &path, error))?;
files.push(File {
path: relative,
contents,
});
} else {
return Err(Error::new(
"invalid_source",
format!("special source entry is forbidden: {}", path.display()),
));
}
}
Ok(())
}
fn root_path(root: &Path, create: bool) -> Result<PathBuf> {
if create {
fs::create_dir_all(root)
.map_err(|error| Error::io("create managed-binaries root", root, error))?;
}
let canonical = fs::canonicalize(root)
.map_err(|error| Error::io("canonicalize managed-binaries root", root, error))?;
if !fs::metadata(&canonical)
.map_err(|error| Error::io("inspect managed-binaries root", &canonical, error))?
.is_dir()
{
return Err(Error::new(
"invalid_root",
format!(
"managed-binaries root is not a directory: {}",
canonical.display()
),
));
}
Ok(canonical)
}
fn write_sync(path: &Path, bytes: &[u8]) -> Result<()> {
let mut file = FsFile::create(path).map_err(|error| Error::io("create file", path, error))?;
file.write_all(bytes)
.map_err(|error| Error::io("write file", path, error))?;
file.sync_all()
.map_err(|error| Error::io("sync file", path, error))
}
struct FileLock(FsFile);
fn lock_file(path: &Path) -> Result<FileLock> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|error| Error::io("open lock file", path, error))?;
file.lock()
.map_err(|error| Error::io("lock file", path, error))?;
Ok(FileLock(file))
}
impl Drop for FileLock {
fn drop(&mut self) {
let _ = self.0.unlock();
}
}
fn unique_directory(parent: &Path, label: &str) -> Result<PathBuf> {
for _ in 0..100 {
let path = parent.join(format!("{label}-{}", 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}-{nanos:x}-{:x}-{counter:x}", std::process::id())
}