1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs::{self, File, OpenOptions},
4 io::{self, BufReader, BufWriter, Read, Write},
5 path::{Path, PathBuf},
6 sync::{Arc, Mutex},
7};
8
9use thiserror::Error;
10use uuid::Uuid;
11
12use super::{
13 ContentTreeDigest, ContentTreeLimits, DiscoveredAsset, DiscoveredContentTree, DiscoveredPost,
14 DiscoveredPublication, LogicalAssetPath, LogicalContentPath, PostCollection,
15 tree::PortableLogicalPath,
16 tree_digest::{
17 DRAFTS_COLLECTION, POSTS_COLLECTION, collection_tag, compare_assets, compare_posts,
18 },
19};
20
21const STORE_DIRECTORY: &str = "content-candidates";
22const CANDIDATE_SUFFIX: &str = ".candidate";
23const STAGING_PREFIX: &str = ".candidate-stage-";
24const STAGING_SUFFIX: &str = ".tmp";
25const DIGEST_PREFIX: &str = "content-b3-v1-";
26const MAX_STORE_ENTRIES: usize = 4_096;
27const MAX_STORE_BYTES: u64 = 1024 * 1024 * 1024;
28
29const ARCHIVE_MAGIC: &[u8; 17] = b"MAINCOPYCANDIDATE";
30const ARCHIVE_VERSION: u16 = 1;
31const ARCHIVE_HEADER_BYTES: u64 = ARCHIVE_MAGIC.len() as u64 + 2 + 32 + 8;
32const PUBLICATION_RECORD_OVERHEAD: u64 = 4 + 8;
33const POST_RECORD_OVERHEAD: u64 = 1 + 4 + 8;
34const ASSET_RECORD_OVERHEAD: u64 = 4 + 8;
35const SEQUENCE_LENGTH_BYTES: u64 = 4;
36
37fn prepare_private_directory(path: &Path) -> io::Result<()> {
38 reject_symlink_components(path)?;
39 create_private_directory(path)?;
40 validate_private_directory(path)
41}
42
43fn reject_symlink_components(path: &Path) -> io::Result<()> {
44 let mut current = PathBuf::new();
45 for component in path.components() {
46 current.push(component);
47 match fs::symlink_metadata(¤t) {
48 Ok(metadata) if metadata.file_type().is_symlink() => {
49 return Err(io::Error::new(
50 io::ErrorKind::InvalidInput,
51 "private candidate-store paths cannot contain symbolic links",
52 ));
53 }
54 Ok(_) => {}
55 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
56 Err(error) => return Err(error),
57 }
58 }
59 Ok(())
60}
61
62#[cfg(unix)]
63fn create_private_directory(path: &Path) -> io::Result<()> {
64 use std::os::unix::fs::DirBuilderExt as _;
65
66 let mut builder = fs::DirBuilder::new();
67 builder.recursive(true).mode(0o700).create(path)
68}
69
70#[cfg(not(unix))]
71fn create_private_directory(path: &Path) -> io::Result<()> {
72 fs::DirBuilder::new().recursive(true).create(path)
73}
74
75#[cfg(unix)]
76fn validate_private_directory(path: &Path) -> io::Result<()> {
77 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
78
79 let metadata = fs::symlink_metadata(path)?;
80 if !metadata.is_dir()
81 || metadata.file_type().is_symlink()
82 || metadata.uid() != rustix::process::geteuid().as_raw()
83 || metadata.permissions().mode() & 0o077 != 0
84 {
85 return Err(io::Error::new(
86 io::ErrorKind::PermissionDenied,
87 "private candidate-store directory ownership or permissions are unsafe",
88 ));
89 }
90 Ok(())
91}
92
93#[cfg(not(unix))]
94fn validate_private_directory(path: &Path) -> io::Result<()> {
95 let metadata = fs::symlink_metadata(path)?;
96 if metadata.is_dir() && !metadata.file_type().is_symlink() {
97 Ok(())
98 } else {
99 Err(io::Error::new(
100 io::ErrorKind::InvalidInput,
101 "private candidate-store path is not a directory",
102 ))
103 }
104}
105
106#[derive(Clone, Debug)]
107pub struct ContentCandidateStore {
108 root: PathBuf,
109 limits: ContentTreeLimits,
110 capacity: CandidateStoreCapacity,
111 admission: Arc<Mutex<()>>,
112}
113
114#[derive(Clone, Copy, Debug)]
115struct CandidateStoreCapacity {
116 entries: usize,
117 bytes: u64,
118}
119
120impl CandidateStoreCapacity {
121 const DEFAULT: Self = Self {
122 entries: MAX_STORE_ENTRIES,
123 bytes: MAX_STORE_BYTES,
124 };
125}
126
127struct CandidateStoreInventory {
128 candidates: Vec<(String, ContentTreeDigest)>,
129 entries: usize,
130 bytes: u64,
131}
132
133#[derive(Debug, Eq, PartialEq)]
134pub struct RetainedContentCandidate {
135 pub digest: ContentTreeDigest,
136 pub tree: DiscoveredContentTree,
137}
138
139impl ContentCandidateStore {
140 pub fn open(
141 state_root: &Path,
142 limits: ContentTreeLimits,
143 ) -> Result<Self, ContentCandidateStoreError> {
144 Self::open_with_capacity(state_root, limits, CandidateStoreCapacity::DEFAULT)
145 }
146
147 fn open_with_capacity(
148 state_root: &Path,
149 limits: ContentTreeLimits,
150 capacity: CandidateStoreCapacity,
151 ) -> Result<Self, ContentCandidateStoreError> {
152 if capacity.entries == 0 || capacity.bytes == 0 {
153 return Err(ContentCandidateStoreError::CapacityExceeded(
154 "candidate-store capacity must be positive",
155 ));
156 }
157 let root = state_root.join(STORE_DIRECTORY);
158 prepare_private_directory(&root).map_err(ContentCandidateStoreError::Directory)?;
159 sync_directory(state_root).map_err(ContentCandidateStoreError::Directory)?;
160 let store = Self {
161 root,
162 limits,
163 capacity,
164 admission: Arc::new(Mutex::new(())),
165 };
166 store.inventory()?;
167 Ok(store)
168 }
169
170 pub fn retain(
171 &self,
172 tree: &DiscoveredContentTree,
173 ) -> Result<ContentTreeDigest, ContentCandidateStoreError> {
174 let _admission = self
175 .admission
176 .lock()
177 .map_err(|_| ContentCandidateStoreError::StoreLockPoisoned)?;
178 validate_tree(tree, self.limits)?;
179 let digest = tree.digest();
180 let inventory = self.inventory()?;
181 if self.confirm_existing_candidate(&inventory, &digest, tree)? {
182 return Ok(digest);
183 }
184 let archive_bytes = encoded_candidate_bytes(tree)?;
185 require_capacity_for_new_candidate(&inventory, archive_bytes, self.capacity)?;
186 let staging_path = self.staging_path();
187 let staging = StagingPath::new(staging_path.clone());
188 write_staging_candidate(&staging_path, &digest, tree, archive_bytes)?;
189 self.publish_staged_candidate(&staging_path, &digest, tree)?;
190 staging.cleanup()?;
191 Ok(digest)
192 }
193
194 pub fn load(
195 &self,
196 digest: &ContentTreeDigest,
197 ) -> Result<DiscoveredContentTree, ContentCandidateStoreError> {
198 let path = self.candidate_path(digest);
199 let file = open_candidate_file(&path)?;
200 let metadata = file.metadata().map_err(ContentCandidateStoreError::Io)?;
201 if u128::from(metadata.len()) > maximum_archive_bytes(self.limits) {
202 return Err(ContentCandidateStoreError::LimitExceeded(
203 "candidate archive exceeds its configured maximum size",
204 ));
205 }
206 decode_candidate(BufReader::new(file), metadata.len(), digest, self.limits)
207 }
208
209 pub fn load_all(&self) -> Result<Vec<RetainedContentCandidate>, ContentCandidateStoreError> {
210 let _admission = self
211 .admission
212 .lock()
213 .map_err(|_| ContentCandidateStoreError::StoreLockPoisoned)?;
214 self.inventory()?
215 .candidates
216 .into_iter()
217 .map(|(_, digest)| {
218 let tree = self.load(&digest)?;
219 Ok(RetainedContentCandidate { digest, tree })
220 })
221 .collect()
222 }
223
224 fn inventory(&self) -> Result<CandidateStoreInventory, ContentCandidateStoreError> {
225 inventory(&self.root, self.capacity)
226 }
227
228 fn confirm_existing_candidate(
229 &self,
230 inventory: &CandidateStoreInventory,
231 digest: &ContentTreeDigest,
232 tree: &DiscoveredContentTree,
233 ) -> Result<bool, ContentCandidateStoreError> {
234 if !inventory
235 .candidates
236 .iter()
237 .any(|(_, existing)| existing == digest)
238 {
239 return Ok(false);
240 }
241 let retained = self.load(digest)?;
242 if !canonically_equal(&retained, tree) {
243 return Err(ContentCandidateStoreError::Collision);
244 }
245 Ok(true)
246 }
247
248 fn publish_staged_candidate(
249 &self,
250 staging_path: &Path,
251 digest: &ContentTreeDigest,
252 tree: &DiscoveredContentTree,
253 ) -> Result<(), ContentCandidateStoreError> {
254 match publish_no_replace(staging_path, &self.candidate_path(digest))? {
255 PublishOutcome::Published => {
256 sync_directory(&self.root).map_err(ContentCandidateStoreError::Io)
257 }
258 PublishOutcome::AlreadyExists => {
259 let retained = self.load(digest)?;
260 if canonically_equal(&retained, tree) {
261 Ok(())
262 } else {
263 Err(ContentCandidateStoreError::Collision)
264 }
265 }
266 }
267 }
268
269 fn candidate_path(&self, digest: &ContentTreeDigest) -> PathBuf {
270 self.root.join(format!("{digest}{CANDIDATE_SUFFIX}"))
271 }
272
273 fn staging_path(&self) -> PathBuf {
274 self.root.join(format!(
275 "{STAGING_PREFIX}{}{STAGING_SUFFIX}",
276 Uuid::new_v4().hyphenated()
277 ))
278 }
279}
280
281fn write_staging_candidate(
282 path: &Path,
283 digest: &ContentTreeDigest,
284 tree: &DiscoveredContentTree,
285 expected_bytes: u64,
286) -> Result<(), ContentCandidateStoreError> {
287 let mut file = create_staging_file(path)?;
288 {
289 let mut writer = BufWriter::new(&mut file);
290 encode_candidate(&mut writer, digest, tree)?;
291 writer.flush().map_err(ContentCandidateStoreError::Io)?;
292 }
293 file.sync_all().map_err(ContentCandidateStoreError::Io)?;
294 if file
295 .metadata()
296 .map_err(ContentCandidateStoreError::Io)?
297 .len()
298 != expected_bytes
299 {
300 return Err(ContentCandidateStoreError::InvalidArchive(
301 "candidate archive size is inconsistent",
302 ));
303 }
304 Ok(())
305}
306
307#[derive(Debug, Error)]
308pub enum ContentCandidateStoreError {
309 #[error("the content candidate directory is unavailable")]
310 Directory(#[source] io::Error),
311 #[error("content candidate storage failed")]
312 Io(#[source] io::Error),
313 #[error("the content candidate store contains an unsafe entry")]
314 UnsafeEntry,
315 #[error("the content candidate store contains an unexpected entry")]
316 UnexpectedEntry,
317 #[error("the content candidate store exceeds its fixed capacity: {0}")]
318 CapacityExceeded(&'static str),
319 #[error("the content candidate store admission lock is poisoned")]
320 StoreLockPoisoned,
321 #[error("the content candidate archive is invalid: {0}")]
322 InvalidArchive(&'static str),
323 #[error("the content candidate exceeds configured limits: {0}")]
324 LimitExceeded(&'static str),
325 #[error("the content candidate digest does not match its archive or content")]
326 DigestMismatch,
327 #[error("the content candidate key is already occupied by different bytes")]
328 Collision,
329}
330
331fn inventory(
332 root: &Path,
333 capacity: CandidateStoreCapacity,
334) -> Result<CandidateStoreInventory, ContentCandidateStoreError> {
335 let mut candidates = Vec::new();
336 let mut entries = 0_usize;
337 let mut bytes = 0_u64;
338 for entry in fs::read_dir(root).map_err(ContentCandidateStoreError::Io)? {
339 let entry = entry.map_err(ContentCandidateStoreError::Io)?;
340 entries = entries
341 .checked_add(1)
342 .ok_or(ContentCandidateStoreError::CapacityExceeded(
343 "candidate-store entry count overflowed",
344 ))?;
345 if entries > capacity.entries {
346 return Err(ContentCandidateStoreError::CapacityExceeded(
347 "candidate-store entry count is full",
348 ));
349 }
350 let name = entry
351 .file_name()
352 .into_string()
353 .map_err(|_| ContentCandidateStoreError::UnsafeEntry)?;
354 let file = open_candidate_file(&entry.path())?;
355 let file_bytes = file
356 .metadata()
357 .map_err(ContentCandidateStoreError::Io)?
358 .len();
359 bytes =
360 bytes
361 .checked_add(file_bytes)
362 .ok_or(ContentCandidateStoreError::CapacityExceeded(
363 "candidate-store byte count overflowed",
364 ))?;
365 if bytes > capacity.bytes {
366 return Err(ContentCandidateStoreError::CapacityExceeded(
367 "candidate-store byte capacity is full",
368 ));
369 }
370 if is_staging_name(&name) {
371 continue;
372 }
373 let digest =
374 parse_candidate_name(&name).ok_or(ContentCandidateStoreError::UnexpectedEntry)?;
375 candidates.push((name, digest));
376 }
377 candidates.sort_unstable_by(|left, right| left.0.cmp(&right.0));
378 Ok(CandidateStoreInventory {
379 candidates,
380 entries,
381 bytes,
382 })
383}
384
385fn require_capacity_for_new_candidate(
386 inventory: &CandidateStoreInventory,
387 archive_bytes: u64,
388 capacity: CandidateStoreCapacity,
389) -> Result<(), ContentCandidateStoreError> {
390 let entries =
391 inventory
392 .entries
393 .checked_add(1)
394 .ok_or(ContentCandidateStoreError::CapacityExceeded(
395 "candidate-store entry count overflowed",
396 ))?;
397 if entries > capacity.entries {
398 return Err(ContentCandidateStoreError::CapacityExceeded(
399 "candidate-store entry count is full",
400 ));
401 }
402 let bytes = inventory.bytes.checked_add(archive_bytes).ok_or(
403 ContentCandidateStoreError::CapacityExceeded("candidate-store byte count overflowed"),
404 )?;
405 if bytes > capacity.bytes {
406 return Err(ContentCandidateStoreError::CapacityExceeded(
407 "candidate-store byte capacity is full",
408 ));
409 }
410 Ok(())
411}
412
413fn encoded_candidate_bytes(
414 tree: &DiscoveredContentTree,
415) -> Result<u64, ContentCandidateStoreError> {
416 let post_count = u64::try_from(tree.posts.len()).map_err(|_| {
417 ContentCandidateStoreError::LimitExceeded("candidate archive size cannot be represented")
418 })?;
419 let asset_count = u64::try_from(tree.assets.len()).map_err(|_| {
420 ContentCandidateStoreError::LimitExceeded("candidate archive size cannot be represented")
421 })?;
422 let path_bytes = std::iter::once(tree.publication.path.as_str().len())
423 .chain(tree.posts.iter().map(|post| post.path.as_str().len()))
424 .chain(tree.assets.iter().map(|asset| asset.path.as_str().len()))
425 .try_fold(0_u64, |total, length| {
426 total.checked_add(u64::try_from(length).ok()?)
427 })
428 .ok_or(ContentCandidateStoreError::LimitExceeded(
429 "candidate archive size cannot be represented",
430 ))?;
431 ARCHIVE_HEADER_BYTES
432 .checked_add(SEQUENCE_LENGTH_BYTES * 2)
433 .and_then(|total| total.checked_add(PUBLICATION_RECORD_OVERHEAD))
434 .and_then(|total| total.checked_add(post_count.checked_mul(POST_RECORD_OVERHEAD)?))
435 .and_then(|total| total.checked_add(asset_count.checked_mul(ASSET_RECORD_OVERHEAD)?))
436 .and_then(|total| total.checked_add(path_bytes))
437 .and_then(|total| total.checked_add(tree.total_bytes))
438 .ok_or(ContentCandidateStoreError::LimitExceeded(
439 "candidate archive size cannot be represented",
440 ))
441}
442
443fn canonically_equal(left: &DiscoveredContentTree, right: &DiscoveredContentTree) -> bool {
444 if left.publication != right.publication
445 || left.total_bytes != right.total_bytes
446 || left.posts.len() != right.posts.len()
447 || left.assets.len() != right.assets.len()
448 {
449 return false;
450 }
451 let mut left_posts = left.posts.iter().collect::<Vec<_>>();
452 let mut right_posts = right.posts.iter().collect::<Vec<_>>();
453 left_posts.sort_unstable_by(compare_posts);
454 right_posts.sort_unstable_by(compare_posts);
455 let mut left_assets = left.assets.iter().collect::<Vec<_>>();
456 let mut right_assets = right.assets.iter().collect::<Vec<_>>();
457 left_assets.sort_unstable_by(compare_assets);
458 right_assets.sort_unstable_by(compare_assets);
459 left_posts == right_posts && left_assets == right_assets
460}
461
462fn encode_candidate(
463 writer: &mut impl Write,
464 digest: &ContentTreeDigest,
465 tree: &DiscoveredContentTree,
466) -> Result<(), ContentCandidateStoreError> {
467 write_all(writer, ARCHIVE_MAGIC)?;
468 write_all(writer, &ARCHIVE_VERSION.to_be_bytes())?;
469 write_all(writer, digest.as_bytes())?;
470 write_all(writer, &tree.total_bytes.to_be_bytes())?;
471
472 write_string(writer, tree.publication.path.as_str())?;
473 write_bytes(writer, tree.publication.source.as_bytes())?;
474
475 let mut posts: Vec<_> = tree.posts.iter().collect();
476 posts.sort_unstable_by(compare_posts);
477 write_u32(writer, posts.len())?;
478 for post in posts {
479 write_all(writer, &[collection_tag(post.collection)])?;
480 write_string(writer, post.path.as_str())?;
481 write_bytes(writer, post.source.as_bytes())?;
482 }
483
484 let mut assets: Vec<_> = tree.assets.iter().collect();
485 assets.sort_unstable_by(compare_assets);
486 write_u32(writer, assets.len())?;
487 for asset in assets {
488 write_string(writer, asset.path.as_str())?;
489 write_bytes(writer, &asset.bytes)?;
490 }
491 Ok(())
492}
493
494fn decode_candidate(
495 reader: BufReader<File>,
496 archive_bytes: u64,
497 expected_digest: &ContentTreeDigest,
498 limits: ContentTreeLimits,
499) -> Result<DiscoveredContentTree, ContentCandidateStoreError> {
500 let mut decoder = CandidateDecoder::open(reader, archive_bytes, expected_digest, limits)?;
501 let publication = decoder.publication()?;
502 let posts = decoder.posts()?;
503 let assets = decoder.assets(posts.len())?;
504 decoder.finish(publication, posts, assets)
505}
506
507struct CandidateDecoder {
508 archive: Decoder,
509 limits: ContentTreeLimits,
510 stored_digest: ContentTreeDigest,
511 stored_total: u64,
512 total_bytes: u64,
513 paths: PathRegistry,
514}
515
516impl CandidateDecoder {
517 fn open(
518 reader: BufReader<File>,
519 archive_bytes: u64,
520 expected_digest: &ContentTreeDigest,
521 limits: ContentTreeLimits,
522 ) -> Result<Self, ContentCandidateStoreError> {
523 let mut archive = Decoder {
524 reader,
525 remaining: archive_bytes,
526 };
527 if archive.fixed::<17>()? != *ARCHIVE_MAGIC {
528 return Err(ContentCandidateStoreError::InvalidArchive(
529 "unknown archive magic",
530 ));
531 }
532 if u16::from_be_bytes(archive.fixed()?) != ARCHIVE_VERSION {
533 return Err(ContentCandidateStoreError::InvalidArchive(
534 "unsupported archive version",
535 ));
536 }
537 let stored_digest = ContentTreeDigest::from_bytes(archive.fixed()?);
538 if &stored_digest != expected_digest {
539 return Err(ContentCandidateStoreError::DigestMismatch);
540 }
541 let stored_total = u64::from_be_bytes(archive.fixed()?);
542 Ok(Self {
543 archive,
544 limits,
545 stored_digest,
546 stored_total,
547 total_bytes: 0,
548 paths: PathRegistry::default(),
549 })
550 }
551
552 fn publication(&mut self) -> Result<DiscoveredPublication, ContentCandidateStoreError> {
553 let path = self.archive.string(self.limits.path_bytes.get())?;
554 validate_publication_path(&path, self.limits)?;
555 self.paths.register(&path)?;
556 let source = self
557 .archive
558 .utf8_bytes(self.limits.publication_file_bytes.get())?;
559 self.total_bytes = add_total(0, source.len(), self.limits)?;
560 Ok(DiscoveredPublication {
561 path: LogicalContentPath::new(path),
562 source: source.into_boxed_str(),
563 })
564 }
565
566 fn posts(&mut self) -> Result<Vec<DiscoveredPost>, ContentCandidateStoreError> {
567 let count = self
568 .archive
569 .count(self.limits.entries.get().saturating_sub(1))?;
570 let mut posts = Vec::new();
571 let mut previous_path: Option<String> = None;
572 for _ in 0..count {
573 let post = self.post(previous_path.as_deref())?;
574 previous_path = Some(post.path.as_str().to_owned());
575 posts.push(post);
576 }
577 Ok(posts)
578 }
579
580 fn post(
581 &mut self,
582 previous_path: Option<&str>,
583 ) -> Result<DiscoveredPost, ContentCandidateStoreError> {
584 let collection = parse_collection(self.archive.byte()?)?;
585 let path = self.archive.string(self.limits.path_bytes.get())?;
586 validate_post_path(&path, collection, self.limits)?;
587 ensure_canonical_path_order(previous_path, &path)?;
588 self.paths.register(&path)?;
589 let source = self.archive.utf8_bytes(self.limits.post_file_bytes.get())?;
590 self.total_bytes = add_total(self.total_bytes, source.len(), self.limits)?;
591 Ok(DiscoveredPost {
592 path: LogicalContentPath::new(path),
593 collection,
594 source: source.into_boxed_str(),
595 })
596 }
597
598 fn assets(
599 &mut self,
600 post_count: usize,
601 ) -> Result<Vec<DiscoveredAsset>, ContentCandidateStoreError> {
602 let remaining_entries = self
603 .limits
604 .entries
605 .get()
606 .saturating_sub(1)
607 .saturating_sub(post_count);
608 let count = self.archive.count(remaining_entries)?;
609 let mut assets = Vec::new();
610 let mut previous_path: Option<String> = None;
611 for _ in 0..count {
612 let asset = self.asset(previous_path.as_deref())?;
613 previous_path = Some(asset.path.as_str().to_owned());
614 assets.push(asset);
615 }
616 Ok(assets)
617 }
618
619 fn asset(
620 &mut self,
621 previous_path: Option<&str>,
622 ) -> Result<DiscoveredAsset, ContentCandidateStoreError> {
623 let path = self.archive.string(self.limits.path_bytes.get())?;
624 validate_asset_path(&path, self.limits)?;
625 ensure_canonical_path_order(previous_path, &path)?;
626 self.paths.register(&path)?;
627 let bytes = self.archive.bytes(self.limits.asset_file_bytes.get())?;
628 self.total_bytes = add_total(self.total_bytes, bytes.len(), self.limits)?;
629 Ok(DiscoveredAsset {
630 path: LogicalAssetPath::parse(&path).map_err(|_| {
631 ContentCandidateStoreError::InvalidArchive("invalid logical asset path")
632 })?,
633 bytes: Arc::from(bytes),
634 })
635 }
636
637 fn finish(
638 self,
639 publication: DiscoveredPublication,
640 posts: Vec<DiscoveredPost>,
641 assets: Vec<DiscoveredAsset>,
642 ) -> Result<DiscoveredContentTree, ContentCandidateStoreError> {
643 self.archive.end()?;
644 if self.total_bytes != self.stored_total {
645 return Err(ContentCandidateStoreError::InvalidArchive(
646 "stored tree byte count is inconsistent",
647 ));
648 }
649 let tree = DiscoveredContentTree::new(publication, posts, assets, self.total_bytes);
650 if tree.digest() != self.stored_digest {
651 return Err(ContentCandidateStoreError::DigestMismatch);
652 }
653 Ok(tree)
654 }
655}
656
657fn validate_tree(
658 tree: &DiscoveredContentTree,
659 limits: ContentTreeLimits,
660) -> Result<(), ContentCandidateStoreError> {
661 validate_tree_entry_count(tree, limits)?;
662 validate_publication_path(tree.publication.path.as_str(), limits)?;
663 ensure_file_size(
664 tree.publication.source.len(),
665 limits.publication_file_bytes.get(),
666 )?;
667 let mut validation = TreeValidation::new(tree.publication.source.len(), limits)?;
668 validation.paths.register(tree.publication.path.as_str())?;
669 validation.posts(&tree.posts)?;
670 validation.assets(&tree.assets)?;
671 if validation.total_bytes != tree.total_bytes {
672 return Err(ContentCandidateStoreError::InvalidArchive(
673 "tree byte count is inconsistent",
674 ));
675 }
676 Ok(())
677}
678
679fn validate_tree_entry_count(
680 tree: &DiscoveredContentTree,
681 limits: ContentTreeLimits,
682) -> Result<(), ContentCandidateStoreError> {
683 let count = 1usize
684 .checked_add(tree.posts.len())
685 .and_then(|count| count.checked_add(tree.assets.len()))
686 .ok_or(ContentCandidateStoreError::LimitExceeded(
687 "candidate entry count overflows",
688 ))?;
689 if count > limits.entries.get()
690 || tree.posts.len() > u32::MAX as usize
691 || tree.assets.len() > u32::MAX as usize
692 {
693 return Err(ContentCandidateStoreError::LimitExceeded(
694 "candidate has too many entries",
695 ));
696 }
697 Ok(())
698}
699
700struct TreeValidation {
701 limits: ContentTreeLimits,
702 total_bytes: u64,
703 paths: PathRegistry,
704}
705
706impl TreeValidation {
707 fn new(
708 publication_bytes: usize,
709 limits: ContentTreeLimits,
710 ) -> Result<Self, ContentCandidateStoreError> {
711 let total_bytes = add_total(0, publication_bytes, limits)?;
712 Ok(Self {
713 limits,
714 total_bytes,
715 paths: PathRegistry::default(),
716 })
717 }
718
719 fn posts(&mut self, posts: &[DiscoveredPost]) -> Result<(), ContentCandidateStoreError> {
720 for post in posts {
721 validate_post_path(post.path.as_str(), post.collection, self.limits)?;
722 ensure_file_size(post.source.len(), self.limits.post_file_bytes.get())?;
723 self.paths.register(post.path.as_str())?;
724 self.total_bytes = add_total(self.total_bytes, post.source.len(), self.limits)?;
725 }
726 Ok(())
727 }
728
729 fn assets(&mut self, assets: &[DiscoveredAsset]) -> Result<(), ContentCandidateStoreError> {
730 for asset in assets {
731 validate_asset_path(asset.path.as_str(), self.limits)?;
732 ensure_file_size(asset.bytes.len(), self.limits.asset_file_bytes.get())?;
733 self.paths.register(asset.path.as_str())?;
734 self.total_bytes = add_total(self.total_bytes, asset.bytes.len(), self.limits)?;
735 }
736 Ok(())
737 }
738}
739
740fn validate_publication_path(
741 path: &str,
742 limits: ContentTreeLimits,
743) -> Result<(), ContentCandidateStoreError> {
744 validate_portable_path(path, limits)?;
745 if path != "publication.toml" {
746 return Err(ContentCandidateStoreError::InvalidArchive(
747 "publication must use the root publication.toml path",
748 ));
749 }
750 Ok(())
751}
752
753fn validate_post_path(
754 path: &str,
755 collection: PostCollection,
756 limits: ContentTreeLimits,
757) -> Result<(), ContentCandidateStoreError> {
758 validate_portable_path(path, limits)?;
759 if !collection.contains_path(path) || !path.ends_with(".md") {
760 return Err(ContentCandidateStoreError::InvalidArchive(
761 "post path does not match its collection",
762 ));
763 }
764 Ok(())
765}
766
767fn validate_asset_path(
768 path: &str,
769 limits: ContentTreeLimits,
770) -> Result<(), ContentCandidateStoreError> {
771 let portable = validate_portable_path(path, limits)?;
772 LogicalAssetPath::parse(portable.as_str()).map_err(|_| {
773 ContentCandidateStoreError::InvalidArchive("asset path is outside the asset namespace")
774 })?;
775 Ok(())
776}
777
778fn validate_portable_path(
779 path: &str,
780 limits: ContentTreeLimits,
781) -> Result<PortableLogicalPath, ContentCandidateStoreError> {
782 let portable =
783 PortableLogicalPath::parse(path, limits.path_bytes.get()).map_err(|error| match error {
784 super::LogicalTreePathError::TooLong => ContentCandidateStoreError::LimitExceeded(
785 "candidate logical path exceeds its configured limit",
786 ),
787 _ => ContentCandidateStoreError::InvalidArchive("invalid portable logical path"),
788 })?;
789 if path.split('/').count() > limits.depth.get() {
790 return Err(ContentCandidateStoreError::LimitExceeded(
791 "candidate logical path exceeds its configured depth",
792 ));
793 }
794 Ok(portable)
795}
796
797#[derive(Default)]
798struct PathRegistry {
799 exact: BTreeSet<String>,
800 folded_prefixes: BTreeMap<String, String>,
801}
802
803impl PathRegistry {
804 fn register(&mut self, path: &str) -> Result<(), ContentCandidateStoreError> {
805 if !self.exact.insert(path.to_owned()) {
806 return Err(ContentCandidateStoreError::InvalidArchive(
807 "candidate contains duplicate or case-colliding paths",
808 ));
809 }
810 for end in path
811 .match_indices('/')
812 .map(|(index, _)| index)
813 .chain(std::iter::once(path.len()))
814 {
815 let prefix = &path[..end];
816 let folded = prefix.to_ascii_lowercase();
817 if self
818 .folded_prefixes
819 .get(&folded)
820 .is_some_and(|existing| existing != prefix)
821 {
822 return Err(ContentCandidateStoreError::InvalidArchive(
823 "candidate contains duplicate or case-colliding paths",
824 ));
825 }
826 self.folded_prefixes
827 .entry(folded)
828 .or_insert_with(|| prefix.to_owned());
829 }
830 Ok(())
831 }
832}
833
834fn ensure_canonical_path_order(
835 previous: Option<&str>,
836 current: &str,
837) -> Result<(), ContentCandidateStoreError> {
838 if previous.is_some_and(|previous| previous >= current) {
839 return Err(ContentCandidateStoreError::InvalidArchive(
840 "candidate records are not in canonical order",
841 ));
842 }
843 Ok(())
844}
845
846fn ensure_file_size(length: usize, limit: u64) -> Result<(), ContentCandidateStoreError> {
847 let length = u64::try_from(length).map_err(|_| {
848 ContentCandidateStoreError::LimitExceeded("candidate file length cannot be represented")
849 })?;
850 if length > limit {
851 return Err(ContentCandidateStoreError::LimitExceeded(
852 "candidate file exceeds its configured limit",
853 ));
854 }
855 Ok(())
856}
857
858fn ensure_total_within_limit(
859 total: u64,
860 limits: ContentTreeLimits,
861) -> Result<(), ContentCandidateStoreError> {
862 if total > limits.total_tree_bytes.get() {
863 return Err(ContentCandidateStoreError::LimitExceeded(
864 "candidate tree exceeds its configured byte limit",
865 ));
866 }
867 Ok(())
868}
869
870fn add_total(
871 total: u64,
872 length: usize,
873 limits: ContentTreeLimits,
874) -> Result<u64, ContentCandidateStoreError> {
875 let length = u64::try_from(length).map_err(|_| {
876 ContentCandidateStoreError::LimitExceeded("candidate tree byte count overflows")
877 })?;
878 let total = total
879 .checked_add(length)
880 .ok_or(ContentCandidateStoreError::LimitExceeded(
881 "candidate tree byte count overflows",
882 ))?;
883 ensure_total_within_limit(total, limits)?;
884 Ok(total)
885}
886
887fn maximum_archive_bytes(limits: ContentTreeLimits) -> u128 {
888 let entries = limits.entries.get() as u128;
889 let path_bytes = limits.path_bytes.get() as u128;
890 u128::from(ARCHIVE_HEADER_BYTES)
891 + u128::from(SEQUENCE_LENGTH_BYTES * 2)
892 + u128::from(PUBLICATION_RECORD_OVERHEAD)
893 + entries * (path_bytes + u128::from(POST_RECORD_OVERHEAD.max(ASSET_RECORD_OVERHEAD)))
894 + u128::from(limits.total_tree_bytes.get())
895}
896
897fn parse_collection(tag: u8) -> Result<PostCollection, ContentCandidateStoreError> {
898 match tag {
899 POSTS_COLLECTION => Ok(PostCollection::Posts),
900 DRAFTS_COLLECTION => Ok(PostCollection::Drafts),
901 _ => Err(ContentCandidateStoreError::InvalidArchive(
902 "unknown post collection tag",
903 )),
904 }
905}
906
907fn write_all(writer: &mut impl Write, bytes: &[u8]) -> Result<(), ContentCandidateStoreError> {
908 writer
909 .write_all(bytes)
910 .map_err(ContentCandidateStoreError::Io)
911}
912
913fn write_u32(writer: &mut impl Write, value: usize) -> Result<(), ContentCandidateStoreError> {
914 let value = u32::try_from(value).map_err(|_| {
915 ContentCandidateStoreError::LimitExceeded("candidate sequence is too large")
916 })?;
917 write_all(writer, &value.to_be_bytes())
918}
919
920fn write_string(writer: &mut impl Write, value: &str) -> Result<(), ContentCandidateStoreError> {
921 let length = u32::try_from(value.len()).map_err(|_| {
922 ContentCandidateStoreError::LimitExceeded("candidate logical path is too large")
923 })?;
924 write_all(writer, &length.to_be_bytes())?;
925 write_all(writer, value.as_bytes())
926}
927
928fn write_bytes(writer: &mut impl Write, bytes: &[u8]) -> Result<(), ContentCandidateStoreError> {
929 let length = u64::try_from(bytes.len()).map_err(|_| {
930 ContentCandidateStoreError::LimitExceeded("candidate file length cannot be represented")
931 })?;
932 write_all(writer, &length.to_be_bytes())?;
933 write_all(writer, bytes)
934}
935
936struct Decoder {
937 reader: BufReader<File>,
938 remaining: u64,
939}
940
941impl Decoder {
942 fn fixed<const LENGTH: usize>(&mut self) -> Result<[u8; LENGTH], ContentCandidateStoreError> {
943 let mut bytes = [0; LENGTH];
944 self.read_exact(&mut bytes)?;
945 Ok(bytes)
946 }
947
948 fn byte(&mut self) -> Result<u8, ContentCandidateStoreError> {
949 Ok(self.fixed::<1>()?[0])
950 }
951
952 fn count(&mut self, limit: usize) -> Result<usize, ContentCandidateStoreError> {
953 let count = u32::from_be_bytes(self.fixed()?) as usize;
954 if count > limit {
955 return Err(ContentCandidateStoreError::LimitExceeded(
956 "candidate has too many entries",
957 ));
958 }
959 Ok(count)
960 }
961
962 fn string(&mut self, limit: usize) -> Result<String, ContentCandidateStoreError> {
963 let length = u32::from_be_bytes(self.fixed()?) as usize;
964 if length > limit {
965 return Err(ContentCandidateStoreError::LimitExceeded(
966 "candidate logical path exceeds its configured limit",
967 ));
968 }
969 String::from_utf8(self.read_vec(length)?).map_err(|_| {
970 ContentCandidateStoreError::InvalidArchive("logical path is not valid UTF-8")
971 })
972 }
973
974 fn utf8_bytes(&mut self, limit: u64) -> Result<String, ContentCandidateStoreError> {
975 String::from_utf8(self.bytes(limit)?).map_err(|_| {
976 ContentCandidateStoreError::InvalidArchive("authored source is not valid UTF-8")
977 })
978 }
979
980 fn bytes(&mut self, limit: u64) -> Result<Vec<u8>, ContentCandidateStoreError> {
981 let length = u64::from_be_bytes(self.fixed()?);
982 if length > limit {
983 return Err(ContentCandidateStoreError::LimitExceeded(
984 "candidate file exceeds its configured limit",
985 ));
986 }
987 let length = usize::try_from(length).map_err(|_| {
988 ContentCandidateStoreError::LimitExceeded("candidate file cannot fit in memory")
989 })?;
990 self.read_vec(length)
991 }
992
993 fn read_vec(&mut self, length: usize) -> Result<Vec<u8>, ContentCandidateStoreError> {
994 self.ensure_available(length)?;
995 let mut bytes = vec![0; length];
996 self.read_exact(&mut bytes)?;
997 Ok(bytes)
998 }
999
1000 fn read_exact(&mut self, bytes: &mut [u8]) -> Result<(), ContentCandidateStoreError> {
1001 self.ensure_available(bytes.len())?;
1002 self.reader.read_exact(bytes).map_err(|error| {
1003 if error.kind() == io::ErrorKind::UnexpectedEof {
1004 ContentCandidateStoreError::InvalidArchive("candidate archive is truncated")
1005 } else {
1006 ContentCandidateStoreError::Io(error)
1007 }
1008 })?;
1009 self.remaining -= bytes.len() as u64;
1010 Ok(())
1011 }
1012
1013 fn ensure_available(&self, length: usize) -> Result<(), ContentCandidateStoreError> {
1014 if u64::try_from(length).map_or(true, |length| length > self.remaining) {
1015 return Err(ContentCandidateStoreError::InvalidArchive(
1016 "candidate archive is truncated",
1017 ));
1018 }
1019 Ok(())
1020 }
1021
1022 fn end(mut self) -> Result<(), ContentCandidateStoreError> {
1023 if self.remaining != 0 {
1024 return Err(ContentCandidateStoreError::InvalidArchive(
1025 "candidate archive has trailing bytes",
1026 ));
1027 }
1028 let mut byte = [0];
1029 match self.reader.read(&mut byte) {
1030 Ok(0) => Ok(()),
1031 Ok(_) => Err(ContentCandidateStoreError::InvalidArchive(
1032 "candidate archive has trailing bytes",
1033 )),
1034 Err(error) => Err(ContentCandidateStoreError::Io(error)),
1035 }
1036 }
1037}
1038
1039fn create_staging_file(path: &Path) -> Result<File, ContentCandidateStoreError> {
1040 let mut options = OpenOptions::new();
1041 options.write(true).create_new(true);
1042 #[cfg(unix)]
1043 {
1044 use std::os::unix::fs::OpenOptionsExt as _;
1045 options.mode(0o600);
1046 }
1047 let file = options.open(path).map_err(ContentCandidateStoreError::Io)?;
1048 validate_private_file_metadata(&file.metadata().map_err(ContentCandidateStoreError::Io)?)?;
1049 Ok(file)
1050}
1051
1052fn open_candidate_file(path: &Path) -> Result<File, ContentCandidateStoreError> {
1053 let before = fs::symlink_metadata(path).map_err(ContentCandidateStoreError::Io)?;
1054 validate_private_file_metadata(&before)?;
1055
1056 #[cfg(any(target_os = "linux", target_os = "macos"))]
1057 let file: File = rustix::fs::open(
1058 path,
1059 rustix::fs::OFlags::RDONLY
1060 | rustix::fs::OFlags::CLOEXEC
1061 | rustix::fs::OFlags::NOFOLLOW
1062 | rustix::fs::OFlags::NONBLOCK,
1063 rustix::fs::Mode::empty(),
1064 )
1065 .map_err(|error| ContentCandidateStoreError::Io(error.into()))?
1066 .into();
1067
1068 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1069 let file = File::open(path).map_err(ContentCandidateStoreError::Io)?;
1070
1071 let after = file.metadata().map_err(ContentCandidateStoreError::Io)?;
1072 validate_private_file_metadata(&after)?;
1073 validate_same_file(&before, &after)?;
1074 Ok(file)
1075}
1076
1077#[cfg(unix)]
1078fn validate_private_file_metadata(
1079 metadata: &fs::Metadata,
1080) -> Result<(), ContentCandidateStoreError> {
1081 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1082
1083 if !metadata.is_file()
1084 || metadata.file_type().is_symlink()
1085 || metadata.nlink() != 1
1086 || metadata.uid() != rustix::process::geteuid().as_raw()
1087 || metadata.permissions().mode() & 0o077 != 0
1088 {
1089 return Err(ContentCandidateStoreError::UnsafeEntry);
1090 }
1091 Ok(())
1092}
1093
1094#[cfg(not(unix))]
1095fn validate_private_file_metadata(
1096 metadata: &fs::Metadata,
1097) -> Result<(), ContentCandidateStoreError> {
1098 if !metadata.is_file() || metadata.file_type().is_symlink() {
1099 return Err(ContentCandidateStoreError::UnsafeEntry);
1100 }
1101 Ok(())
1102}
1103
1104#[cfg(unix)]
1105fn validate_same_file(
1106 before: &fs::Metadata,
1107 after: &fs::Metadata,
1108) -> Result<(), ContentCandidateStoreError> {
1109 use std::os::unix::fs::MetadataExt as _;
1110
1111 if before.dev() != after.dev() || before.ino() != after.ino() {
1112 return Err(ContentCandidateStoreError::UnsafeEntry);
1113 }
1114 Ok(())
1115}
1116
1117#[cfg(not(unix))]
1118fn validate_same_file(
1119 _before: &fs::Metadata,
1120 _after: &fs::Metadata,
1121) -> Result<(), ContentCandidateStoreError> {
1122 Ok(())
1123}
1124
1125enum PublishOutcome {
1126 Published,
1127 AlreadyExists,
1128}
1129
1130#[cfg(any(target_os = "linux", target_os = "macos"))]
1131fn publish_no_replace(
1132 from: &Path,
1133 to: &Path,
1134) -> Result<PublishOutcome, ContentCandidateStoreError> {
1135 match rustix::fs::renameat_with(
1136 rustix::fs::CWD,
1137 from,
1138 rustix::fs::CWD,
1139 to,
1140 rustix::fs::RenameFlags::NOREPLACE,
1141 ) {
1142 Ok(()) => Ok(PublishOutcome::Published),
1143 Err(rustix::io::Errno::EXIST) => Ok(PublishOutcome::AlreadyExists),
1144 Err(error) => Err(ContentCandidateStoreError::Io(error.into())),
1145 }
1146}
1147
1148#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1149fn publish_no_replace(
1150 from: &Path,
1151 to: &Path,
1152) -> Result<PublishOutcome, ContentCandidateStoreError> {
1153 match fs::hard_link(from, to) {
1154 Ok(()) => {
1155 fs::remove_file(from).map_err(ContentCandidateStoreError::Io)?;
1156 Ok(PublishOutcome::Published)
1157 }
1158 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
1159 Ok(PublishOutcome::AlreadyExists)
1160 }
1161 Err(error) => Err(ContentCandidateStoreError::Io(error)),
1162 }
1163}
1164
1165#[cfg(any(target_os = "linux", target_os = "macos"))]
1166fn sync_directory(path: &Path) -> io::Result<()> {
1167 let directory: File = rustix::fs::open(
1168 path,
1169 rustix::fs::OFlags::RDONLY
1170 | rustix::fs::OFlags::DIRECTORY
1171 | rustix::fs::OFlags::CLOEXEC
1172 | rustix::fs::OFlags::NOFOLLOW,
1173 rustix::fs::Mode::empty(),
1174 )?
1175 .into();
1176 directory.sync_all()
1177}
1178
1179#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1180fn sync_directory(_path: &Path) -> io::Result<()> {
1181 Ok(())
1182}
1183
1184struct StagingPath {
1185 path: PathBuf,
1186 cleaned: bool,
1187}
1188
1189impl StagingPath {
1190 fn new(path: PathBuf) -> Self {
1191 Self {
1192 path,
1193 cleaned: false,
1194 }
1195 }
1196
1197 fn cleanup(mut self) -> Result<(), ContentCandidateStoreError> {
1198 match fs::remove_file(&self.path) {
1199 Ok(()) => sync_directory(self.path.parent().ok_or(
1200 ContentCandidateStoreError::InvalidArchive("candidate staging path has no parent"),
1201 )?)
1202 .map_err(ContentCandidateStoreError::Io)?,
1203 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1204 Err(error) => return Err(ContentCandidateStoreError::Io(error)),
1205 }
1206 self.cleaned = true;
1207 Ok(())
1208 }
1209}
1210
1211impl Drop for StagingPath {
1212 fn drop(&mut self) {
1213 if self.cleaned {
1214 return;
1215 }
1216 match fs::remove_file(&self.path) {
1217 Ok(()) => {
1218 let _ = self.path.parent().map(sync_directory);
1219 }
1220 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1221 Err(_) => {}
1222 }
1223 }
1224}
1225
1226fn parse_candidate_name(name: &str) -> Option<ContentTreeDigest> {
1227 let digest = name
1228 .strip_prefix(DIGEST_PREFIX)?
1229 .strip_suffix(CANDIDATE_SUFFIX)?;
1230 if digest.len() != 64 {
1231 return None;
1232 }
1233 let mut bytes = [0; 32];
1234 for (destination, pair) in bytes.iter_mut().zip(digest.as_bytes().as_chunks::<2>().0) {
1235 *destination = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
1236 }
1237 Some(ContentTreeDigest::from_bytes(bytes))
1238}
1239
1240const fn hex_nibble(byte: u8) -> Option<u8> {
1241 match byte {
1242 b'0'..=b'9' => Some(byte - b'0'),
1243 b'a'..=b'f' => Some(byte - b'a' + 10),
1244 _ => None,
1245 }
1246}
1247
1248fn is_staging_name(name: &str) -> bool {
1249 let Some(uuid) = name
1250 .strip_prefix(STAGING_PREFIX)
1251 .and_then(|name| name.strip_suffix(STAGING_SUFFIX))
1252 else {
1253 return false;
1254 };
1255 Uuid::parse_str(uuid).is_ok_and(|parsed| parsed.hyphenated().to_string() == uuid)
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 #[cfg(unix)]
1261 use std::os::unix::fs::{PermissionsExt as _, symlink};
1262
1263 use super::*;
1264 use crate::tree::{asset, post, publication};
1265
1266 fn fixture(marker: &str) -> DiscoveredContentTree {
1267 let publication_source = format!("title = \"Café {marker}\"\n");
1268 let posts = vec![
1269 post(
1270 "drafts/later.md",
1271 PostCollection::Drafts,
1272 format!("---\ntitle: Later {marker}\n---\n\nExact draft.\n"),
1273 ),
1274 post(
1275 "posts/hello.md",
1276 PostCollection::Posts,
1277 format!("---\ntitle: Hello {marker}\n---\n\nExact post.\n"),
1278 ),
1279 ];
1280 let assets = vec![
1281 asset(
1282 LogicalAssetPath::parse("assets/data.bin").unwrap(),
1283 vec![0, 1, 0xff, marker.len() as u8],
1284 ),
1285 asset(
1286 LogicalAssetPath::parse("assets/picture.png").unwrap(),
1287 b"not really a png".to_vec(),
1288 ),
1289 ];
1290 let total_bytes = publication_source.len() as u64
1291 + posts
1292 .iter()
1293 .map(|post| post.source.len() as u64)
1294 .sum::<u64>()
1295 + assets
1296 .iter()
1297 .map(|asset| asset.bytes.len() as u64)
1298 .sum::<u64>();
1299 DiscoveredContentTree::new(
1300 publication("publication.toml", publication_source),
1301 posts,
1302 assets,
1303 total_bytes,
1304 )
1305 }
1306
1307 fn store() -> (tempfile::TempDir, ContentCandidateStore) {
1308 let state = tempfile::tempdir().unwrap();
1309 let store =
1310 ContentCandidateStore::open(state.path(), ContentTreeLimits::default()).unwrap();
1311 (state, store)
1312 }
1313
1314 #[test]
1315 fn retains_and_recovers_every_exact_authored_byte() {
1316 let (_state, store) = store();
1317 let tree = fixture("one");
1318 let digest = store.retain(&tree).unwrap();
1319
1320 assert_eq!(store.load(&digest).unwrap(), tree);
1321 let recovered = store.load_all().unwrap();
1322 assert_eq!(recovered.len(), 1);
1323 assert_eq!(recovered[0].digest, digest);
1324 assert_eq!(recovered[0].tree, tree);
1325
1326 #[cfg(unix)]
1327 {
1328 let directory_mode = fs::metadata(&store.root).unwrap().permissions().mode();
1329 let file_mode = fs::metadata(store.candidate_path(&digest))
1330 .unwrap()
1331 .permissions()
1332 .mode();
1333 assert_eq!(directory_mode & 0o077, 0);
1334 assert_eq!(file_mode & 0o077, 0);
1335 }
1336 }
1337
1338 #[test]
1339 fn retain_is_deterministic_and_idempotent_across_input_order() {
1340 let (_state, store) = store();
1341 let tree = fixture("same");
1342 let digest = store.retain(&tree).unwrap();
1343 let first_bytes = fs::read(store.candidate_path(&digest)).unwrap();
1344
1345 let mut reordered = tree.clone();
1346 reordered.posts.reverse();
1347 reordered.assets.reverse();
1348 assert_eq!(store.retain(&reordered).unwrap(), digest);
1349 assert_eq!(
1350 fs::read(store.candidate_path(&digest)).unwrap(),
1351 first_bytes
1352 );
1353 assert_eq!(store.load_all().unwrap().len(), 1);
1354 }
1355
1356 #[test]
1357 fn load_all_is_digest_sorted_and_survives_a_new_store_instance() {
1358 let (state, store) = store();
1359 let first = store.retain(&fixture("first")).unwrap();
1360 let second = store.retain(&fixture("second")).unwrap();
1361 drop(store);
1362
1363 let reopened =
1364 ContentCandidateStore::open(state.path(), ContentTreeLimits::default()).unwrap();
1365 let recovered = reopened.load_all().unwrap();
1366 let actual: Vec<_> = recovered
1367 .iter()
1368 .map(|candidate| candidate.digest.to_string())
1369 .collect();
1370 let mut expected = vec![first.to_string(), second.to_string()];
1371 expected.sort();
1372 assert_eq!(actual, expected);
1373 }
1374
1375 #[test]
1376 fn candidate_store_capacity_bounds_new_archives_and_recovery_scans() {
1377 let entry_state = tempfile::tempdir().unwrap();
1378 let entry_limited = ContentCandidateStore::open_with_capacity(
1379 entry_state.path(),
1380 ContentTreeLimits::default(),
1381 CandidateStoreCapacity {
1382 entries: 1,
1383 bytes: u64::MAX,
1384 },
1385 )
1386 .unwrap();
1387 let first = fixture("capacity-first");
1388 let first_digest = entry_limited.retain(&first).unwrap();
1389 let mut reordered = first.clone();
1390 reordered.posts.reverse();
1391 reordered.assets.reverse();
1392 assert_eq!(entry_limited.retain(&reordered).unwrap(), first_digest);
1393 assert!(matches!(
1394 entry_limited.retain(&fixture("capacity-second")),
1395 Err(ContentCandidateStoreError::CapacityExceeded(_))
1396 ));
1397
1398 let staging_path = entry_limited.staging_path();
1399 let mut staging = create_staging_file(&staging_path).unwrap();
1400 staging.write_all(b"bounded interrupted staging").unwrap();
1401 staging.sync_all().unwrap();
1402 drop(staging);
1403 assert!(matches!(
1404 entry_limited.load_all(),
1405 Err(ContentCandidateStoreError::CapacityExceeded(_))
1406 ));
1407
1408 let byte_state = tempfile::tempdir().unwrap();
1409 let first_bytes = encoded_candidate_bytes(&first).unwrap();
1410 let byte_limited = ContentCandidateStore::open_with_capacity(
1411 byte_state.path(),
1412 ContentTreeLimits::default(),
1413 CandidateStoreCapacity {
1414 entries: 2,
1415 bytes: first_bytes,
1416 },
1417 )
1418 .unwrap();
1419 byte_limited.retain(&first).unwrap();
1420 assert!(matches!(
1421 byte_limited.retain(&fixture("capacity-byte-overflow")),
1422 Err(ContentCandidateStoreError::CapacityExceeded(_))
1423 ));
1424 }
1425
1426 #[test]
1427 fn interrupted_private_staging_file_does_not_hide_recoverable_candidates() {
1428 let (_state, store) = store();
1429 let digest = store.retain(&fixture("retained")).unwrap();
1430 let staging_path = store.staging_path();
1431 let mut staging = create_staging_file(&staging_path).unwrap();
1432 staging.write_all(b"partial archive").unwrap();
1433 staging.sync_all().unwrap();
1434 drop(staging);
1435
1436 let recovered = store.load_all().unwrap();
1437 assert_eq!(recovered.len(), 1);
1438 assert_eq!(recovered[0].digest, digest);
1439 assert!(staging_path.exists());
1440 }
1441
1442 #[test]
1443 fn rejects_header_digest_and_logical_path_tampering() {
1444 let (_state, store) = store();
1445 let tree = fixture("tamper");
1446 let digest = store.retain(&tree).unwrap();
1447 let path = store.candidate_path(&digest);
1448 let original = fs::read(&path).unwrap();
1449
1450 let wrong_key = ContentTreeDigest::from_bytes([0x5a; 32]);
1451 let wrong_path = store.candidate_path(&wrong_key);
1452 fs::copy(&path, &wrong_path).unwrap();
1453 assert!(matches!(
1454 store.load(&wrong_key),
1455 Err(ContentCandidateStoreError::DigestMismatch)
1456 ));
1457 fs::remove_file(wrong_path).unwrap();
1458
1459 let mut changed_digest = original.clone();
1460 changed_digest[ARCHIVE_MAGIC.len() + 2] ^= 1;
1461 fs::write(&path, changed_digest).unwrap();
1462 assert!(matches!(
1463 store.load(&digest),
1464 Err(ContentCandidateStoreError::DigestMismatch)
1465 ));
1466
1467 let mut changed_path = original;
1468 let post_path_offset = changed_path
1469 .windows(b"posts/hello.md".len())
1470 .position(|window| window == b"posts/hello.md")
1471 .unwrap();
1472 changed_path[post_path_offset + "posts/".len()] = b'j';
1473 fs::write(&path, changed_path).unwrap();
1474 assert!(matches!(
1475 store.load(&digest),
1476 Err(ContentCandidateStoreError::DigestMismatch)
1477 ));
1478 }
1479
1480 #[test]
1481 fn corrupt_existing_key_is_never_replaced_during_idempotent_retain() {
1482 let (_state, store) = store();
1483 let tree = fixture("collision");
1484 let digest = store.retain(&tree).unwrap();
1485 let path = store.candidate_path(&digest);
1486 let mut corrupt = fs::read(&path).unwrap();
1487 corrupt[ARCHIVE_MAGIC.len() + 2] ^= 1;
1488 fs::write(&path, &corrupt).unwrap();
1489
1490 assert!(store.retain(&tree).is_err());
1491 assert_eq!(fs::read(path).unwrap(), corrupt);
1492 }
1493
1494 #[test]
1495 fn rejects_trailing_data_and_oversized_counts_before_allocation() {
1496 let (_state, store) = store();
1497 let digest = store.retain(&fixture("bounds")).unwrap();
1498 let path = store.candidate_path(&digest);
1499 let mut bytes = fs::read(&path).unwrap();
1500 bytes.push(0);
1501 fs::write(&path, &bytes).unwrap();
1502 assert!(matches!(
1503 store.load(&digest),
1504 Err(ContentCandidateStoreError::InvalidArchive(
1505 "candidate archive has trailing bytes"
1506 ))
1507 ));
1508
1509 let digest = fixture("count").digest();
1510 let path = store.candidate_path(&digest);
1511 let mut bytes = Vec::new();
1512 bytes.extend_from_slice(ARCHIVE_MAGIC);
1513 bytes.extend_from_slice(&ARCHIVE_VERSION.to_be_bytes());
1514 bytes.extend_from_slice(digest.as_bytes());
1515 bytes.extend_from_slice(&0_u64.to_be_bytes());
1516 bytes.extend_from_slice(&("publication.toml".len() as u32).to_be_bytes());
1517 bytes.extend_from_slice(b"publication.toml");
1518 bytes.extend_from_slice(&0_u64.to_be_bytes());
1519 bytes.extend_from_slice(&u32::MAX.to_be_bytes());
1520 fs::write(&path, bytes).unwrap();
1521 #[cfg(unix)]
1522 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1523 assert!(matches!(
1524 store.load(&digest),
1525 Err(ContentCandidateStoreError::LimitExceeded(
1526 "candidate has too many entries"
1527 ))
1528 ));
1529
1530 let permissive = ContentCandidateStore::open(
1531 _state.path(),
1532 ContentTreeLimits {
1533 entries: crate::ContentEntryLimit::new(usize::MAX).unwrap(),
1534 ..ContentTreeLimits::default()
1535 },
1536 )
1537 .unwrap();
1538 assert!(matches!(
1539 permissive.load(&digest),
1540 Err(ContentCandidateStoreError::InvalidArchive(
1541 "candidate archive is truncated"
1542 ))
1543 ));
1544 }
1545
1546 #[test]
1547 #[cfg(unix)]
1548 fn rejects_symlink_non_regular_and_open_permission_targets() {
1549 let (_state, store) = store();
1550 let tree = fixture("unsafe");
1551 let digest = tree.digest();
1552 let target = store.root.join("outside");
1553 fs::write(&target, b"outside").unwrap();
1554 let candidate = store.candidate_path(&digest);
1555 symlink(&target, &candidate).unwrap();
1556 assert!(matches!(
1557 store.load(&digest),
1558 Err(ContentCandidateStoreError::UnsafeEntry)
1559 ));
1560 assert!(matches!(
1561 store.load_all(),
1562 Err(ContentCandidateStoreError::UnsafeEntry)
1563 ));
1564 fs::remove_file(&candidate).unwrap();
1565
1566 fs::create_dir(&candidate).unwrap();
1567 assert!(matches!(
1568 store.load(&digest),
1569 Err(ContentCandidateStoreError::UnsafeEntry)
1570 ));
1571 fs::remove_dir(&candidate).unwrap();
1572
1573 fs::write(&candidate, b"not private").unwrap();
1574 fs::set_permissions(&candidate, fs::Permissions::from_mode(0o644)).unwrap();
1575 assert!(matches!(
1576 store.load(&digest),
1577 Err(ContentCandidateStoreError::UnsafeEntry)
1578 ));
1579 }
1580}