Skip to main content

syncweb_core/folder/
package.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use iroh::Endpoint;
8use iroh_blobs::{Hash, ticket::BlobTicket};
9use semver::Version;
10use uuid::Uuid;
11
12use crate::{
13    error::{Result, SyncwebError},
14    folder::{CollectionManifest, CollectionState},
15    storage::node_db::NodeDatabase,
16};
17
18/// Installs collection package versions into versioned directories.
19#[derive(Clone, Debug)]
20pub struct PackageManager {
21    root: PathBuf,
22    db: NodeDatabase,
23}
24
25impl PackageManager {
26    #[must_use]
27    pub fn new(root: impl Into<PathBuf>, db: NodeDatabase) -> Self {
28        Self { root: root.into(), db }
29    }
30
31    #[must_use]
32    pub fn root(&self) -> &Path {
33        &self.root
34    }
35
36    /// # Errors
37    ///
38    /// Returns an error if state cannot be read or decoded.
39    pub fn state(&self) -> Result<CollectionState> {
40        self.db.get_collection_state()
41    }
42
43    /// Return the currently selected version for each installed collection.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error if an installed version is not valid semver.
48    pub fn available_versions(&self) -> Result<BTreeMap<Uuid, Version>> {
49        self.state()?
50            .installed
51            .into_iter()
52            .map(|(collection_id, installed)| {
53                let version = Version::parse(&installed.current).map_err(|error| {
54                    SyncwebError::InvalidConfig(format!(
55                        "installed collection {collection_id} has an invalid version: {error}"
56                    ))
57                })?;
58                Ok((collection_id, version))
59            })
60            .collect()
61    }
62
63    /// Stage, verify, and atomically make a collection version current.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error when source content does not match the manifest or installation fails.
68    pub fn install(&self, manifest: &CollectionManifest, source: impl AsRef<Path>) -> Result<()> {
69        manifest.validate()?;
70        verify_directory(manifest, source.as_ref())?;
71        let manifest_hash = manifest.blob_id()?;
72        let collection_dir = self.root.join(manifest.collection_id.to_string());
73        fs::create_dir_all(&collection_dir)
74            .map_err(|error| SyncwebError::operation("failed to create package directory", error))?;
75        let version_dir = collection_dir.join(&manifest.version);
76        if version_dir.exists() {
77            return Err(SyncwebError::InvalidConfig(format!(
78                "collection version {} is already installed",
79                manifest.version
80            )));
81        }
82
83        let staging = collection_dir.join(format!(".stage-{}", Uuid::new_v4()));
84        copy_manifest_entries(manifest, source.as_ref(), &staging)?;
85        fs::rename(&staging, &version_dir)
86            .map_err(|error| SyncwebError::operation("failed to finalize staged package", error))?;
87        self.set_current(manifest.collection_id, &manifest.version)?;
88        self.db
89            .install_collection(manifest.collection_id, &manifest_hash, &manifest.version, &version_dir)
90    }
91
92    /// Fetch a manifest and all of its content blobs, then install it
93    /// atomically.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if a ticket, manifest, content blob, or installation
98    /// step is invalid.
99    pub async fn install_from_ticket(
100        &self,
101        manifest_ticket: &BlobTicket,
102        endpoint: &Endpoint,
103        blobs: &crate::node::blob_store::BlobStore,
104    ) -> Result<CollectionManifest> {
105        if !blobs.has(manifest_ticket.hash()).await? {
106            blobs.fetch(endpoint, manifest_ticket).await?;
107        }
108        let manifest_bytes = blobs.get(manifest_ticket.hash()).await?;
109        let manifest = CollectionManifest::from_bytes(&manifest_bytes)?;
110        if manifest.blob_id()? != manifest_ticket.hash() {
111            return Err(SyncwebError::InvalidTicket(
112                "manifest ticket hash does not match manifest content".to_owned(),
113            ));
114        }
115
116        // Transaction test: verify no file collisions before any writes
117        let collection_dir = self.root.join(manifest.collection_id.to_string());
118        let version_dir = collection_dir.join(&manifest.version);
119        if version_dir.exists() {
120            return Err(SyncwebError::InvalidConfig(format!(
121                "collection version {} is already installed",
122                manifest.version
123            )));
124        }
125
126        let source = self.root.join(format!(".source-{}", Uuid::new_v4()));
127        fs::create_dir_all(&source)
128            .map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
129        for entry in &manifest.entries {
130            if !blobs.has(entry.content_id).await? {
131                let ticket = blobs.ticket_for_addr(manifest_ticket.addr().clone(), entry.content_id);
132                blobs.fetch(endpoint, &ticket).await?;
133            }
134            let bytes = blobs.get(entry.content_id).await?;
135            let destination = source.join(&entry.logical_path);
136            let parent = destination
137                .parent()
138                .ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
139            fs::create_dir_all(parent)
140                .map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
141            fs::write(destination, bytes)
142                .map_err(|error| SyncwebError::operation("failed to materialize package entry", error))?;
143        }
144
145        let install_result = self.install(&manifest, &source);
146        let cleanup_result = fs::remove_dir_all(&source)
147            .map_err(|error| SyncwebError::operation("failed to remove temporary package source", error));
148        match (install_result, cleanup_result) {
149            (Err(error), _) | (Ok(()), Err(error)) => Err(error),
150            (Ok(()), Ok(())) => Ok(manifest),
151        }
152    }
153
154    /// # Errors
155    ///
156    /// Returns an error if the requested version is not installed or the active link cannot be switched.
157    pub fn switch(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
158        let version = requested_version.as_ref();
159        let mut state = self.state()?;
160        let installed = state
161            .installed
162            .get_mut(&collection_id)
163            .ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
164        if !installed.versions.contains_key(version) {
165            return Err(SyncwebError::InvalidConfig(format!(
166                "collection version {version} is not installed"
167            )));
168        }
169        self.set_current(collection_id, version)?;
170        self.db.switch_collection_version(collection_id, version)
171    }
172
173    /// # Errors
174    ///
175    /// Returns an error if an installed version cannot be deleted.
176    pub fn remove(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
177        let version = requested_version.as_ref();
178        let state = self.state()?;
179        let installed = state
180            .installed
181            .get(&collection_id)
182            .ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
183        if installed.current == version {
184            return Err(SyncwebError::InvalidConfig(
185                "switch away from the active collection version before removing it".to_owned(),
186            ));
187        }
188        let path = installed
189            .versions
190            .get(version)
191            .ok_or_else(|| SyncwebError::InvalidConfig(format!("collection version {version} is not installed")))?;
192        fs::remove_dir_all(path)
193            .map_err(|error| SyncwebError::operation("failed to remove installed collection version", error))?;
194        self.db.remove_collection_version(collection_id, version)
195    }
196
197    /// # Errors
198    ///
199    /// Returns an error when an installed version's content does not match its manifest.
200    pub fn verify(&self, manifest: &CollectionManifest) -> Result<()> {
201        let state = self.state()?;
202        let installed = state
203            .installed
204            .get(&manifest.collection_id)
205            .ok_or_else(|| SyncwebError::FolderNotFound(manifest.collection_id.to_string()))?;
206        let path = installed.versions.get(&manifest.version).ok_or_else(|| {
207            SyncwebError::InvalidConfig(format!("collection version {} is not installed", manifest.version))
208        })?;
209        verify_directory(manifest, path)
210    }
211
212    fn set_current(&self, collection_id: Uuid, version: &str) -> Result<()> {
213        let collection_dir = self.root.join(collection_id.to_string());
214        let current = collection_dir.join("current");
215        let temporary = collection_dir.join(format!(".current-{}", Uuid::new_v4()));
216        create_current_link(version, &temporary)?;
217        fs::rename(&temporary, &current)
218            .map_err(|error| SyncwebError::operation("failed to atomically switch collection version", error))
219    }
220}
221
222fn verify_directory(manifest: &CollectionManifest, root: &Path) -> Result<()> {
223    for entry in &manifest.entries {
224        let path = root.join(&entry.logical_path);
225        let bytes = fs::read(&path)
226            .map_err(|error| SyncwebError::operation("failed to read package entry during verification", error))?;
227        let actual_size =
228            u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("package entry is too large", error))?;
229        if actual_size != entry.size || Hash::new(&bytes) != entry.content_id {
230            return Err(SyncwebError::InvalidConfig(format!(
231                "package entry does not match manifest: {}",
232                entry.logical_path.display()
233            )));
234        }
235    }
236    Ok(())
237}
238
239fn copy_manifest_entries(manifest: &CollectionManifest, source: &Path, staging: &Path) -> Result<()> {
240    for entry in &manifest.entries {
241        let destination = staging.join(&entry.logical_path);
242        let parent = destination
243            .parent()
244            .ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
245        fs::create_dir_all(parent)
246            .map_err(|error| SyncwebError::operation("failed to create package staging directory", error))?;
247        fs::copy(source.join(&entry.logical_path), destination)
248            .map_err(|error| SyncwebError::operation("failed to stage package entry", error))?;
249    }
250    Ok(())
251}
252
253#[cfg(unix)]
254fn create_current_link(version: &str, path: &Path) -> Result<()> {
255    std::os::unix::fs::symlink(version, path)
256        .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
257}
258
259#[cfg(not(unix))]
260fn create_current_link(version: &str, path: &Path) -> Result<()> {
261    std::os::windows::fs::symlink_dir(version, path)
262        .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
263}