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::{BlobFormat, Hash, ticket::BlobTicket};
9use semver::Version;
10use uuid::Uuid;
11
12use crate::{
13    error::{Result, SyncwebError},
14    folder::{CollectionEntry, CollectionManifest, CollectionState},
15    node::blob_store::BlobStore,
16    storage::node_db::NodeDatabase,
17};
18
19/// Installs collection package versions into versioned directories.
20#[derive(Clone, Debug)]
21pub struct PackageManager {
22    root: PathBuf,
23    db: NodeDatabase,
24}
25
26impl PackageManager {
27    #[must_use]
28    pub fn new(root: impl Into<PathBuf>, db: NodeDatabase) -> Self {
29        Self { root: root.into(), db }
30    }
31
32    #[must_use]
33    pub fn root(&self) -> &Path {
34        &self.root
35    }
36
37    /// # Errors
38    ///
39    /// Returns an error if state cannot be read or decoded.
40    pub fn state(&self) -> Result<CollectionState> {
41        self.db.get_collection_state()
42    }
43
44    /// Return the currently selected version for each installed collection.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if an installed version is not valid semver.
49    pub fn available_versions(&self) -> Result<BTreeMap<Uuid, Version>> {
50        self.state()?
51            .installed
52            .into_iter()
53            .map(|(collection_id, installed)| {
54                let version = Version::parse(&installed.current).map_err(|error| {
55                    SyncwebError::InvalidConfig(format!(
56                        "installed collection {collection_id} has an invalid version: {error}"
57                    ))
58                })?;
59                Ok((collection_id, version))
60            })
61            .collect()
62    }
63
64    /// Stage, verify, and atomically make a collection version current.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error when source content does not match the manifest or installation fails.
69    pub fn install(&self, manifest: &CollectionManifest, source: impl AsRef<Path>) -> Result<()> {
70        manifest.validate()?;
71        verify_directory(manifest, source.as_ref())?;
72        let manifest_hash = manifest.blob_id()?;
73        let collection_dir = self.root.join(manifest.collection_id.to_string());
74        fs::create_dir_all(&collection_dir)
75            .map_err(|error| SyncwebError::operation("failed to create package directory", error))?;
76        let version_dir = collection_dir.join(&manifest.version);
77        if version_dir.exists() {
78            return Err(SyncwebError::InvalidConfig(format!(
79                "collection version {} is already installed",
80                manifest.version
81            )));
82        }
83
84        let staging = collection_dir.join(format!(".stage-{}", Uuid::new_v4()));
85        copy_manifest_entries(manifest, source.as_ref(), &staging)?;
86        fs::rename(&staging, &version_dir)
87            .map_err(|error| SyncwebError::operation("failed to finalize staged package", error))?;
88        self.set_current(manifest.collection_id, &manifest.version)?;
89        self.db
90            .install_collection(manifest.collection_id, &manifest_hash, &manifest.version, &version_dir)
91    }
92
93    /// Fetch a manifest and all of its content blobs, then install it
94    /// atomically.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if a ticket, manifest, content blob, or installation
99    /// step is invalid.
100    pub async fn install_from_ticket(
101        &self,
102        manifest_ticket: &BlobTicket,
103        endpoint: &Endpoint,
104        blobs: &crate::node::blob_store::BlobStore,
105    ) -> Result<CollectionManifest> {
106        if !blobs.has(manifest_ticket.hash()).await? {
107            blobs.fetch(endpoint, manifest_ticket).await?;
108        }
109        let manifest_bytes = blobs.get(manifest_ticket.hash()).await?;
110        let manifest = CollectionManifest::from_bytes(&manifest_bytes)?;
111        if manifest.blob_id()? != manifest_ticket.hash() {
112            return Err(SyncwebError::InvalidTicket(
113                "manifest ticket hash does not match manifest content".to_owned(),
114            ));
115        }
116
117        let source = self.root.join(format!(".source-{}", Uuid::new_v4()));
118        fs::create_dir_all(&source)
119            .map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
120        for entry in &manifest.entries {
121            if !blobs.has(entry.content_id).await? {
122                let ticket = BlobTicket::new(manifest_ticket.addr().clone(), entry.content_id, BlobFormat::Raw);
123                blobs.fetch(endpoint, &ticket).await?;
124            }
125            let bytes = blobs.get(entry.content_id).await?;
126            let destination = source.join(&entry.logical_path);
127            let parent = destination
128                .parent()
129                .ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
130            fs::create_dir_all(parent)
131                .map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
132            fs::write(destination, bytes)
133                .map_err(|error| SyncwebError::operation("failed to materialize package entry", error))?;
134        }
135
136        let install_result = self.install(&manifest, &source);
137        let cleanup_result = fs::remove_dir_all(&source)
138            .map_err(|error| SyncwebError::operation("failed to remove temporary package source", error));
139        match (install_result, cleanup_result) {
140            (Err(error), _) | (Ok(()), Err(error)) => Err(error),
141            (Ok(()), Ok(())) => Ok(manifest),
142        }
143    }
144
145    /// # Errors
146    ///
147    /// Returns an error if the requested version is not installed or the active link cannot be switched.
148    pub fn switch(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
149        let version = requested_version.as_ref();
150        let mut state = self.state()?;
151        let installed = state
152            .installed
153            .get_mut(&collection_id)
154            .ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
155        if !installed.versions.contains_key(version) {
156            return Err(SyncwebError::InvalidConfig(format!(
157                "collection version {version} is not installed"
158            )));
159        }
160        self.set_current(collection_id, version)?;
161        self.db.switch_collection_version(collection_id, version)
162    }
163
164    /// # Errors
165    ///
166    /// Returns an error if an installed version cannot be deleted.
167    pub fn remove(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
168        let version = requested_version.as_ref();
169        let state = self.state()?;
170        let installed = state
171            .installed
172            .get(&collection_id)
173            .ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
174        if installed.current == version {
175            return Err(SyncwebError::InvalidConfig(
176                "switch away from the active collection version before removing it".to_owned(),
177            ));
178        }
179        let path = installed
180            .versions
181            .get(version)
182            .ok_or_else(|| SyncwebError::InvalidConfig(format!("collection version {version} is not installed")))?;
183        fs::remove_dir_all(path)
184            .map_err(|error| SyncwebError::operation("failed to remove installed collection version", error))?;
185        self.db.remove_collection_version(collection_id, version)
186    }
187
188    /// # Errors
189    ///
190    /// Returns an error when an installed version's content does not match its manifest.
191    pub fn verify(&self, manifest: &CollectionManifest) -> Result<()> {
192        let state = self.state()?;
193        let installed = state
194            .installed
195            .get(&manifest.collection_id)
196            .ok_or_else(|| SyncwebError::FolderNotFound(manifest.collection_id.to_string()))?;
197        let path = installed.versions.get(&manifest.version).ok_or_else(|| {
198            SyncwebError::InvalidConfig(format!("collection version {} is not installed", manifest.version))
199        })?;
200        verify_directory(manifest, path)
201    }
202
203    /// Import existing files into the blob store via reference links.
204    ///
205    /// For each `(source_path, logical_path)` pair the file is hashed with
206    /// BLAKE3, added to the blob store with a reference (hardlink where
207    /// possible, avoiding duplication), and recorded in a new
208    /// [`CollectionManifest`].
209    ///
210    /// The manifest is unsigned; the caller must sign it before publishing.
211    ///
212    /// # Arguments
213    ///
214    /// * `files` – List of `(source_path, logical_path)` pairs.
215    /// * `blobs` – The node's blob store.
216    /// * `collection_name` – Human-readable name for the collection manifest.
217    /// * `collection_id` – UUID for the new collection.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if a file cannot be read or added to the blob store.
222    pub async fn import_from_paths(
223        &self,
224        files: &[(PathBuf, PathBuf)],
225        blobs: &BlobStore,
226        collection_name: &str,
227        collection_id: Uuid,
228    ) -> Result<CollectionManifest> {
229        let mut entries = Vec::new();
230        for (source, logical_path) in files {
231            let content_id = blobs
232                .add_file_ref(source)
233                .await
234                .map_err(|error| SyncwebError::operation("failed to import file into blob store", error))?;
235            let metadata =
236                fs::metadata(source).map_err(|error| SyncwebError::operation("failed to stat source file", error))?;
237            entries.push(CollectionEntry::new(
238                content_id,
239                logical_path.as_path(),
240                metadata.len(),
241            )?);
242        }
243        let mut manifest = CollectionManifest::new(collection_id, "1.0.0");
244        manifest.entries = entries;
245        manifest.package = Some(crate::folder::PackageProfile::new(collection_name));
246        Ok(manifest)
247    }
248
249    /// Make existing files seedable via syncweb.
250    ///
251    /// For each file:
252    /// 1. Import into the blob store via reference (no copy)
253    /// 2. Pin all blobs to prevent garbage collection
254    /// 3. Return manifest and blob ticket for sharing
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if import, pinning, or ticket creation fails.
259    pub async fn make_seedable(
260        &self,
261        files: &[(PathBuf, PathBuf)],
262        blobs: &BlobStore,
263        endpoint: &Endpoint,
264        collection_name: &str,
265    ) -> Result<(CollectionManifest, BlobTicket)> {
266        let collection_id = Uuid::new_v4();
267        let manifest = self
268            .import_from_paths(files, blobs, collection_name, collection_id)
269            .await?;
270
271        // Pin content blobs
272        for entry in &manifest.entries {
273            blobs
274                .pin(
275                    format!("syncweb/package/{}/{}", collection_id, entry.content_id),
276                    entry.content_id,
277                )
278                .await?;
279        }
280
281        // Pin and ticket the manifest itself
282        let manifest_bytes = manifest.to_bytes()?;
283        let manifest_hash = blobs.add_bytes(&manifest_bytes).await?;
284        blobs
285            .pin(format!("syncweb/package/{collection_id}/manifest"), manifest_hash)
286            .await?;
287        let ticket = blobs.ticket(endpoint, manifest_hash);
288
289        Ok((manifest, ticket))
290    }
291
292    fn set_current(&self, collection_id: Uuid, version: &str) -> Result<()> {
293        let collection_dir = self.root.join(collection_id.to_string());
294        let current = collection_dir.join("current");
295        let temporary = collection_dir.join(format!(".current-{}", Uuid::new_v4()));
296        create_current_link(version, &temporary)?;
297        fs::rename(&temporary, &current)
298            .map_err(|error| SyncwebError::operation("failed to atomically switch collection version", error))
299    }
300}
301
302fn verify_directory(manifest: &CollectionManifest, root: &Path) -> Result<()> {
303    for entry in &manifest.entries {
304        let path = root.join(&entry.logical_path);
305        let bytes = fs::read(&path)
306            .map_err(|error| SyncwebError::operation("failed to read package entry during verification", error))?;
307        let actual_size =
308            u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("package entry is too large", error))?;
309        if actual_size != entry.size || Hash::new(&bytes) != entry.content_id {
310            return Err(SyncwebError::InvalidConfig(format!(
311                "package entry does not match manifest: {}",
312                entry.logical_path.display()
313            )));
314        }
315    }
316    Ok(())
317}
318
319fn copy_manifest_entries(manifest: &CollectionManifest, source: &Path, staging: &Path) -> Result<()> {
320    for entry in &manifest.entries {
321        let destination = staging.join(&entry.logical_path);
322        let parent = destination
323            .parent()
324            .ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
325        fs::create_dir_all(parent)
326            .map_err(|error| SyncwebError::operation("failed to create package staging directory", error))?;
327        fs::copy(source.join(&entry.logical_path), destination)
328            .map_err(|error| SyncwebError::operation("failed to stage package entry", error))?;
329    }
330    Ok(())
331}
332
333#[cfg(unix)]
334fn create_current_link(version: &str, path: &Path) -> Result<()> {
335    std::os::unix::fs::symlink(version, path)
336        .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
337}
338
339#[cfg(not(unix))]
340fn create_current_link(version: &str, path: &Path) -> Result<()> {
341    std::os::windows::fs::symlink_dir(version, path)
342        .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
343}