1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use iroh::Endpoint;
8use iroh_blobs::{Hash, ticket::BlobTicket};
9use semver::Version;
10use uuid::Uuid;
11
12use crate::{
13 error::{Result, SyncwebError},
14 folder::{CollectionManifest, CollectionState},
15 storage::node_db::NodeDatabase,
16};
17
18#[derive(Clone, Debug)]
20pub struct PackageManager {
21 root: PathBuf,
22 db: NodeDatabase,
23}
24
25impl PackageManager {
26 #[must_use]
27 pub fn new(root: impl Into<PathBuf>, db: NodeDatabase) -> Self {
28 Self { root: root.into(), db }
29 }
30
31 #[must_use]
32 pub fn root(&self) -> &Path {
33 &self.root
34 }
35
36 pub fn state(&self) -> Result<CollectionState> {
40 self.db.get_collection_state()
41 }
42
43 pub fn available_versions(&self) -> Result<BTreeMap<Uuid, Version>> {
49 self.state()?
50 .installed
51 .into_iter()
52 .map(|(collection_id, installed)| {
53 let version = Version::parse(&installed.current).map_err(|error| {
54 SyncwebError::InvalidConfig(format!(
55 "installed collection {collection_id} has an invalid version: {error}"
56 ))
57 })?;
58 Ok((collection_id, version))
59 })
60 .collect()
61 }
62
63 pub fn install(&self, manifest: &CollectionManifest, source: impl AsRef<Path>) -> Result<()> {
69 manifest.validate()?;
70 verify_directory(manifest, source.as_ref())?;
71 let manifest_hash = manifest.blob_id()?;
72 let collection_dir = self.root.join(manifest.collection_id.to_string());
73 fs::create_dir_all(&collection_dir)
74 .map_err(|error| SyncwebError::operation("failed to create package directory", error))?;
75 let version_dir = collection_dir.join(&manifest.version);
76 if version_dir.exists() {
77 return Err(SyncwebError::InvalidConfig(format!(
78 "collection version {} is already installed",
79 manifest.version
80 )));
81 }
82
83 let staging = collection_dir.join(format!(".stage-{}", Uuid::new_v4()));
84 copy_manifest_entries(manifest, source.as_ref(), &staging)?;
85 fs::rename(&staging, &version_dir)
86 .map_err(|error| SyncwebError::operation("failed to finalize staged package", error))?;
87 self.set_current(manifest.collection_id, &manifest.version)?;
88 self.db
89 .install_collection(manifest.collection_id, &manifest_hash, &manifest.version, &version_dir)
90 }
91
92 pub async fn install_from_ticket(
100 &self,
101 manifest_ticket: &BlobTicket,
102 endpoint: &Endpoint,
103 blobs: &crate::node::blob_store::BlobStore,
104 ) -> Result<CollectionManifest> {
105 if !blobs.has(manifest_ticket.hash()).await? {
106 blobs.fetch(endpoint, manifest_ticket).await?;
107 }
108 let manifest_bytes = blobs.get(manifest_ticket.hash()).await?;
109 let manifest = CollectionManifest::from_bytes(&manifest_bytes)?;
110 if manifest.blob_id()? != manifest_ticket.hash() {
111 return Err(SyncwebError::InvalidTicket(
112 "manifest ticket hash does not match manifest content".to_owned(),
113 ));
114 }
115
116 let collection_dir = self.root.join(manifest.collection_id.to_string());
118 let version_dir = collection_dir.join(&manifest.version);
119 if version_dir.exists() {
120 return Err(SyncwebError::InvalidConfig(format!(
121 "collection version {} is already installed",
122 manifest.version
123 )));
124 }
125
126 let source = self.root.join(format!(".source-{}", Uuid::new_v4()));
127 fs::create_dir_all(&source)
128 .map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
129 for entry in &manifest.entries {
130 if !blobs.has(entry.content_id).await? {
131 let ticket = blobs.ticket_for_addr(manifest_ticket.addr().clone(), entry.content_id);
132 blobs.fetch(endpoint, &ticket).await?;
133 }
134 let bytes = blobs.get(entry.content_id).await?;
135 let destination = source.join(&entry.logical_path);
136 let parent = destination
137 .parent()
138 .ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
139 fs::create_dir_all(parent)
140 .map_err(|error| SyncwebError::operation("failed to create package source directory", error))?;
141 fs::write(destination, bytes)
142 .map_err(|error| SyncwebError::operation("failed to materialize package entry", error))?;
143 }
144
145 let install_result = self.install(&manifest, &source);
146 let cleanup_result = fs::remove_dir_all(&source)
147 .map_err(|error| SyncwebError::operation("failed to remove temporary package source", error));
148 match (install_result, cleanup_result) {
149 (Err(error), _) | (Ok(()), Err(error)) => Err(error),
150 (Ok(()), Ok(())) => Ok(manifest),
151 }
152 }
153
154 pub fn switch(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
158 let version = requested_version.as_ref();
159 let mut state = self.state()?;
160 let installed = state
161 .installed
162 .get_mut(&collection_id)
163 .ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
164 if !installed.versions.contains_key(version) {
165 return Err(SyncwebError::InvalidConfig(format!(
166 "collection version {version} is not installed"
167 )));
168 }
169 self.set_current(collection_id, version)?;
170 self.db.switch_collection_version(collection_id, version)
171 }
172
173 pub fn remove(&self, collection_id: Uuid, requested_version: impl AsRef<str>) -> Result<()> {
177 let version = requested_version.as_ref();
178 let state = self.state()?;
179 let installed = state
180 .installed
181 .get(&collection_id)
182 .ok_or_else(|| SyncwebError::FolderNotFound(collection_id.to_string()))?;
183 if installed.current == version {
184 return Err(SyncwebError::InvalidConfig(
185 "switch away from the active collection version before removing it".to_owned(),
186 ));
187 }
188 let path = installed
189 .versions
190 .get(version)
191 .ok_or_else(|| SyncwebError::InvalidConfig(format!("collection version {version} is not installed")))?;
192 fs::remove_dir_all(path)
193 .map_err(|error| SyncwebError::operation("failed to remove installed collection version", error))?;
194 self.db.remove_collection_version(collection_id, version)
195 }
196
197 pub fn verify(&self, manifest: &CollectionManifest) -> Result<()> {
201 let state = self.state()?;
202 let installed = state
203 .installed
204 .get(&manifest.collection_id)
205 .ok_or_else(|| SyncwebError::FolderNotFound(manifest.collection_id.to_string()))?;
206 let path = installed.versions.get(&manifest.version).ok_or_else(|| {
207 SyncwebError::InvalidConfig(format!("collection version {} is not installed", manifest.version))
208 })?;
209 verify_directory(manifest, path)
210 }
211
212 fn set_current(&self, collection_id: Uuid, version: &str) -> Result<()> {
213 let collection_dir = self.root.join(collection_id.to_string());
214 let current = collection_dir.join("current");
215 let temporary = collection_dir.join(format!(".current-{}", Uuid::new_v4()));
216 create_current_link(version, &temporary)?;
217 fs::rename(&temporary, ¤t)
218 .map_err(|error| SyncwebError::operation("failed to atomically switch collection version", error))
219 }
220}
221
222fn verify_directory(manifest: &CollectionManifest, root: &Path) -> Result<()> {
223 for entry in &manifest.entries {
224 let path = root.join(&entry.logical_path);
225 let bytes = fs::read(&path)
226 .map_err(|error| SyncwebError::operation("failed to read package entry during verification", error))?;
227 let actual_size =
228 u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("package entry is too large", error))?;
229 if actual_size != entry.size || Hash::new(&bytes) != entry.content_id {
230 return Err(SyncwebError::InvalidConfig(format!(
231 "package entry does not match manifest: {}",
232 entry.logical_path.display()
233 )));
234 }
235 }
236 Ok(())
237}
238
239fn copy_manifest_entries(manifest: &CollectionManifest, source: &Path, staging: &Path) -> Result<()> {
240 for entry in &manifest.entries {
241 let destination = staging.join(&entry.logical_path);
242 let parent = destination
243 .parent()
244 .ok_or_else(|| SyncwebError::InvalidConfig("package entry has no parent directory".to_owned()))?;
245 fs::create_dir_all(parent)
246 .map_err(|error| SyncwebError::operation("failed to create package staging directory", error))?;
247 fs::copy(source.join(&entry.logical_path), destination)
248 .map_err(|error| SyncwebError::operation("failed to stage package entry", error))?;
249 }
250 Ok(())
251}
252
253#[cfg(unix)]
254fn create_current_link(version: &str, path: &Path) -> Result<()> {
255 std::os::unix::fs::symlink(version, path)
256 .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
257}
258
259#[cfg(not(unix))]
260fn create_current_link(version: &str, path: &Path) -> Result<()> {
261 std::os::windows::fs::symlink_dir(version, path)
262 .map_err(|error| SyncwebError::operation("failed to create current collection link", error))
263}