Skip to main content

caboodle/
pendant_projects.rs

1//! Project selection reads the native share scope; there is no repository manifest.
2use 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
19/// Read metadata without extracting any archive paths. Quipu subsequently verifies
20/// the complete frozen archive before it can change CURRENT.
21pub 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
82/// Canonical native CONSTRUCT scope, also used when reading a derived share.
83pub 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    // A parent may itself have a previous snapshot in its lineage. Find the
267    // parent referenced by this archive set, rather than assuming a null parent.
268    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}