use crate::model::{
DOCUMENTATION_PATH, File, MANIFEST_PATH, MAX_FILE_BYTES, MAX_FILES, MAX_TREE_BYTES,
ValidatedTree, validate_module_name, validate_tree,
};
use crate::storage;
use crate::{Error, Lib, Result};
use fs2::FileExt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;
struct HeadReplaceFailure {
error: Error,
head_replaced: bool,
}
pub(crate) fn create(web_libs_root: &Path, publications_root: &Path, name: &str) -> Result<Lib> {
validate_module_name(name)?;
let root = root_path(web_libs_root, true)?;
let publications_root = crate::publication::root_path(publications_root, true)?;
ensure_separate_roots(&root, &publications_root)?;
let _root_lock = lock_file(&root.join(".kcode-web-libs.lock"))?;
let library_root = root.join(name);
match fs::symlink_metadata(&library_root) {
Ok(_) => {
return Err(Error::new(format!(
"already_exists: managed web library `{name}` already exists"
)));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect library destination", error)),
}
let mut files = initial_files(name);
let validated = validate_tree(&files, name)?;
files.sort_by(|left, right| left.path.cmp(&right.path));
let staging = root.join(format!(".create-{}", Uuid::new_v4()));
fs::create_dir(&staging).map_err(|error| Error::io("create staged managed library", error))?;
let creation = (|| {
let lock_path = staging.join(".lock");
let lock_file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&lock_path)
.map_err(|error| Error::io("create repository lock file", error))?;
lock_file
.sync_all()
.map_err(|error| Error::io("sync repository lock file", error))?;
let generations = staging.join("generations");
fs::create_dir(&generations)
.map_err(|error| Error::io("create generations directory", error))?;
let generation = Uuid::new_v4().to_string();
let generation_root = generations.join(&generation);
fs::create_dir(&generation_root)
.map_err(|error| Error::io("create initial generation", error))?;
materialize_source(&generation_root, &files)?;
storage::sync_tree_directories(&generation_root)?;
storage::sync_directory(&generations)?;
let head_path = staging.join("HEAD");
let mut head = OpenOptions::new()
.create_new(true)
.write(true)
.open(&head_path)
.map_err(|error| Error::io("create initial repository HEAD", error))?;
head.write_all(format!("{generation}\n").as_bytes())
.map_err(|error| Error::io("write initial repository HEAD", error))?;
head.sync_all()
.map_err(|error| Error::io("sync initial repository HEAD", error))?;
drop(head);
storage::sync_directory(&staging)?;
fs::rename(&staging, &library_root)
.map_err(|error| Error::io("commit new managed library", error))?;
if let Err(error) = storage::sync_directory(&root) {
return Err(Error::new(format!(
"source_create_commit_uncertain: managed web library `{name}` was installed but \
the source root could not be synchronized: {error}"
)));
}
Ok(generation)
})();
let generation = match creation {
Ok(generation) => generation,
Err(error) => {
if fs::remove_dir_all(&staging).is_ok() {
let _ = storage::sync_directory(&root);
}
return Err(error);
}
};
Ok(Lib::from_parts(
root,
publications_root,
name.to_owned(),
generation,
files_with_validated_order(files, &validated),
))
}
pub(crate) fn open(web_libs_root: &Path, publications_root: &Path, name: &str) -> Result<Lib> {
validate_module_name(name)?;
let root = root_path(web_libs_root, false)?;
let publications_root = crate::publication::root_path(publications_root, true)?;
ensure_separate_roots(&root, &publications_root)?;
let library_root = checked_repository(&root.join(name), name)?;
ensure_complete_layout(&library_root, name)?;
let lock = lock_file(&library_root.join(".lock"))?;
ensure_complete_layout(&library_root, name)?;
let generation = read_head(&library_root)?;
let files = read_generation(&library_root, &generation)?;
validate_tree(&files, name)?;
drop(lock);
Ok(Lib::from_parts(
root,
publications_root,
name.to_owned(),
generation,
files,
))
}
pub(crate) fn docs(web_libs_root: &Path, name: &str) -> Result<(String, String)> {
validate_module_name(name)?;
let root = root_path(web_libs_root, false)?;
let library_root = checked_repository(&root.join(name), name)?;
ensure_complete_layout(&library_root, name)?;
let lock = lock_file(&library_root.join(".lock"))?;
ensure_complete_layout(&library_root, name)?;
let generation = read_head(&library_root)?;
let generation_root = checked_generation(&library_root, &generation)?;
let manifest_contents = read_regular_utf8(&generation_root.join(MANIFEST_PATH))?;
let documentation = read_regular_utf8(&generation_root.join(DOCUMENTATION_PATH))?;
let manifest = crate::model::validate_manifest(&manifest_contents, name)?;
drop(lock);
Ok((manifest.version, documentation))
}
pub(crate) fn write(
root: &Path,
name: &str,
expected_generation: &str,
files: &[File],
) -> Result<String> {
validate_module_name(name)?;
validate_generation(expected_generation)?;
validate_tree(files, name)?;
let library_root = root.join(name);
let lock = lock_file(&library_root.join(".lock"))?;
ensure_complete_layout(&library_root, name)?;
let current = read_head(&library_root)?;
if current != expected_generation {
return Err(stale_error(expected_generation, ¤t));
}
let generation = write_generation(&library_root, files)?;
if let Err(failure) = replace_head(&library_root, &generation) {
if !failure.head_replaced {
let generations_root = library_root.join("generations");
let _ = fs::remove_dir_all(generations_root.join(generation.as_str()));
let _ = storage::sync_directory(&generations_root);
}
return Err(failure.error);
}
let generations_root = library_root.join("generations");
if fs::remove_dir_all(generations_root.join(¤t)).is_ok() {
let _ = storage::sync_directory(&generations_root);
}
drop(lock);
Ok(generation)
}
fn initial_files(name: &str) -> Vec<File> {
let manifest = serde_json::json!({
"name": name,
"version": "0.1.0",
"entry": "index.js",
"tests": "tests.js"
});
vec![
File::new(
MANIFEST_PATH,
format!(
"{}\n",
serde_json::to_string_pretty(&manifest)
.expect("serializing a fixed manifest cannot fail")
),
),
File::new(
DOCUMENTATION_PATH,
format!("# {name}\n\nA buildless ES module managed by `kcode-web-libs`.\n"),
),
File::new(
"index.js",
"export function mount(root) {\n root.textContent = \"Hello from a kcode web library.\";\n}\n",
),
File::new(
"tests.js",
"export async function runTests() {\n const module = await import(\"./index.js\");\n if (typeof module.mount !== \"function\") {\n throw new Error(\"index.js must export mount\");\n }\n}\n",
),
]
}
fn files_with_validated_order(mut files: Vec<File>, _validated: &ValidatedTree) -> Vec<File> {
files.sort_by(|left, right| left.path.cmp(&right.path));
files
}
fn lock_file(path: &Path) -> Result<fs::File> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(Error::new(format!(
"unsafe_source: lock path is not a regular file: {}",
path.display()
)));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect lock file", error)),
}
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)
.map_err(|error| Error::io("open library lock", error))?;
if !file
.metadata()
.map_err(|error| Error::io("inspect opened lock file", error))?
.is_file()
{
return Err(Error::new(format!(
"unsafe_source: opened lock path is not a regular file: {}",
path.display()
)));
}
FileExt::lock_exclusive(&file).map_err(|error| Error::io("lock library", error))?;
Ok(file)
}
fn ensure_complete_layout(library_root: &Path, name: &str) -> Result<()> {
let head = fs::symlink_metadata(library_root.join("HEAD"));
let lock = fs::symlink_metadata(library_root.join(".lock"));
let generations = fs::symlink_metadata(library_root.join("generations"));
match (head, lock, generations) {
(Ok(head), Ok(lock), Ok(generations))
if head.file_type().is_file() && lock.file_type().is_file() && generations.is_dir() =>
{
Ok(())
}
(Err(head), Err(lock), Err(generations))
if head.kind() == std::io::ErrorKind::NotFound
&& lock.kind() == std::io::ErrorKind::NotFound
&& generations.kind() == std::io::ErrorKind::NotFound =>
{
Err(Error::new(format!(
"invalid_repository: managed web library `{name}` is uninitialized"
)))
}
(Err(error), _, _) if error.kind() != std::io::ErrorKind::NotFound => {
Err(Error::io("inspect HEAD", error))
}
(_, Err(error), _) if error.kind() != std::io::ErrorKind::NotFound => {
Err(Error::io("inspect repository lock", error))
}
(_, _, Err(error)) if error.kind() != std::io::ErrorKind::NotFound => {
Err(Error::io("inspect generations", error))
}
_ => Err(Error::new(format!(
"invalid_repository: managed web library `{name}` has an incomplete layout"
))),
}
}
fn root_path(path: &Path, create: bool) -> Result<PathBuf> {
if create {
storage::create_dir_all_durable(path, "create managed-library root")?;
}
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)
};
let metadata = fs::symlink_metadata(&absolute)
.map_err(|error| Error::io("inspect managed-library root", error))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(Error::new(format!(
"unsafe_source: managed-library root is not a regular directory: {}",
absolute.display()
)));
}
fs::canonicalize(&absolute)
.map_err(|error| Error::io("canonicalize managed-library root", error))
}
fn ensure_separate_roots(source: &Path, publications: &Path) -> Result<()> {
if source.starts_with(publications) || publications.starts_with(source) {
return Err(Error::new(
"invalid_publication_storage: source and publication roots must be disjoint",
));
}
Ok(())
}
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(format!(
"unsafe_source: managed web library `{name}` is a symlink"
))),
Ok(metadata) if metadata.is_dir() => Ok(path.to_path_buf()),
Ok(_) => Err(Error::new(format!(
"invalid_repository: managed web library `{name}` is not a directory"
))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(Error::new(format!(
"not_found: managed web library `{name}` does not exist"
))),
Err(error) => Err(Error::io("inspect managed web library", error)),
}
}
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(format!(
"unsafe_path: source path has no parent: `{}`",
file.path
))
})?;
fs::create_dir_all(parent).map_err(|error| Error::io("create source directory", error))?;
let mut output = OpenOptions::new()
.create_new(true)
.write(true)
.open(&destination)
.map_err(|error| Error::io("create source file", error))?;
output
.write_all(file.contents.as_bytes())
.map_err(|error| Error::io("write source file", error))?;
output
.sync_all()
.map_err(|error| Error::io("sync source file", error))?;
}
Ok(())
}
fn read_head(library_root: &Path) -> Result<String> {
let path = library_root.join("HEAD");
let value = read_regular_utf8(&path)?;
let generation = value.trim_end_matches(['\r', '\n']);
if generation.len() != value.trim().len() || generation != value.trim() {
return Err(Error::new(
"invalid_repository: HEAD contains surrounding whitespace",
));
}
validate_generation(generation)?;
Ok(generation.to_owned())
}
fn validate_generation(generation: &str) -> Result<()> {
let parsed = Uuid::parse_str(generation)
.map_err(|_| Error::new("invalid_repository: invalid generation identifier"))?;
if parsed.to_string() != generation {
return Err(Error::new(
"invalid_repository: generation identifier is not canonical",
));
}
Ok(())
}
fn stale_error(expected: &str, current: &str) -> Error {
Error::new(format!(
"stale_snapshot: expected generation `{expected}`, current generation is `{current}`; reopen and reconcile"
))
}
fn write_generation(library_root: &Path, files: &[File]) -> Result<String> {
let generation = Uuid::new_v4().to_string();
let generations_root = library_root.join("generations");
let staging = generations_root.join(format!(".staging-{generation}"));
let final_path = generations_root.join(&generation);
fs::create_dir(&staging).map_err(|error| Error::io("create staged generation", error))?;
let staging_write = (|| {
materialize_source(&staging, files)?;
storage::sync_tree_directories(&staging)
})();
if let Err(error) = staging_write {
remove_staged_generation(&staging, &generations_root);
return Err(error);
}
if let Err(error) = fs::rename(&staging, &final_path) {
remove_staged_generation(&staging, &generations_root);
return Err(Error::io("commit source generation", error));
}
if let Err(error) = storage::sync_directory(&generations_root) {
return Err(Error::new(format!(
"source_generation_commit_uncertain: generation `{generation}` was installed but \
its parent directory could not be synchronized: {error}"
)));
}
Ok(generation)
}
fn replace_head(
library_root: &Path,
generation: &str,
) -> std::result::Result<(), HeadReplaceFailure> {
let temporary = library_root.join(format!(".HEAD-{}.tmp", Uuid::new_v4()));
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temporary)
.map_err(|error| HeadReplaceFailure {
error: Error::io("create temporary HEAD", error),
head_replaced: false,
})?;
let preparation = file
.write_all(format!("{generation}\n").as_bytes())
.map_err(|error| Error::io("write temporary HEAD", error))
.and_then(|()| {
file.sync_all()
.map_err(|error| Error::io("sync temporary HEAD", error))
});
drop(file);
if let Err(error) = preparation {
remove_temporary_head(&temporary, library_root);
return Err(HeadReplaceFailure {
error,
head_replaced: false,
});
}
if let Err(error) = fs::rename(&temporary, library_root.join("HEAD")) {
remove_temporary_head(&temporary, library_root);
return Err(HeadReplaceFailure {
error: Error::io("replace HEAD", error),
head_replaced: false,
});
}
storage::sync_directory(library_root).map_err(|error| HeadReplaceFailure {
error: Error::new(format!(
"source_commit_uncertain: HEAD was replaced with generation `{generation}` but the \
library directory could not be synchronized: {error}"
)),
head_replaced: true,
})?;
Ok(())
}
fn remove_staged_generation(staging: &Path, generations_root: &Path) {
if fs::remove_dir_all(staging).is_ok() {
let _ = storage::sync_directory(generations_root);
}
}
fn remove_temporary_head(temporary: &Path, library_root: &Path) {
if fs::remove_file(temporary).is_ok() {
let _ = storage::sync_directory(library_root);
}
}
fn read_generation(library_root: &Path, generation: &str) -> Result<Vec<File>> {
validate_generation(generation)?;
let root = checked_generation(library_root, generation)?;
let mut files = Vec::new();
let mut total_bytes = 0usize;
read_directory(&root, &root, &mut files, &mut total_bytes)?;
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
fn checked_generation(library_root: &Path, generation: &str) -> Result<PathBuf> {
let path = library_root.join("generations").join(generation);
let metadata =
fs::symlink_metadata(&path).map_err(|error| Error::io("inspect generation", error))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(Error::new(
"invalid_repository: generation is not a regular directory",
));
}
Ok(path)
}
fn read_regular_utf8(path: &Path) -> Result<String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| Error::io("inspect required source file", error))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(Error::new(format!(
"unsafe_source: required source is not a regular file: {}",
path.display()
)));
}
let bytes = fs::read(path).map_err(|error| Error::io("read source file", error))?;
String::from_utf8(bytes).map_err(|_| {
Error::new(format!(
"unsafe_source: source file is not UTF-8: {}",
path.display()
))
})
}
fn read_directory(
root: &Path,
directory: &Path,
files: &mut Vec<File>,
total_bytes: &mut usize,
) -> Result<()> {
let mut entries = fs::read_dir(directory)
.map_err(|error| Error::io("read generation directory", error))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| Error::io("read generation entry", 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 generation entry", error))?;
if metadata.file_type().is_symlink() {
return Err(Error::new(
"invalid_repository: source generations may not contain symlinks",
));
}
if metadata.is_dir() {
read_directory(root, &path, files, total_bytes)?;
continue;
}
if !metadata.is_file() {
return Err(Error::new(
"invalid_repository: source generations may contain only regular files",
));
}
if files.len() >= MAX_FILES {
return Err(Error::invalid_source(format!(
"a web library may contain at most {MAX_FILES} files"
)));
}
let length = usize::try_from(metadata.len())
.map_err(|_| Error::invalid_source("source file length does not fit usize"))?;
if length > MAX_FILE_BYTES {
return Err(Error::invalid_source(format!(
"a source file exceeds the {MAX_FILE_BYTES} byte limit"
)));
}
*total_bytes = total_bytes
.checked_add(length)
.ok_or_else(|| Error::invalid_source("source byte count overflow"))?;
if *total_bytes > MAX_TREE_BYTES {
return Err(Error::invalid_source(format!(
"the source tree exceeds the {MAX_TREE_BYTES} byte limit"
)));
}
let relative = path
.strip_prefix(root)
.map_err(|_| Error::new("invalid_repository: source path escaped generation"))?;
let relative = relative
.to_str()
.ok_or_else(|| Error::new("invalid_repository: source path is not valid UTF-8"))?;
let relative = relative.replace(std::path::MAIN_SEPARATOR, "/");
crate::model::validate_file_path(&relative)?;
let bytes = fs::read(&path).map_err(|error| Error::io("read source file", error))?;
let contents = String::from_utf8(bytes)
.map_err(|_| Error::invalid_source(format!("`{relative}` is not valid UTF-8")))?;
files.push(File::new(relative, contents));
}
Ok(())
}