Skip to main content

prikk_store/
snapshot.rs

1//! Snapshot-manifest validation for future checkout materialization.
2//!
3//! Snapshot bytes are stored inside Blob objects. PR-017 validates snapshot content and feeds an
4//! explicit snapshot materializer.
5
6use prikk_error::{PrikkError, Result};
7
8use crate::path::{RepoPath, validate_no_path_collisions};
9
10const SNAPSHOT_MAGIC: &[u8] = b"PRIKK-SNAPSHOT-MANIFEST-v1\n";
11
12/// A single file entry in a snapshot manifest.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct SnapshotEntry {
15    /// Validated repository-relative path.
16    pub path: RepoPath,
17    /// File content bytes.
18    pub bytes: Vec<u8>,
19}
20
21/// Decoded snapshot manifest.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SnapshotManifest {
24    /// File entries, sorted by path.
25    pub files: Vec<SnapshotEntry>,
26}
27
28impl SnapshotManifest {
29    /// Decode a snapshot manifest from Blob payload bytes.
30    pub fn decode(bytes: &[u8]) -> Result<Self> {
31        let Some(mut rest) = bytes.get(SNAPSHOT_MAGIC.len()..) else {
32            return Err(PrikkError::MalformedData(
33                "snapshot manifest is shorter than magic".to_string(),
34            ));
35        };
36        if !bytes.starts_with(SNAPSHOT_MAGIC) {
37            return Err(PrikkError::MalformedData(
38                "snapshot manifest magic mismatch".to_string(),
39            ));
40        }
41        let mut files = Vec::new();
42        while !rest.is_empty() {
43            let (path_len, after_path_len) = read_u32(rest)?;
44            rest = after_path_len;
45            let path_len = path_len as usize;
46            if path_len == 0 {
47                return Err(PrikkError::MalformedData(
48                    "snapshot path must not be empty".to_string(),
49                ));
50            }
51            let (path_bytes, after_path) = read_exact(rest, path_len)?;
52            rest = after_path;
53            let path_text = std::str::from_utf8(path_bytes)
54                .map_err(|_| PrikkError::MalformedData("snapshot path is not UTF-8".to_string()))?;
55            let path = RepoPath::parse(path_text)?;
56            let (content_len, after_content_len) = read_u64(rest)?;
57            rest = after_content_len;
58            let content_len = usize::try_from(content_len).map_err(|_| {
59                PrikkError::MalformedData("snapshot content length does not fit usize".to_string())
60            })?;
61            let (content, after_content) = read_exact(rest, content_len)?;
62            rest = after_content;
63            files.push(SnapshotEntry {
64                path,
65                bytes: content.to_vec(),
66            });
67        }
68        let manifest = Self { files };
69        manifest.validate_order_and_collisions()?;
70        Ok(manifest)
71    }
72
73    /// Encode a snapshot manifest. This is used by tests and fixture generation.
74    pub fn encode(&self) -> Result<Vec<u8>> {
75        self.validate_order_and_collisions()?;
76        let mut out = Vec::new();
77        out.extend_from_slice(SNAPSHOT_MAGIC);
78        for file in &self.files {
79            let path = file.path.as_str().as_bytes();
80            let path_len = u32::try_from(path.len()).map_err(|_| {
81                PrikkError::MalformedData("snapshot path length exceeds u32".to_string())
82            })?;
83            out.extend_from_slice(&path_len.to_be_bytes());
84            out.extend_from_slice(path);
85            out.extend_from_slice(&(file.bytes.len() as u64).to_be_bytes());
86            out.extend_from_slice(&file.bytes);
87        }
88        Ok(out)
89    }
90
91    /// Return the total number of content bytes across entries.
92    #[must_use]
93    pub fn total_content_bytes(&self) -> u64 {
94        self.files
95            .iter()
96            .map(|entry| entry.bytes.len() as u64)
97            .sum()
98    }
99
100    fn validate_order_and_collisions(&self) -> Result<()> {
101        let paths: Vec<RepoPath> = self.files.iter().map(|entry| entry.path.clone()).collect();
102        validate_no_path_collisions(&paths)?;
103        if !paths.windows(2).all(|pair| {
104            let mut items = pair.iter();
105            match (items.next(), items.next()) {
106                (Some(left), Some(right)) => left < right,
107                _ => true,
108            }
109        }) {
110            return Err(PrikkError::MalformedData(
111                "snapshot paths must be sorted by repository path".to_string(),
112            ));
113        }
114        Ok(())
115    }
116}
117
118fn read_u32(bytes: &[u8]) -> Result<(u32, &[u8])> {
119    let (raw, rest) = read_exact(bytes, 4)?;
120    let mut out = [0_u8; 4];
121    out.copy_from_slice(raw);
122    Ok((u32::from_be_bytes(out), rest))
123}
124
125fn read_u64(bytes: &[u8]) -> Result<(u64, &[u8])> {
126    let (raw, rest) = read_exact(bytes, 8)?;
127    let mut out = [0_u8; 8];
128    out.copy_from_slice(raw);
129    Ok((u64::from_be_bytes(out), rest))
130}
131
132fn read_exact(bytes: &[u8], len: usize) -> Result<(&[u8], &[u8])> {
133    let Some(value) = bytes.get(..len) else {
134        return Err(PrikkError::MalformedData(
135            "unexpected end of snapshot manifest".to_string(),
136        ));
137    };
138    let Some(rest) = bytes.get(len..) else {
139        return Err(PrikkError::MalformedData(
140            "snapshot manifest range overflow".to_string(),
141        ));
142    };
143    Ok((value, rest))
144}
145
146#[cfg(test)]
147mod tests;