Skip to main content

markdown_compiler/
candidate_store.rs

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(&current) {
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}