1use anyhow::{bail, Context, Result};
3use serde::Deserialize;
4use std::{fs, io::Read, path::Path};
5
6#[derive(Deserialize)]
7pub struct Manifest {
8 pub share_id: String,
9 pub parent_share: Option<String>,
10 pub scope: Scope,
11}
12
13#[derive(Deserialize)]
14pub struct Scope {
15 pub kind: String,
16 pub value: Option<String>,
17}
18
19pub fn manifest(path: &Path, budget: &mut u64) -> Result<Manifest> {
22 let compressed = fs::File::open(path)?;
23 let decoder = flate2::read::GzDecoder::new(compressed);
24 let mut limited = decoder.take(*budget);
25 let result = (|| {
26 let mut archive = tar::Archive::new(&mut limited);
27 let mut found = None;
28 for entry in archive.entries()? {
29 let mut entry = entry?;
30 let name = entry.path()?.into_owned();
31 if name.file_name().is_some_and(|n| n == "manifest.json") {
32 if found.is_some() || !entry.header().entry_type().is_file() {
33 bail!("project archive needs exactly one regular manifest.json");
34 }
35 if entry.size() > 1024 * 1024 {
36 bail!("project share manifest exceeds 1 MiB");
37 }
38 let mut bytes = Vec::new();
39 entry.read_to_end(&mut bytes)?;
40 found = Some(serde_json::from_slice(&bytes)?);
41 }
42 }
43 found.context("project archive has no native share manifest")
44 })();
45 let remaining = limited.limit();
46 let exhausted = remaining == 0;
47 *budget = remaining;
48 if exhausted {
49 bail!("project archives exceed the 256 MiB decompressed limit");
50 }
51 result
52}
53
54pub fn valid_prefix(prefix: &str) -> Result<()> {
55 if !prefix.ends_with('/')
56 || prefix.starts_with('/')
57 || prefix.split('/').any(|p| p == "." || p == "..")
58 || prefix
59 .strip_suffix('/')
60 .unwrap_or(prefix)
61 .split('/')
62 .any(str::is_empty)
63 || prefix.chars().any(|c| c.is_control() || c == '\\')
64 || prefix == "/"
65 {
66 bail!("project prefix must be a nonempty relative directory ending in /: {prefix:?}");
67 }
68 Ok(())
69}
70
71fn iri(value: &str) -> Result<()> {
72 if !value.contains(':')
73 || value
74 .chars()
75 .any(|c| c.is_whitespace() || "<>\"{}|^`\\".contains(c))
76 {
77 bail!("expected an absolute IRI: {value:?}");
78 }
79 Ok(())
80}
81
82pub fn query(graph: &str, predicate: &str, prefix: &str) -> Result<String> {
84 enriched_query(graph, predicate, prefix, &[])
85}
86
87pub fn enriched_query(
88 graph: &str,
89 predicate: &str,
90 prefix: &str,
91 owners: &[crate::pendant_ownership::OwnedPath],
92) -> Result<String> {
93 valid_prefix(prefix)?;
94 iri(graph)?;
95 iri(predicate)?;
96 let mut template = "CONSTRUCT { ?s ?p ?o".to_owned();
97 let mut optional = String::new();
98 for (i, owner) in owners.iter().enumerate() {
99 iri(&owner.predicate)?;
100 iri(&owner.owner)?;
101 valid_prefix(&format!("{}/", owner.path))?;
102 if !owner.path.starts_with(prefix) {
103 bail!("ownership path lies outside the project");
104 }
105 template.push_str(&format!(
106 " . ?owned{i} <{}> <{}>",
107 owner.predicate, owner.owner
108 ));
109 optional.push_str(&format!(
110 " UNION {{ GRAPH <{graph}> {{ ?owned{i} <{predicate}> {} }} }}",
111 serde_json::to_string(&owner.path)?
112 ));
113 }
114 let core = format!(
115 "GRAPH <{graph}> {{ ?s <{predicate}> ?path . ?s ?p ?o . FILTER(STRSTARTS(?path, {})) }}",
116 serde_json::to_string(prefix)?
117 );
118 let body = if owners.is_empty() {
119 core
120 } else {
121 format!("{{ {core} }}{optional}")
122 };
123 Ok(format!("{template} }} WHERE {{ {body} }}"))
124}
125
126fn literal(input: &str) -> Result<(String, &str)> {
127 let mut stream = serde_json::Deserializer::from_str(input).into_iter::<String>();
128 let value = stream.next().context("missing scope literal")??;
129 let offset = stream.byte_offset();
130 Ok((value, &input[offset..]))
131}
132
133pub fn prefix(scope: &Scope) -> Result<String> {
134 if scope.kind != "construct" {
135 bail!("derived project share needs a CONSTRUCT path-prefix scope");
136 }
137 let value = scope.value.as_deref().context("missing CONSTRUCT scope")?;
138 let (mut template, rest) = value
139 .strip_prefix("CONSTRUCT { ?s ?p ?o")
140 .and_then(|v| v.split_once(" } WHERE { "))
141 .context("unsupported project scope; use packs project-scope")?;
142 let enriched = !template.is_empty();
143 let rest = if enriched {
144 rest.strip_prefix("{ GRAPH <")
145 } else {
146 rest.strip_prefix("GRAPH <")
147 }
148 .context("unsupported project graph pattern")?;
149 let (graph, rest) = rest
150 .split_once("> { ?s <")
151 .context("unsupported project source graph")?;
152 let (predicate, tail) = rest
153 .split_once("> ?path . ?s ?p ?o . FILTER(STRSTARTS(?path, ")
154 .context("unsupported project scope")?;
155 let (prefix, tail) = literal(tail)?;
156 let mut tail = tail
157 .strip_prefix(")) }")
158 .context("unsupported project scope suffix")?;
159 if enriched {
160 tail = tail
161 .strip_prefix(" }")
162 .context("unsupported project union")?;
163 }
164 let mut owners = Vec::new();
165 while !template.is_empty() {
166 let i = owners.len();
167 let rest = template
168 .strip_prefix(&format!(" . ?owned{i} <"))
169 .context("unsupported ownership template")?;
170 let (owned_by, rest) = rest
171 .split_once("> <")
172 .context("unsupported ownership predicate")?;
173 let (owner, rest) = rest.split_once('>').context("unsupported ownership IRI")?;
174 template = rest;
175 tail = tail
176 .strip_prefix(&format!(
177 " UNION {{ GRAPH <{graph}> {{ ?owned{i} <{predicate}> "
178 ))
179 .context("unsupported ownership scope")?;
180 let (path, rest) = literal(tail)?;
181 tail = rest
182 .strip_prefix(" } }")
183 .context("unsupported ownership binding")?;
184 owners.push(crate::pendant_ownership::OwnedPath {
185 path,
186 predicate: owned_by.into(),
187 owner: owner.into(),
188 });
189 if owners.len() > 4096 {
190 bail!("ownership scope exceeds 4096 assignments");
191 }
192 }
193 if tail != " }" || enriched_query(graph, predicate, &prefix, &owners)? != value {
194 bail!("noncanonical project scope");
195 }
196 Ok(prefix)
197}
198
199pub fn present(root: &Path, prefix: &str) -> Result<bool> {
200 valid_prefix(prefix)?;
201 let mut path = root.to_path_buf();
202 for part in prefix.trim_end_matches('/').split('/') {
203 path.push(part);
204 match fs::symlink_metadata(&path) {
205 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
206 Err(e) => return Err(e.into()),
207 Ok(m) if m.is_dir() && !m.file_type().is_symlink() => (),
208 Ok(_) => bail!(
209 "project path must contain only regular directories: {}",
210 path.display()
211 ),
212 }
213 }
214 Ok(true)
215}
216
217fn checked_out(root: &Path, prefix: &str) -> Result<bool> {
218 if !present(root, prefix)? {
219 return Ok(false);
220 }
221 let listed = crate::pendant_sync::git_command(root)
222 .args(["--literal-pathspecs", "ls-files", "-v", "-z", "--", prefix])
223 .output()?;
224 if !listed.status.success() {
225 bail!("cannot inspect project checkout membership");
226 }
227 for entry in String::from_utf8(listed.stdout)?
228 .split('\0')
229 .filter(|s| !s.is_empty())
230 {
231 let (tag, path) = entry
232 .split_once(' ')
233 .context("invalid Git checkout entry")?;
234 if tag.len() != 1 || !path.starts_with(prefix) {
235 bail!("Git returned an unexpected project path");
236 }
237 if matches!(tag, "S" | "s") {
238 continue;
239 }
240 match fs::symlink_metadata(root.join(path)) {
241 Ok(m) if m.is_file() || m.file_type().is_symlink() => return Ok(true),
242 Ok(_) => (),
243 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
244 Err(e) => return Err(e.into()),
245 }
246 }
247 Ok(false)
248}
249
250pub fn select(
251 root: &Path,
252 inputs: Vec<(String, std::path::PathBuf)>,
253) -> Result<Vec<(String, std::path::PathBuf)>> {
254 if inputs.is_empty() {
255 return Ok(Vec::new());
256 }
257 let mut budget = 256 * 1024 * 1024;
258 let manifests = inputs
259 .iter()
260 .map(|(_, p)| manifest(p, &mut budget))
261 .collect::<Result<Vec<_>>>()?;
262 let ids: std::collections::BTreeSet<_> = manifests.iter().map(|m| &m.share_id).collect();
263 if ids.len() != manifests.len() {
264 bail!("duplicate share identity in monorepo archives");
265 }
266 let parents: Vec<_> = manifests
269 .iter()
270 .filter(|m| {
271 manifests
272 .iter()
273 .any(|child| child.parent_share.as_ref() == Some(&m.share_id))
274 })
275 .collect();
276 if parents.len() != 1 {
277 bail!("monorepo sync needs exactly one parent share and derived project shares");
278 }
279 let parent = &parents[0].share_id;
280 let mut prefixes = std::collections::BTreeSet::new();
281 let mut selected = Vec::new();
282 for (input, manifest) in inputs.into_iter().zip(manifests.iter()) {
283 if &manifest.share_id == parent {
284 continue;
285 }
286 let id = manifest
287 .parent_share
288 .as_ref()
289 .context("project share has no parent")?;
290 if id != parent {
291 bail!("derived project share names a different parent");
292 }
293 let prefix = prefix(&manifest.scope)?;
294 if !prefixes.insert(prefix.clone()) {
295 bail!("duplicate project prefix: {prefix}");
296 }
297 if checked_out(root, &prefix)? {
298 selected.push(input);
299 }
300 }
301 if prefixes.is_empty() {
302 bail!("monorepo sync has no derived project shares");
303 }
304 Ok(selected)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn archive(
312 root: &Path,
313 name: &str,
314 id: &str,
315 parent: Option<&str>,
316 scope: serde_json::Value,
317 ) -> std::path::PathBuf {
318 let path = root.join(format!("{name}.pendant.tar.gz"));
319 let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
320 fs::File::create(&path).unwrap(),
321 flate2::Compression::default(),
322 ));
323 let data = serde_json::to_vec(
324 &serde_json::json!({"share_id": id, "parent_share":parent, "scope":scope}),
325 )
326 .unwrap();
327 let mut header = tar::Header::new_gnu();
328 header.set_size(data.len() as u64);
329 header.set_mode(0o644);
330 header.set_cksum();
331 tar.append_data(&mut header, "pendant/manifest.json", data.as_slice())
332 .unwrap();
333 tar.into_inner().unwrap().finish().unwrap();
334 path
335 }
336
337 #[test]
338 fn parent_lineage_sparse_membership_and_duplicate_scope_controls() {
339 let root = tempfile::tempdir().unwrap();
340 assert!(crate::pendant_sync::git_command(root.path())
341 .args(["init", "-q"])
342 .status()
343 .unwrap()
344 .success());
345 fs::create_dir_all(root.path().join("packages/api")).unwrap();
346 fs::write(root.path().join("packages/api/source.rs"), "fixture").unwrap();
347 assert!(crate::pendant_sync::git_command(root.path())
348 .args(["add", "packages/api/source.rs"])
349 .status()
350 .unwrap()
351 .success());
352 let parent = archive(
353 root.path(),
354 "parent",
355 "parent-id",
356 Some("previous-parent-id"),
357 serde_json::json!({"kind":"graph", "value":"urn:source"}),
358 );
359 let scope = |p| serde_json::json!({"kind":"construct", "value":query("urn:source", "urn:path", p).unwrap()});
360 let api = archive(
361 root.path(),
362 "api",
363 "api-id",
364 Some("parent-id"),
365 scope("packages/api/"),
366 );
367 let absent = archive(
368 root.path(),
369 "absent",
370 "absent-id",
371 Some("parent-id"),
372 scope("packages/absent/"),
373 );
374 let inputs = vec![
375 ("parent".into(), parent.clone()),
376 ("api".into(), api.clone()),
377 ("absent".into(), absent.clone()),
378 ];
379 let selected = select(root.path(), inputs).unwrap();
380 assert_eq!(selected.len(), 1);
381 assert_eq!(selected[0].0, "api");
382 assert!(crate::pendant_sync::git_command(root.path())
383 .args(["update-index", "--skip-worktree", "packages/api/source.rs"])
384 .status()
385 .unwrap()
386 .success());
387 fs::remove_file(root.path().join("packages/api/source.rs")).unwrap();
388 fs::create_dir(root.path().join("packages/api/node_modules")).unwrap();
389 fs::write(
390 root.path().join("packages/api/node_modules/cache"),
391 "retained",
392 )
393 .unwrap();
394 assert!(!checked_out(root.path(), "packages/api/").unwrap());
395 assert_eq!(
396 fs::read_to_string(root.path().join("packages/api/node_modules/cache")).unwrap(),
397 "retained"
398 );
399 let duplicate = archive(
400 root.path(),
401 "duplicate",
402 "different-id",
403 Some("parent-id"),
404 scope("packages/api/"),
405 );
406 assert!(select(
407 root.path(),
408 vec![
409 ("parent".into(), parent.clone()),
410 ("api".into(), api.clone()),
411 ("duplicate".into(), duplicate)
412 ]
413 )
414 .is_err());
415 assert!(select(root.path(), vec![("api".into(), api)]).is_err());
416 let mut budget = 16;
417 assert!(manifest(&parent, &mut budget).is_err());
418 assert!(select(root.path(), Vec::new()).unwrap().is_empty());
419 }
420
421 #[test]
422 fn scopes_round_trip_and_cannot_escape_or_broaden() {
423 for name in ["packages/client/", "projects/a b/", "projects/quote\"/"] {
424 let value = query("urn:source", "urn:path", name).unwrap();
425 let scope = Scope {
426 kind: "construct".into(),
427 value: Some(value.clone()),
428 };
429 assert_eq!(prefix(&scope).unwrap(), name);
430 let broad = Scope {
431 kind: "construct".into(),
432 value: Some(value.replace(")) } }", ")) } } UNION { ?s ?p ?o }")),
433 };
434 assert!(prefix(&broad).is_err());
435 }
436 for name in [
437 "",
438 "/",
439 "/absolute/",
440 "../escape/",
441 "a/../b/",
442 "a//",
443 "a//b/",
444 "a/./",
445 "a\\b/",
446 "a\n/",
447 ] {
448 assert!(valid_prefix(name).is_err(), "{name:?}");
449 }
450 assert!(query("urn:source> } UNION {", "urn:path", "a/").is_err());
451 }
452
453 #[test]
454 fn project_presence_tracks_checkout_and_rejects_redirects() {
455 let root = tempfile::tempdir().unwrap();
456 assert!(!present(root.path(), "packages/api/").unwrap());
457 fs::create_dir_all(root.path().join("packages/api")).unwrap();
458 assert!(present(root.path(), "packages/api/").unwrap());
459 fs::remove_dir(root.path().join("packages/api")).unwrap();
460 fs::write(root.path().join("packages/api"), "not a directory").unwrap();
461 assert!(present(root.path(), "packages/api/").is_err());
462 #[cfg(unix)]
463 {
464 fs::remove_file(root.path().join("packages/api")).unwrap();
465 std::os::unix::fs::symlink(root.path(), root.path().join("packages/api")).unwrap();
466 assert!(present(root.path(), "packages/api/").is_err());
467 }
468 }
469}