#[cfg(test)]
mod tests;
use std::collections::HashSet;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const MANIFEST_NAME: &str = "manifest.toml";
pub const BUILTIN_SOURCE: &str = "builtin";
pub static REGISTRY: &[Bundle] = &[];
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug)]
pub struct Bundle {
pub manifest: &'static str,
pub files: &'static [BundleFile],
}
#[derive(Debug)]
pub struct BundleFile {
pub path: &'static str,
pub contents: &'static str,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub name: String,
pub description: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Ledger {
version: String,
bundles: Vec<Installed>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Installed {
name: String,
source: String,
files: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Convergence {
Created,
Current,
Stale {
installed: String,
},
}
pub fn converge(
registry: &[Bundle],
skills_dir: &Path,
ledger_path: &Path,
) -> Result<Convergence, AssetsError> {
let io = |path: &Path| {
let path = path.to_path_buf();
move |source| AssetsError::Io { path, source }
};
fs::create_dir_all(skills_dir).map_err(io(skills_dir))?;
if let Some(text) = read_if_exists(ledger_path)? {
let ledger: Ledger = toml::from_str(&text).map_err(|source| AssetsError::Ledger {
path: ledger_path.to_path_buf(),
source,
})?;
if ledger.version == VERSION {
return Ok(Convergence::Current);
}
return Ok(Convergence::Stale {
installed: ledger.version,
});
}
if ledger_path.symlink_metadata().is_ok() {
return Err(AssetsError::Occupied {
path: ledger_path.to_path_buf(),
});
}
let mut writes: Vec<(PathBuf, &str)> = Vec::new();
let mut entries = Vec::new();
for bundle in registry {
let manifest: Manifest =
toml::from_str(bundle.manifest).map_err(|source| AssetsError::Manifest { source })?;
if !is_plain_relative(&manifest.name) || manifest.name.contains('/') {
return Err(AssetsError::Packaging {
detail: format!(
"the bundle name `{}` is not a plain directory name",
manifest.name
),
});
}
let dir = skills_dir.join(&manifest.name);
let mut files = vec![MANIFEST_NAME.to_owned()];
writes.push((dir.join(MANIFEST_NAME), bundle.manifest));
for file in bundle.files {
if !is_plain_relative(file.path) {
return Err(AssetsError::Packaging {
detail: format!(
"the path `{}` in bundle `{}` is not plain and relative",
file.path, manifest.name
),
});
}
files.push(file.path.to_owned());
writes.push((dir.join(file.path), file.contents));
}
entries.push(Installed {
name: manifest.name,
source: BUILTIN_SOURCE.to_owned(),
files,
});
}
let mut planned = HashSet::new();
for (path, _) in &writes {
if !planned.insert(path) {
return Err(AssetsError::Packaging {
detail: format!("two bundles collide at {}", path.display()),
});
}
if path.symlink_metadata().is_ok() {
return Err(AssetsError::Occupied { path: path.clone() });
}
}
for (path, contents) in &writes {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(io(parent))?;
}
create_atomically(path, contents).map_err(io(path))?;
}
let ledger = Ledger {
version: VERSION.to_owned(),
bundles: entries,
};
let text = toml::to_string(&ledger).map_err(|source| AssetsError::Encode { source })?;
create_atomically(ledger_path, &text).map_err(io(ledger_path))?;
Ok(Convergence::Created)
}
fn is_plain_relative(path: &str) -> bool {
!path.contains('\\')
&& path
.split('/')
.all(|segment| !segment.is_empty() && segment != "." && segment != "..")
}
pub(crate) fn create_atomically(path: &Path, contents: &str) -> io::Result<()> {
let dir = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
io::Write::write_all(&mut tmp, contents.as_bytes())?;
tmp.persist_noclobber(path).map_err(|e| e.error)?;
Ok(())
}
fn read_if_exists(path: &Path) -> Result<Option<String>, AssetsError> {
match fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(AssetsError::Io {
path: path.to_path_buf(),
source,
}),
}
}
#[derive(Debug)]
pub enum AssetsError {
Io {
path: PathBuf,
source: io::Error,
},
Ledger {
path: PathBuf,
source: toml::de::Error,
},
Manifest {
source: toml::de::Error,
},
Encode {
source: toml::ser::Error,
},
Packaging {
detail: String,
},
Occupied {
path: PathBuf,
},
}
impl fmt::Display for AssetsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AssetsError::Io { path, source } => {
write!(f, "could not access {}: {source}", path.display())
}
AssetsError::Ledger { path, source } => write!(
f,
"the ledger at {} is invalid; it records which files are fairway's, \
so nothing was touched:\n{source}",
path.display()
),
AssetsError::Manifest { source } => {
write!(f, "a shipped bundle manifest is invalid:\n{source}")
}
AssetsError::Encode { source } => {
write!(f, "the ledger could not be encoded: {source}")
}
AssetsError::Packaging { detail } => {
write!(f, "a shipped bundle is invalid: {detail}")
}
AssetsError::Occupied { path } => write!(
f,
"{} already exists but is not fairway's; move it away and retry, \
or delete it if it is a leftover from an interrupted install",
path.display()
),
}
}
}
impl std::error::Error for AssetsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AssetsError::Io { source, .. } => Some(source),
AssetsError::Ledger { source, .. } | AssetsError::Manifest { source } => Some(source),
AssetsError::Encode { source } => Some(source),
AssetsError::Packaging { .. } | AssetsError::Occupied { .. } => None,
}
}
}