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;
10
11use uuid::Uuid;
12
13use crate::{
14    error::{Result, SyncwebError},
15    folder::{CollectionManifest, CollectionState},
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    fn set_current(&self, collection_id: Uuid, version: &str) -> Result<()> {
204        let collection_dir = self.root.join(collection_id.to_string());
205        let current = collection_dir.join("current");
206        let temporary = collection_dir.join(format!(".current-{}", Uuid::new_v4()));
207        create_current_link(version, &temporary)?;
208        fs::rename(&temporary, &current)
209            .map_err(|error| SyncwebError::operation("failed to atomically switch collection version", error))
210    }
211}
212
213fn verify_directory(manifest: &CollectionManifest, root: &Path) -> Result<()> {
214    for entry in &manifest.entries {
215        let path = root.join(&entry.logical_path);
216        let bytes = fs::read(&path)
217            .map_err(|error| SyncwebError::operation("failed to read package entry during verification", error))?;
218        let actual_size =
219            u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("package entry is too large", error))?;
220        if actual_size != entry.size || Hash::new(&bytes) != entry.content_id {
221            return Err(SyncwebError::InvalidConfig(format!(
222                "package entry does not match manifest: {}",
223                entry.logical_path.display()
224            )));
225        }
226    }
227    Ok(())
228}
229
230fn copy_manifest_entries(manifest: &CollectionManifest, source: &Path, staging: &Path) -> Result<()> {
231    for entry in &manifest.entries {
232        let destination = staging.join(&entry.logical_path);
233        let parent = destination
234            .parent()
235            .ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
236        fs::create_dir_all(parent)
237            .map_err(|error| SyncwebError::operation("failed to create package staging directory", error))?;
238        fs::copy(source.join(&entry.logical_path), destination)
239            .map_err(|error| SyncwebError::operation("failed to stage package entry", error))?;
240    }
241    Ok(())
242}
243
244#[cfg(unix)]
245fn create_current_link(version: &str, path: &Path) -> Result<()> {
246    std::os::unix::fs::symlink(version, path)
247        .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
248}
249
250#[cfg(not(unix))]
251fn create_current_link(version: &str, path: &Path) -> Result<()> {
252    std::os::windows::fs::symlink_dir(version, path)
253        .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
254}