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#[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 pub fn state(&self) -> Result<CollectionState> {
41 self.db.get_collection_state()
42 }
43
44 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 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 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 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 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 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, ¤t)
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}