1use 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;
17pub 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#[derive(Clone, Debug)]
34pub struct ProjectionPolicy {
35 set: GlobSet,
36 path_hashes: BTreeSet<String>,
37}
38
39impl ProjectionPolicy {
40 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 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 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 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 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
207pub 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}