use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use iroh::Endpoint;
use iroh_blobs::{Hash, ticket::BlobTicket};
use semver::Version;
use uuid::Uuid;
use crate::{
error::{Result, SyncwebError},
folder::{CollectionManifest, CollectionState},
storage::node_db::NodeDatabase,
};
#[derive(Clone, Debug)]
pub struct PackageManager {
root: PathBuf,
db: NodeDatabase,
}
impl PackageManager {
#[must_use]
pub fn new(root: impl Into<PathBuf>, db: NodeDatabase) -> Self {
Self { root: root.into(), db }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn state(&self) -> Result<CollectionState> {
self.db.get_collection_state()
}
pub fn available_versions(&self) -> Result<BTreeMap<Uuid, Version>> {
self.state()?
.installed
.into_iter()
.map(|(collection_id, installed)| {
let version = Version::parse(&installed.current).map_err(|error| {
SyncwebError::InvalidConfig(format!(
"installed collection {collection_id} has an invalid version: {error}"
))
})?;
Ok((collection_id, version))
})
.collect()
}
pub fn install(&self, manifest: &CollectionManifest, source: impl AsRef<Path>) -> Result<()> {
manifest.validate()?;
verify_directory(manifest, source.as_ref())?;
let manifest_hash = manifest.blob_id()?;
let collection_dir = self.root.join(manifest.collection_id.to_string());
fs::create_dir_all(&collection_dir)
.map_err(|error| SyncwebError::operation("failed to create package directory", error))?;
let version_dir = collection_dir.join(&manifest.version);
if version_dir.exists() {
return Err(SyncwebError::InvalidConfig(format!(
"collection version {} is already installed",
manifest.version
)));
}
let staging = collection_dir.join(format!(".stage-{}", Uuid::new_v4()));
copy_manifest_entries(manifest, source.as_ref(), &staging)?;
fs::rename(&staging, &version_dir)
.map_err(|error| SyncwebError::operation("failed to finalize staged package", error))?;
self.set_current(manifest.collection_id, &manifest.version)?;
self.db
.install_collection(manifest.collection_id, &manifest_hash, &manifest.version, &version_dir)
}
pub async fn install_from_ticket(
&self,
manifest_ticket: &BlobTicket,
endpoint: &Endpoint,
blobs: &crate::node::blob_store::BlobStore,
) -> Result<CollectionManifest> {
if !blobs.has(manifest_ticket.hash()).await? {
blobs.fetch(endpoint, manifest_ticket).await?;
}
let manifest_bytes = blobs.get(manifest_ticket.hash()).await?;
let manifest = CollectionManifest::from_bytes(&manifest_bytes)?;
if manifest.blob_id()? != manifest_ticket.hash() {
return Err(SyncwebError::InvalidTicket(
"manifest ticket hash does not match manifest content".to_owned(),
));
}
let collection_dir = self.root.join(manifest.collection_id.to_string());
let version_dir = collection_dir.join(&manifest.version);
if version_dir.exists() {
return Err(SyncwebError::InvalidConfig(format!(
"collection version {} is already installed",
manifest.version
)));
}
let source = self.root.join(format!(".source-{}", Uuid::new_v4()));
fs::create_dir_all(&source)
.map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
for entry in &manifest.entries {
if !blobs.has(entry.content_id).await? {
let ticket = blobs.ticket_for_addr(manifest_ticket.addr().clone(), entry.content_id);
blobs.fetch(endpoint, &ticket).await?;
}
let bytes = blobs.get(entry.content_id).await?;
let destination = source.join(&entry.logical_path);
let parent = destination
.parent()
.ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
fs::create_dir_all(parent)
.map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
fs::write(destination, bytes)
.map_err(|error| SyncwebError::operation("failed to materialize package entry", error))?;
}
let install_result = self.install(&manifest, &source);
let cleanup_result = fs::remove_dir_all(&source)
.map_err(|error| SyncwebError::operation("failed to remove temporary package source", error));
match (install_result, cleanup_result) {
(Err(error), _) | (Ok(()), Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(manifest),
}
}
pub fn switch(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
let version = requested_version.as_ref();
let mut state = self.state()?;
let installed = state
.installed
.get_mut(&collection_id)
.ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
if !installed.versions.contains_key(version) {
return Err(SyncwebError::InvalidConfig(format!(
"collection version {version} is not installed"
)));
}
self.set_current(collection_id, version)?;
self.db.switch_collection_version(collection_id, version)
}
pub fn remove(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
let version = requested_version.as_ref();
let state = self.state()?;
let installed = state
.installed
.get(&collection_id)
.ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
if installed.current == version {
return Err(SyncwebError::InvalidConfig(
"switch away from the active collection version before removing it".to_owned(),
));
}
let path = installed
.versions
.get(version)
.ok_or_else(|| SyncwebError::InvalidConfig(format!("collection version {version} is not installed")))?;
fs::remove_dir_all(path)
.map_err(|error| SyncwebError::operation("failed to remove installed collection version", error))?;
self.db.remove_collection_version(collection_id, version)
}
pub fn verify(&self, manifest: &CollectionManifest) -> Result<()> {
let state = self.state()?;
let installed = state
.installed
.get(&manifest.collection_id)
.ok_or_else(|| SyncwebError::FolderNotFound(manifest.collection_id.to_string()))?;
let path = installed.versions.get(&manifest.version).ok_or_else(|| {
SyncwebError::InvalidConfig(format!("collection version {} is not installed", manifest.version))
})?;
verify_directory(manifest, path)
}
fn set_current(&self, collection_id: Uuid, version: &str) -> Result<()> {
let collection_dir = self.root.join(collection_id.to_string());
let current = collection_dir.join("current");
let temporary = collection_dir.join(format!(".current-{}", Uuid::new_v4()));
create_current_link(version, &temporary)?;
fs::rename(&temporary, ¤t)
.map_err(|error| SyncwebError::operation("failed to atomically switch collection version", error))
}
}
fn verify_directory(manifest: &CollectionManifest, root: &Path) -> Result<()> {
for entry in &manifest.entries {
let path = root.join(&entry.logical_path);
let bytes = fs::read(&path)
.map_err(|error| SyncwebError::operation("failed to read package entry during verification", error))?;
let actual_size =
u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("package entry is too large", error))?;
if actual_size != entry.size || Hash::new(&bytes) != entry.content_id {
return Err(SyncwebError::InvalidConfig(format!(
"package entry does not match manifest: {}",
entry.logical_path.display()
)));
}
}
Ok(())
}
fn copy_manifest_entries(manifest: &CollectionManifest, source: &Path, staging: &Path) -> Result<()> {
for entry in &manifest.entries {
let destination = staging.join(&entry.logical_path);
let parent = destination
.parent()
.ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
fs::create_dir_all(parent)
.map_err(|error| SyncwebError::operation("failed to create package staging directory", error))?;
fs::copy(source.join(&entry.logical_path), destination)
.map_err(|error| SyncwebError::operation("failed to stage package entry", error))?;
}
Ok(())
}
#[cfg(unix)]
fn create_current_link(version: &str, path: &Path) -> Result<()> {
std::os::unix::fs::symlink(version, path)
.map_err(|error| SyncwebError::operation("failed to create current collection link", error))
}
#[cfg(not(unix))]
fn create_current_link(version: &str, path: &Path) -> Result<()> {
std::os::windows::fs::symlink_dir(version, path)
.map_err(|error| SyncwebError::operation("failed to create current collection link", error))
}