use crate::model::{File, ValidatedTree, validate_module_name, validate_tree};
use crate::storage;
use crate::{Error, Result};
use fs2::FileExt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;
pub(crate) fn publish(
root: &Path,
name: &str,
files: &[File],
validated: &ValidatedTree,
) -> Result<()> {
validate_module_name(name)?;
let supplied = validate_tree(files, name)?;
if supplied.source_sha256 != validated.source_sha256 || supplied.manifest != validated.manifest
{
return Err(Error::new(
"invalid_source: publication source does not match the validated source",
));
}
let module_parent =
ensure_durable_child_directory(root, "module", "published module parent directory")?;
let module_root =
ensure_durable_child_directory(&module_parent, name, "published module directory")?;
let locks_root = ensure_durable_child_directory(root, ".locks", "publication lock directory")?;
let module_lock_root =
ensure_durable_child_directory(&locks_root, name, "module publication lock directory")?;
let lock_path = module_lock_root.join(".lock");
match fs::symlink_metadata(&lock_path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(Error::new(
"invalid_publication_storage: publication lock is not a regular file",
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect publication lock", error)),
}
let lock = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.map_err(|error| Error::io("open publication lock", error))?;
if !lock
.metadata()
.map_err(|error| Error::io("inspect opened publication lock", error))?
.is_file()
{
return Err(Error::new(
"invalid_publication_storage: opened publication lock is not a regular file",
));
}
FileExt::lock_exclusive(&lock).map_err(|error| Error::io("lock module publication", error))?;
let version_directory = format!("v{}", validated.manifest.version);
let destination = module_root.join(&version_directory);
if path_exists(&destination)? {
return Err(Error::new(format!(
"already_published: `{name}` version `{}` already exists",
validated.manifest.version
)));
}
let staging = module_root.join(format!(".staging-{}", Uuid::new_v4()));
fs::create_dir(&staging)
.map_err(|error| Error::io("create staged publication directory", error))?;
if let Err(error) = write_publication(&staging, files) {
remove_staging(&staging, &module_root);
return Err(error);
}
match path_exists(&destination) {
Ok(false) => {}
Ok(true) => {
remove_staging(&staging, &module_root);
return Err(Error::new(format!(
"already_published: `{name}` version `{}` already exists",
validated.manifest.version
)));
}
Err(error) => {
remove_staging(&staging, &module_root);
return Err(error);
}
}
if let Err(error) = fs::rename(&staging, &destination) {
remove_staging(&staging, &module_root);
return Err(Error::io("install published module", error));
}
if let Err(error) = storage::sync_directory(&module_root) {
return Err(Error::new(format!(
"publish.commit_uncertain: `{name}` version `{}` was installed but the published \
module directory could not be synchronized: {error}",
validated.manifest.version
)));
}
drop(lock);
Ok(())
}
pub(crate) fn root_path(path: &Path, create: bool) -> Result<PathBuf> {
if create {
storage::create_dir_all_durable(path, "create publication 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 publication root", error))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(Error::new(format!(
"invalid_publication_storage: publication root is not a regular directory: {}",
absolute.display()
)));
}
fs::canonicalize(&absolute).map_err(|error| Error::io("canonicalize publication root", error))
}
fn ensure_durable_child_directory(parent: &Path, child: &str, label: &str) -> Result<PathBuf> {
ensure_regular_directory(parent, "publication directory parent")?;
let path = parent.join(child);
match fs::symlink_metadata(&path) {
Ok(_) => {
ensure_regular_directory(&path, label)?;
return Ok(path);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io(&format!("inspect {label}"), error)),
}
match fs::create_dir(&path) {
Ok(()) => {
ensure_regular_directory(&path, label)?;
storage::sync_directory(parent)?;
Ok(path)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
ensure_regular_directory(&path, label)?;
Ok(path)
}
Err(error) => Err(Error::io(&format!("create {label}"), error)),
}
}
fn write_publication(root: &Path, files: &[File]) -> Result<()> {
for file in files {
let destination = root.join(&file.path);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|error| Error::io("create published source directory", error))?;
}
let mut output = OpenOptions::new()
.create_new(true)
.write(true)
.open(&destination)
.map_err(|error| Error::io("create published source file", error))?;
output
.write_all(file.contents.as_bytes())
.map_err(|error| Error::io("write published source file", error))?;
output
.sync_all()
.map_err(|error| Error::io("sync published source file", error))?;
}
storage::sync_tree_directories(root)
}
fn remove_staging(staging: &Path, module_root: &Path) {
if fs::remove_dir_all(staging).is_ok() {
let _ = storage::sync_directory(module_root);
}
}
fn ensure_regular_directory(path: &Path, label: &str) -> Result<()> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| Error::io(&format!("inspect {label}"), error))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(Error::new(format!(
"invalid_publication_storage: {label} is not a regular directory"
)));
}
Ok(())
}
fn path_exists(path: &Path) -> Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(Error::io("inspect publication destination", error)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{File, create};
use tempfile::TempDir;
fn source_root(root: &TempDir) -> PathBuf {
root.path().join("source")
}
fn publications_root(root: &TempDir) -> PathBuf {
root.path().join("publications")
}
#[test]
fn installs_one_immutable_exact_version() {
let root = TempDir::new().unwrap();
let publications = publications_root(&root);
let library = create(source_root(&root), &publications, "example").unwrap();
let validated = validate_tree(&library.files, "example").unwrap();
publish(&publications, "example", &library.files, &validated).unwrap();
assert_eq!(
fs::read_to_string(publications.join("module/example/v0.1.0/index.js")).unwrap(),
library
.files
.iter()
.find(|file| file.path == "index.js")
.unwrap()
.contents
);
let error = publish(&publications, "example", &library.files, &validated).unwrap_err();
assert!(error.to_string().starts_with("already_published:"));
}
#[test]
fn rejects_source_that_does_not_match_the_validated_tree() {
let root = TempDir::new().unwrap();
let publications = publications_root(&root);
let library = create(source_root(&root), &publications, "example").unwrap();
let validated = validate_tree(&library.files, "example").unwrap();
let mut changed = library.files.clone();
changed.push(File::new("extra.js", "export {};"));
let error = publish(&publications, "example", &changed, &validated).unwrap_err();
assert!(error.to_string().starts_with("invalid_source:"));
assert!(!publications.join("module/example/v0.1.0").exists());
}
#[test]
fn installs_distinct_versions_as_complete_independent_copies() {
let root = TempDir::new().unwrap();
let publications = publications_root(&root);
let library = create(source_root(&root), &publications, "example").unwrap();
let first = validate_tree(&library.files, "example").unwrap();
publish(&publications, "example", &library.files, &first).unwrap();
let mut second_files = library.files.clone();
second_files
.iter_mut()
.find(|file| file.path == "kcode-web.json")
.unwrap()
.contents =
r#"{"name":"example","version":"0.2.0","entry":"index.js","tests":"tests.js"}"#
.to_owned();
second_files
.iter_mut()
.find(|file| file.path == "index.js")
.unwrap()
.contents = "export const release = \"second\";".to_owned();
let second = validate_tree(&second_files, "example").unwrap();
publish(&publications, "example", &second_files, &second).unwrap();
assert!(
publications
.join("module/example/v0.1.0/tests.js")
.is_file()
);
assert_eq!(
fs::read_to_string(publications.join("module/example/v0.2.0/index.js")).unwrap(),
"export const release = \"second\";"
);
assert_ne!(
fs::read_to_string(publications.join("module/example/v0.1.0/index.js")).unwrap(),
fs::read_to_string(publications.join("module/example/v0.2.0/index.js")).unwrap()
);
}
#[cfg(unix)]
#[test]
fn rejects_a_symlinked_module_parent_without_creating_outside_the_root() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let publications = publications_root(&root);
let library = create(source_root(&root), &publications, "example").unwrap();
let validated = validate_tree(&library.files, "example").unwrap();
symlink(outside.path(), publications.join("module")).unwrap();
let error = publish(&publications, "example", &library.files, &validated).unwrap_err();
assert!(
error
.to_string()
.starts_with("invalid_publication_storage:")
);
assert!(!outside.path().join("example").exists());
}
}