Skip to main content

dbmd_core/
projection.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Explicit partial-store projection policies.
4
5use std::collections::BTreeSet;
6use std::path::Path;
7
8use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
9use serde::Deserialize;
10use sha2::{Digest, Sha256};
11
12use crate::Store;
13
14const MAX_BYTES: u64 = 1024 * 1024;
15const MAX_LINE_BYTES: usize = 4096;
16const MAX_ENTRIES: usize = 10_000;
17/// Maximum encoded size of a projection commitment manifest.
18pub const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
19const MAX_MANIFEST_HASHES: usize = 100_000;
20const PATH_HASH_DOMAIN: &[u8] = b"dbmd-projection-path-v1\0";
21
22#[derive(Debug, Deserialize)]
23#[serde(deny_unknown_fields)]
24struct ProjectionManifest {
25    version: u8,
26    algorithm: String,
27    path_hashes: Vec<String>,
28}
29
30/// A bounded, case-sensitive set of store-relative path rules. Its syntax is
31/// intentionally identical to `.sevralocal`: blank lines and `#` comments are
32/// ignored; every other line is one glob with backslash escaping enabled.
33#[derive(Clone, Debug)]
34pub struct ProjectionPolicy {
35    set: GlobSet,
36    path_hashes: BTreeSet<String>,
37}
38
39impl ProjectionPolicy {
40    /// Load a required policy file through the store's no-follow capability.
41    pub fn load(store: &Store, file: &str) -> Result<Self, String> {
42        let bytes = read_regular_bounded(store, file, MAX_BYTES)?;
43        let raw =
44            std::str::from_utf8(&bytes).map_err(|_| format!("refusing {file}: it is not UTF-8"))?;
45        Self::compile(file, raw)
46    }
47
48    /// Load a path-confidential projection commitment manifest. The manifest
49    /// carries only domain-separated SHA-256 commitments to exact store paths,
50    /// so a recovery package need not publish the source `.sevralocal` rules.
51    pub fn load_manifest(store: &Store, file: &str) -> Result<Self, String> {
52        let bytes = read_regular_bounded(store, file, MAX_MANIFEST_BYTES)?;
53        Self::from_manifest_bytes(file, &bytes)
54    }
55
56    /// Parse bounded manifest bytes supplied by a trusted higher-level
57    /// transport (for example stdin from a signed package verifier).
58    pub fn from_manifest_bytes(file: &str, bytes: &[u8]) -> Result<Self, String> {
59        if bytes.len() as u64 > MAX_MANIFEST_BYTES {
60            return Err(format!(
61                "refusing {file}: it exceeds {MAX_MANIFEST_BYTES} bytes"
62            ));
63        }
64        let manifest: ProjectionManifest = serde_json::from_slice(bytes)
65            .map_err(|_| format!("refusing {file}: it is not a valid projection manifest"))?;
66        if manifest.version != 1 || manifest.algorithm != "sha256" {
67            return Err(format!(
68                "refusing {file}: unsupported projection manifest format"
69            ));
70        }
71        if manifest.path_hashes.len() > MAX_MANIFEST_HASHES {
72            return Err(format!(
73                "refusing {file}: it has more than {MAX_MANIFEST_HASHES} path commitments"
74            ));
75        }
76        let mut prior: Option<&str> = None;
77        for hash in &manifest.path_hashes {
78            if hash.len() != 64
79                || !hash
80                    .bytes()
81                    .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
82            {
83                return Err(format!(
84                    "refusing {file}: projection commitments must be lowercase SHA-256"
85                ));
86            }
87            if prior.is_some_and(|value| value >= hash.as_str()) {
88                return Err(format!(
89                    "refusing {file}: projection commitments must be sorted and unique"
90                ));
91            }
92            prior = Some(hash);
93        }
94        let path_hashes = manifest.path_hashes.into_iter().collect::<BTreeSet<_>>();
95        if ["DB.md", "assets.jsonl"]
96            .into_iter()
97            .any(|path| path_hashes.contains(&projection_path_sha256(path)))
98        {
99            return Err(format!(
100                "refusing {file}: projection manifest cannot cover DB.md or assets.jsonl"
101            ));
102        }
103        Ok(Self {
104            set: empty_glob_set(),
105            path_hashes,
106        })
107    }
108
109    pub(crate) fn empty() -> Self {
110        Self {
111            set: empty_glob_set(),
112            path_hashes: BTreeSet::new(),
113        }
114    }
115
116    fn compile(file: &str, raw: &str) -> Result<Self, String> {
117        let mut builder = GlobSetBuilder::new();
118        let mut effective = 0_usize;
119        for (index, line) in raw.lines().enumerate() {
120            if line.len() > MAX_LINE_BYTES {
121                return Err(format!(
122                    "refusing {file}: line {} exceeds {MAX_LINE_BYTES} bytes",
123                    index + 1
124                ));
125            }
126            let entry = line.strip_suffix('\r').unwrap_or(line);
127            if entry.trim().is_empty() || entry.starts_with('#') {
128                continue;
129            }
130            if entry.starts_with('/')
131                || entry.contains('\0')
132                || entry
133                    .split('/')
134                    .any(|component| component.is_empty() || matches!(component, "." | ".."))
135            {
136                return Err(format!(
137                    "refusing {file}: line {} is not a safe store-relative glob",
138                    index + 1
139                ));
140            }
141            effective += 1;
142            if effective > MAX_ENTRIES {
143                return Err(format!(
144                    "refusing {file}: it has more than {MAX_ENTRIES} entries"
145                ));
146            }
147            builder.add(
148                GlobBuilder::new(entry)
149                    .backslash_escape(true)
150                    .build()
151                    .map_err(|_| {
152                        format!("refusing {file}: line {} is not a valid glob", index + 1)
153                    })?,
154            );
155        }
156        let set = builder
157            .build()
158            .map_err(|_| format!("refusing {file}: its matcher could not be compiled"))?;
159        if set.is_match("DB.md") || set.is_match("assets.jsonl") {
160            return Err(format!(
161                "refusing {file}: projection policy cannot cover DB.md or assets.jsonl"
162            ));
163        }
164        Ok(Self {
165            set,
166            path_hashes: BTreeSet::new(),
167        })
168    }
169
170    /// True when this exact materialized store path is declared outside the
171    /// projection.
172    pub fn excludes_path(&self, path: &str) -> bool {
173        self.set.is_match(path) || self.path_hashes.contains(&projection_path_sha256(path))
174    }
175
176    /// Match a structured wiki target, accounting for markdown's conventional
177    /// extensionless link coordinate while retaining raw-source paths.
178    pub fn excludes_wiki_coordinate(&self, coordinate: &str) -> bool {
179        self.excludes_path(coordinate) || self.excludes_path(&format!("{coordinate}.md"))
180    }
181}
182
183fn empty_glob_set() -> GlobSet {
184    GlobSetBuilder::new()
185        .build()
186        .expect("an empty projection matcher compiles")
187}
188
189fn read_regular_bounded(store: &Store, file: &str, max: u64) -> Result<Vec<u8>, String> {
190    let exists = store
191        .regular_file_exists(Path::new(file))
192        .map_err(|_| format!("refusing {file}: it is not a no-follow regular file"))?;
193    if !exists {
194        return Err(format!(
195            "refusing {file}: it is not a no-follow regular file"
196        ));
197    }
198    let bytes = store
199        .read_bounded(Path::new(file), max + 1)
200        .map_err(|_| format!("could not securely read {file}"))?;
201    if bytes.len() as u64 > max {
202        return Err(format!("refusing {file}: it exceeds {max} bytes"));
203    }
204    Ok(bytes)
205}
206
207/// Domain-separated commitment used by projection manifests.
208pub fn projection_path_sha256(path: &str) -> String {
209    let mut digest = Sha256::new();
210    digest.update(PATH_HASH_DOMAIN);
211    digest.update(path.as_bytes());
212    format!("{:x}", digest.finalize())
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn store_with(policy: &str) -> (tempfile::TempDir, Store) {
220        let directory = tempfile::tempdir().unwrap();
221        std::fs::write(
222            directory.path().join("DB.md"),
223            "---\ntype: db-md\nscope: test\nowner: test\n---\n",
224        )
225        .unwrap();
226        std::fs::write(directory.path().join(".projection"), policy).unwrap();
227        let store = Store::open_strict(directory.path()).unwrap();
228        (directory, store)
229    }
230
231    #[test]
232    fn exact_glob_comment_and_markdown_coordinates_share_one_contract() {
233        let (_directory, store) = store_with("# private\nrecords/private/**\nsources/raw/a.json\n");
234        let policy = ProjectionPolicy::load(&store, ".projection").unwrap();
235        assert!(policy.excludes_path("records/private/a.md"));
236        assert!(policy.excludes_wiki_coordinate("records/private/a"));
237        assert!(policy.excludes_wiki_coordinate("sources/raw/a.json"));
238        assert!(!policy.excludes_path("records/Private/a.md"));
239    }
240
241    #[test]
242    fn projection_cannot_hide_core_coordinates() {
243        let (_directory, store) = store_with("**\n");
244        assert!(ProjectionPolicy::load(&store, ".projection")
245            .unwrap_err()
246            .contains("cannot cover"));
247    }
248
249    #[test]
250    fn commitment_manifest_matches_exact_paths_without_publishing_them() {
251        let private = projection_path_sha256("records/private/secret.md");
252        let raw =
253            format!("{{\"version\":1,\"algorithm\":\"sha256\",\"path_hashes\":[\"{private}\"]}}");
254        let policy = ProjectionPolicy::from_manifest_bytes("stdin", raw.as_bytes()).unwrap();
255        assert!(policy.excludes_wiki_coordinate("records/private/secret"));
256        assert!(!policy.excludes_path("records/private/other.md"));
257        assert!(!raw.contains("secret.md"));
258    }
259
260    #[test]
261    fn commitment_manifest_is_canonical_and_cannot_hide_core_files() {
262        let mut commitments = [
263            projection_path_sha256("records/private/first.md"),
264            projection_path_sha256("records/private/second.md"),
265        ];
266        commitments.sort();
267        let [first, second] = commitments;
268        let unsorted = format!(
269            "{{\"version\":1,\"algorithm\":\"sha256\",\"path_hashes\":[\"{second}\",\"{first}\"]}}"
270        );
271        assert!(
272            ProjectionPolicy::from_manifest_bytes("stdin", unsorted.as_bytes())
273                .unwrap_err()
274                .contains("sorted and unique")
275        );
276        let core = projection_path_sha256("DB.md");
277        let raw =
278            format!("{{\"version\":1,\"algorithm\":\"sha256\",\"path_hashes\":[\"{core}\"]}}");
279        assert!(
280            ProjectionPolicy::from_manifest_bytes("stdin", raw.as_bytes())
281                .unwrap_err()
282                .contains("cannot cover")
283        );
284    }
285}