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