1use std::path::{Component, Path, PathBuf};
2
3use iroh_blobs::Hash;
4use iroh_docs::NamespaceId;
5use serde::{Deserialize, Serialize};
6
7use crate::{Result, SyncwebError};
8
9#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
11#[non_exhaustive]
12pub struct StorageRoot {
13 pub id: String,
14 pub path: PathBuf,
15 pub min_free: u64,
16 pub enabled: bool,
17}
18
19impl StorageRoot {
20 #[must_use]
22 pub fn new(id: impl Into<String>, path: impl Into<PathBuf>, min_free: u64) -> Self {
23 Self {
24 id: id.into(),
25 path: path.into(),
26 min_free,
27 enabled: true,
28 }
29 }
30
31 #[must_use]
33 pub const fn with_enabled(mut self, enabled: bool) -> Self {
34 self.enabled = enabled;
35 self
36 }
37}
38
39#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[non_exhaustive]
45pub struct RootCapacity {
46 pub total: u64,
47 pub free: u64,
48 pub reserved: u64,
49 pub available: u64,
50}
51
52impl RootCapacity {
53 #[must_use]
55 pub const fn new(total: u64, free: u64, reserved: u64) -> Self {
56 Self {
57 total,
58 free,
59 reserved,
60 available: free.saturating_sub(reserved),
61 }
62 }
63
64 #[must_use]
66 pub const fn allocatable(self, min_free: u64) -> u64 {
67 self.available.saturating_sub(min_free)
68 }
69}
70
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
73#[non_exhaustive]
74pub struct AllocationCandidate {
75 pub namespace: NamespaceId,
76 pub path: PathBuf,
77 pub hash: Hash,
78 pub size: u64,
79 pub peer_count: usize,
80 pub local: bool,
81}
82
83impl AllocationCandidate {
84 #[must_use]
86 pub fn new(
87 namespace: NamespaceId,
88 path: impl Into<PathBuf>,
89 hash: Hash,
90 size: u64,
91 peer_count: usize,
92 local: bool,
93 ) -> Self {
94 Self {
95 namespace,
96 path: path.into(),
97 hash,
98 size,
99 peer_count,
100 local,
101 }
102 }
103}
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
107#[non_exhaustive]
108pub struct AllocationDecision {
109 pub candidate: AllocationCandidate,
110 pub root_id: String,
111 pub destination: PathBuf,
112}
113
114pub fn materialization_path(root: &StorageRoot, candidate: &AllocationCandidate) -> Result<PathBuf> {
124 validate_relative_path(&candidate.path)?;
125 Ok(root.path.join(candidate.namespace.to_string()).join(&candidate.path))
126}
127
128#[must_use]
138pub fn allocate(
139 roots: &[(StorageRoot, RootCapacity)],
140 candidates: &[AllocationCandidate],
141) -> (Vec<AllocationDecision>, Vec<AllocationCandidate>) {
142 let mut ordered = candidates.to_vec();
143 ordered.sort_by(|left, right| {
144 left.peer_count
145 .cmp(&right.peer_count)
146 .then(left.path.cmp(&right.path))
147 .then(left.hash.cmp(&right.hash))
148 .then(left.namespace.to_string().cmp(&right.namespace.to_string()))
149 });
150
151 let mut reserved = vec![0_u64; roots.len()];
152 let mut decisions = Vec::new();
153 let mut unallocated = Vec::new();
154
155 for candidate in ordered {
156 if validate_relative_path(&candidate.path).is_err() {
157 unallocated.push(candidate);
158 continue;
159 }
160
161 let mut decision = None;
162 for ((root, capacity), reserved_for_root) in roots.iter().zip(reserved.iter_mut()) {
163 if !root.enabled {
164 continue;
165 }
166
167 let remaining = capacity.available.saturating_sub(*reserved_for_root);
168 if candidate.size > remaining.saturating_sub(root.min_free) {
169 continue;
170 }
171
172 let Ok(destination) = materialization_path(root, &candidate) else {
173 continue;
174 };
175 *reserved_for_root = reserved_for_root.saturating_add(candidate.size);
176 decision = Some(AllocationDecision {
177 candidate: candidate.clone(),
178 root_id: root.id.clone(),
179 destination,
180 });
181 break;
182 }
183
184 if let Some(selected) = decision {
185 decisions.push(selected);
186 } else {
187 unallocated.push(candidate);
188 }
189 }
190
191 (decisions, unallocated)
192}
193
194fn validate_relative_path(path: &Path) -> Result<()> {
195 if path.as_os_str().is_empty()
196 || path.is_absolute()
197 || path.components().any(|component| {
198 matches!(
199 component,
200 Component::ParentDir | Component::RootDir | Component::Prefix(_)
201 )
202 })
203 {
204 return Err(SyncwebError::InvalidConfig(format!(
205 "materialization path must be a non-empty relative path: {}",
206 path.display()
207 )));
208 }
209 Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214 use std::path::PathBuf;
215
216 use iroh_blobs::Hash;
217
218 use super::{AllocationCandidate, RootCapacity, StorageRoot, allocate, materialization_path};
219
220 fn namespace(value: u8) -> iroh_docs::NamespaceId {
221 iroh_docs::NamespaceId::from([value; 32])
222 }
223
224 fn candidate(name: &str, size: u64, peers: usize, hash_byte: u8) -> AllocationCandidate {
225 AllocationCandidate::new(
226 namespace(hash_byte),
227 PathBuf::from(name),
228 Hash::from([hash_byte; 32]),
229 size,
230 peers,
231 false,
232 )
233 }
234
235 fn root(id: &str, capacity: u64) -> (StorageRoot, RootCapacity) {
236 (
237 StorageRoot::new(id, format!("/storage/{id}"), 0),
238 RootCapacity::new(capacity, capacity, 0),
239 )
240 }
241
242 #[test]
243 fn packs_candidates_across_roots() {
244 let roots = vec![root("one", 10), root("two", 5)];
245 let candidates = vec![
246 candidate("large", 6, 0, 1),
247 candidate("small", 4, 1, 2),
248 candidate("other", 5, 2, 3),
249 ];
250
251 let (decisions, unallocated) = allocate(&roots, &candidates);
252
253 assert_eq!(decisions.len(), 3);
254 assert!(unallocated.is_empty());
255 let assigned_roots = decisions
256 .iter()
257 .map(|decision| decision.root_id.as_str())
258 .collect::<Vec<_>>();
259 assert_eq!(assigned_roots, vec!["one", "one", "two"]);
260 }
261
262 #[test]
263 fn honors_min_free_and_available_capacity() {
264 let roots = vec![
265 (
266 StorageRoot::new("protected", "/protected", 4),
267 RootCapacity::new(20, 10, 2),
268 ),
269 root("available", 5),
270 ];
271 let candidates = vec![candidate("five", 5, 0, 1), candidate("six", 6, 1, 2)];
272
273 let (decisions, unallocated) = allocate(&roots, &candidates);
274
275 assert_eq!(decisions.len(), 1);
276 assert_eq!(
277 decisions.first().map(|decision| decision.root_id.as_str()),
278 Some("available")
279 );
280 assert_eq!(unallocated.len(), 1);
281 assert_eq!(
282 unallocated.first().map(|candidate| candidate.path.clone()),
283 Some(PathBuf::from("six"))
284 );
285 }
286
287 #[test]
288 fn orders_by_peers_path_and_hash() {
289 let roots = vec![root("one", 30)];
290 let candidates = vec![
291 candidate("z", 1, 2, 1),
292 candidate("b", 1, 1, 3),
293 candidate("a", 1, 1, 2),
294 candidate("same", 1, 1, 5),
295 candidate("same", 1, 1, 4),
296 ];
297
298 let (decisions, _) = allocate(&roots, &candidates);
299
300 let paths = decisions
301 .iter()
302 .map(|decision| decision.candidate.path.clone())
303 .collect::<Vec<_>>();
304 assert_eq!(
305 paths,
306 vec![
307 PathBuf::from("a"),
308 PathBuf::from("b"),
309 PathBuf::from("same"),
310 PathBuf::from("same"),
311 PathBuf::from("z")
312 ]
313 );
314 assert_eq!(
315 decisions.get(2).map(|decision| decision.candidate.hash),
316 Some(Hash::from([4; 32]))
317 );
318 }
319
320 #[test]
321 fn skips_disabled_roots() {
322 let roots = vec![
323 (
324 StorageRoot::new("disabled", "/disabled", 0).with_enabled(false),
325 RootCapacity::new(100, 100, 0),
326 ),
327 root("enabled", 1),
328 ];
329
330 let (decisions, unallocated) = allocate(&roots, &[candidate("file", 1, 0, 1)]);
331
332 assert!(unallocated.is_empty());
333 assert_eq!(
334 decisions.first().map(|decision| decision.root_id.as_str()),
335 Some("enabled")
336 );
337 }
338
339 #[test]
340 fn rejects_unsafe_materialization_paths() {
341 let root = StorageRoot::new("one", "/storage", 0);
342 let absolute = AllocationCandidate::new(
343 namespace(1),
344 PathBuf::from("/outside"),
345 Hash::from([1; 32]),
346 1,
347 0,
348 false,
349 );
350 let traversal = AllocationCandidate::new(
351 namespace(1),
352 PathBuf::from("../outside"),
353 Hash::from([2; 32]),
354 1,
355 0,
356 false,
357 );
358
359 assert!(materialization_path(&root, &absolute).is_err());
360 assert!(materialization_path(&root, &traversal).is_err());
361 let (_, unallocated) = allocate(&[(root, RootCapacity::new(10, 10, 0))], &[absolute, traversal]);
362 assert_eq!(unallocated.len(), 2);
363 }
364}