Skip to main content

majordomus_cli/capability/builtin/
pack.rs

1//! The `pack` module: the tracked tree as token-bounded text shards for a reader whose file
2//! search indexes text, with nothing in it that is not source.
3//!
4//! Two capabilities. `pack.plan` is the verdict before anything is written: what a profile
5//! of `share/archive.yaml` carries, what it leaves out and why, how it is cut into shards and
6//! every reason it cannot be built. `pack.verify` reads a pack that was written and refuses
7//! one whose files are not what its manifest names, or that carries a binary, an artifact, a
8//! link, a worktree or a leak. Writing the pack is `majordomus pack build`, which the command
9//! line alone offers, because every projection of the registry is read-only. The selection
10//! and the judgement live in [`crate::pack`].
11//!
12//! ```
13//! use majordomus_cli::capability::builtin::modules;
14//! let m = modules().into_iter().find(|m| m.id.as_str() == "pack").unwrap();
15//! let ids: Vec<&str> = m.capabilities.iter().map(|c| c.capability.id.as_str()).collect();
16//! assert_eq!(ids, ["pack.plan", "pack.verify"]);
17//! ```
18
19use std::path::{Component, Path, PathBuf};
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
25use crate::capability::handler::{CapabilityError, Context};
26use crate::capability::model::{CliExposure, Exposure, Stability};
27use crate::capability::module::ModuleDescriptor;
28use crate::pack::{PackPlan, PackVerdict, Planned, Profiles};
29use crate::{capability, module};
30
31use super::{get, mcp};
32
33/// The input of `pack.plan`: which profile of `share/archive.yaml` to plan, or the
34/// distribution's default one when none is named.
35///
36/// ```
37/// use majordomus_cli::capability::builtin::pack::PackPlanInput;
38/// let input = PackPlanInput { profile: Some("chatgpt".into()) };
39/// assert_eq!(input.profile.as_deref(), Some("chatgpt"));
40/// ```
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct PackPlanInput {
44    /// A profile of `share/archive.yaml`; its `default` when absent.
45    #[serde(default)]
46    pub profile: Option<String>,
47}
48
49impl BenchmarkCases for PackPlanInput {
50    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
51        vec![
52            NamedCase::new("default-profile", PackPlanInput { profile: None }),
53            NamedCase::new(
54                "chatgpt",
55                PackPlanInput {
56                    profile: Some("chatgpt".into()),
57                },
58            ),
59        ]
60    }
61}
62
63/// The input of `pack.verify`: the directory of a pack that was written, which must lie
64/// inside the repository it was built from.
65///
66/// ```
67/// use majordomus_cli::capability::builtin::pack::PackVerifyInput;
68/// let input = PackVerifyInput { dir: "tmp/packs/x".into() };
69/// assert_eq!(input.dir, "tmp/packs/x");
70/// ```
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72#[serde(deny_unknown_fields)]
73pub struct PackVerifyInput {
74    /// The pack's directory, inside the repository: relative to its root, or absolute
75    /// under it.
76    pub dir: String,
77}
78
79impl BenchmarkCases for PackVerifyInput {
80    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
81        // no pack exists in a fresh checkout, so the route answers with the unmeasured
82        // verdict, which still gives the required parameter its example
83        vec![NamedCase::new(
84            "absent",
85            PackVerifyInput {
86                dir: "tmp/packs/absent".into(),
87            },
88        )]
89    }
90}
91
92fn share_dir(ctx: &Context, root: &Path) -> Result<PathBuf, CapabilityError> {
93    crate::share::Share::locate(ctx.index.share.as_deref(), root)
94        .map(|s| s.dir().to_path_buf())
95        .map_err(|e| CapabilityError::Internal(e.to_string()))
96}
97
98/// The plan of `profile` for the repository the context reads, with the content it was
99/// made from: what `pack.plan` answers and what `majordomus pack build` writes. A
100/// distribution that cannot be found is a plan that could not be measured, as an unreadable
101/// profiles file is.
102pub(crate) fn planned(ctx: &Context, profile: Option<&str>) -> Planned {
103    let root = PathBuf::from(&ctx.index.repository.root);
104    match share_dir(ctx, &root) {
105        Ok(share) => crate::pack::plan(&root, &share, profile),
106        Err(e) => crate::pack::unmeasured(profile.unwrap_or(""), e.to_string()),
107    }
108}
109
110fn plan(ctx: &Context, input: PackPlanInput) -> Result<PackPlan, CapabilityError> {
111    Ok(planned(ctx, input.profile.as_deref()).plan)
112}
113
114/// The directory a verify input names, refused when it leaves the repository.
115fn inside(root: &Path, dir: &str) -> Result<PathBuf, CapabilityError> {
116    let p = Path::new(dir);
117    let rel = if p.is_absolute() {
118        p.strip_prefix(root)
119            .map_err(|_| CapabilityError::Refused(format!("{dir} is not inside the repository")))?
120            .to_path_buf()
121    } else {
122        p.to_path_buf()
123    };
124    if rel
125        .components()
126        .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
127    {
128        return Err(CapabilityError::Refused(format!(
129            "{dir} leaves the repository; a pack is verified where it was built"
130        )));
131    }
132    Ok(root.join(rel))
133}
134
135fn verify(ctx: &Context, input: PackVerifyInput) -> Result<PackVerdict, CapabilityError> {
136    let root = PathBuf::from(&ctx.index.repository.root);
137    let dir = inside(&root, &input.dir)?;
138    let share = share_dir(ctx, &root)?;
139    let profiles = Profiles::load(&share, &root).map_err(CapabilityError::Internal)?;
140    let mut v = crate::pack::verify(&dir, &profiles, &root);
141    v.dir = input.dir;
142    Ok(v)
143}
144
145/// The module: `pack.plan` and `pack.verify`, each exposed over MCP, HTTP and the command
146/// line; writing a pack stays with `majordomus pack build`.
147///
148/// ```
149/// use majordomus_cli::capability::builtin::pack::module;
150/// let m = module();
151/// assert_eq!(m.id.as_str(), "pack");
152/// let tools: Vec<_> = m
153///     .capabilities
154///     .iter()
155///     .filter_map(|c| c.capability.exposure.mcp.as_ref()?.tool.clone())
156///     .collect();
157/// assert_eq!(tools, ["majordomus_pack_plan", "majordomus_pack_verify"]);
158/// ```
159pub fn module() -> ModuleDescriptor {
160    module! {
161        id: "pack",
162        title: "Source pack",
163        description: "The tracked tree as a few token-bounded Markdown shards a language model's file search can index, with an index that orients the reader and a manifest that proves afterwards what left the machine: the git index's blobs only, never the working tree, and never a binary, a gitlink or worktree, a symbolic link, build output or a credential, whatever the index holds.",
164        stability: Stability::BehaviorallyVerified,
165        capabilities: [
166            capability! {
167                id: "pack.plan",
168                title: "Plan a source pack",
169                description: "What a profile of share/archive.yaml would pack: the files carried and their bytes and o200k_base tokens, every file left out by reason (worktree, link, artifact, binary, derived, excluded; derived counted, the rest listed), the shards it is cut into within the profile's token budget and file count, and every finding that refuses the build — a file over the budget, too many shards, a machine path or credential in a selected file, an empty selection, a profile without limits. Reads git's index and blobs only; writes nothing.",
170                input: PackPlanInput,
171                output: PackPlan,
172                stability: Stability::BehaviorallyVerified,
173                exposure: Exposure {
174                    mcp: mcp("majordomus_pack_plan"),
175                    http: get("/api/v1/pack/plan"),
176                    cli: Some(CliExposure { path: vec!["pack".into(), "plan".into()] }),
177                },
178                tags: ["pack", "archive", "review", "privacy"],
179                handler: plan,
180            },
181            capability! {
182                id: "pack.verify",
183                title: "Verify a written source pack",
184                description: "Read a pack directory inside the repository against its pack.json: every file it names is present with its digest and nothing else is, every carried file's content has the digest its marker records, no carried path is a binary, an artifact, a link or a gitlink under the profile the manifest names, no file is over the profile's token budget or holds a NUL byte or a leak, and there are no more files than the profile allows. An unreadable manifest is unmeasured, never a pass.",
185                input: PackVerifyInput,
186                output: PackVerdict,
187                stability: Stability::BehaviorallyVerified,
188                exposure: Exposure {
189                    mcp: mcp("majordomus_pack_verify"),
190                    http: get("/api/v1/pack/verify"),
191                    cli: Some(CliExposure { path: vec!["pack".into(), "verify".into()] }),
192                },
193                tags: ["pack", "archive", "privacy", "evidence"],
194                handler: verify,
195            },
196        ],
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn a_verify_input_never_leaves_the_repository() {
206        let root = Path::new("/r/repo");
207        assert_eq!(
208            inside(root, "tmp/packs/a").unwrap(),
209            root.join("tmp/packs/a")
210        );
211        assert_eq!(inside(root, "/r/repo/tmp/p").unwrap(), root.join("tmp/p"));
212        assert!(inside(root, "../other").is_err());
213        assert!(inside(root, "tmp/../../x").is_err());
214        assert!(inside(root, "/etc").is_err());
215    }
216
217    /// A context whose distribution is named and is not there: the one way a share can
218    /// fail to be found whatever the environment of the run holds.
219    #[test]
220    fn a_distribution_that_cannot_be_found_is_unmeasured_and_verifies_nothing() {
221        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
222        let built = repo.context().expect("a context");
223        let mut index = repo.index().expect("an index");
224        index.share = Some(PathBuf::from("/nonexistent/share"));
225        let ctx = Context::new(std::sync::Arc::new(index), built.registry.clone());
226
227        let p = plan(&ctx, PackPlanInput::default()).expect("answered");
228        assert!(!p.measured && !p.passes, "{p:?}");
229        assert!(p.reason.is_some_and(|r| r.contains("/nonexistent/share")));
230
231        let input = PackVerifyInput {
232            dir: "tmp/packs/x".into(),
233        };
234        let e = verify(&ctx, input).expect_err("refused");
235        assert!(matches!(e, CapabilityError::Internal(_)), "{e}");
236    }
237
238    #[test]
239    fn the_declaration_yields_the_projections_it_claims() {
240        let m = module();
241        let plan = &m.capabilities[0].capability.exposure;
242        assert_eq!(plan.http.as_ref().unwrap().path, "/api/v1/pack/plan");
243        assert_eq!(plan.cli.as_ref().unwrap().path, ["pack", "plan"]);
244        let verify = &m.capabilities[1].capability.exposure;
245        assert_eq!(
246            verify.mcp.as_ref().and_then(|m| m.tool.as_deref()),
247            Some("majordomus_pack_verify")
248        );
249    }
250}