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#[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 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 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 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 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, ¤t)
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}