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