repolith_core/manifest.rs
1//! Repolith manifest schema and parser.
2//!
3//! The manifest (typically `repolith.toml` at the repo root) is the user-facing
4//! contract: it declares which upstream nodes to track and which actions to run
5//! against each.
6//!
7//! Parse via [`Manifest::from_toml`], which both deserializes and validates.
8//!
9//! [`Manifest::from_toml`]: crate::manifest::Manifest::from_toml
10
11use crate::types::ActionId;
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14use thiserror::Error;
15
16/// Top-level `[orchestrator]` section.
17#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
18pub struct OrchestratorMeta {
19 /// Manifest schema version, expected to match `~0.1` (semver tilde range).
20 pub schema_version: String,
21 /// Human-readable name for the orchestrator instance (e.g. `"anatta-rs"`).
22 pub name: String,
23}
24
25/// A single `[[node]]` entry — one upstream repository to manage.
26///
27/// **`git` and `path` semantics depend on the action kind**:
28///
29/// - For `kind = "git-clone"` actions, `git` is the **source** URL
30/// (required) and `path` is the **destination** working tree
31/// (optional, defaults to `./{node.id}`). Both fields can — and
32/// typically do — coexist on the same node.
33/// - For `kind = "cargo-install"` actions, `path` (when set) is the
34/// **source** crate root that wins over `git`. If only `git` is
35/// set, cargo is invoked with `--git <url>`. If both are set, `path`
36/// takes precedence — the assumption being you've already cloned via
37/// a sibling `git-clone` action.
38///
39/// The validator only requires that **at least one** of `git`/`path`
40/// is set — the per-action interpretation above lives in the CLI's
41/// manifest factory, not here.
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
43pub struct NodeEntry {
44 /// Unique identifier for the node within the manifest.
45 pub id: String,
46 /// Remote git URL. Required when [`Self::path`] is `None` and the
47 /// node has a `git-clone` action; required as the cargo-install
48 /// fallback when `path` is absent. URLs must use one of the
49 /// allowlisted schemes (`https://`, `http://`, `ssh://`, `git@`,
50 /// `file://`) and must not start with `-` — see the validator.
51 pub git: Option<String>,
52 /// Local path. For `git-clone` it's the destination working tree;
53 /// for `cargo-install` it's the source crate root (preferred over
54 /// `git`). Tilde expansion (`~/...`) is applied by the CLI before
55 /// the path reaches `git` / `cargo`.
56 pub path: Option<PathBuf>,
57 /// Ordered list of actions to run for this node.
58 #[serde(default, rename = "action")]
59 pub actions: Vec<ActionEntry>,
60}
61
62/// Root manifest document — deserialized from `repolith.toml`.
63///
64/// `[[attached]]` blocks are accepted in the TOML for forward
65/// compatibility but currently ignored (no field is collected). The
66/// `attached` schema is reserved for an M2 `template_apply` action and
67/// will land with that feature.
68#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
69pub struct Manifest {
70 /// `[orchestrator]` metadata (schema version, instance name).
71 pub orchestrator: OrchestratorMeta,
72 /// All `[[node]]` entries declared in the manifest.
73 #[serde(default, rename = "node")]
74 pub nodes: Vec<NodeEntry>,
75}
76
77/// Tagged variant of `[[node.action]]`, dispatched by the TOML `kind` field
78/// (kebab-case: `"git-clone"`, `"cargo-install"`, `"docker"`).
79#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80#[serde(tag = "kind", rename_all = "kebab-case")]
81pub enum ActionEntry {
82 /// `kind = "git-clone"` — fetch the node's source.
83 GitClone,
84 /// `kind = "cargo-install"` — `cargo install` from the node's source tree.
85 CargoInstall {
86 /// Crate name (TOML key `crate`). Defaults to the node's `id` when absent.
87 #[serde(default, rename = "crate")]
88 crate_name: Option<String>,
89 /// Cargo features to enable.
90 #[serde(default)]
91 features: Vec<String>,
92 /// Target install directory. Defaults to `~/.repolith/bin` when absent.
93 #[serde(default)]
94 install_to: Option<PathBuf>,
95 },
96 /// `kind = "docker"` — `docker build` an image from the node's checkout.
97 ///
98 /// Build-only by design: running containers is process supervision,
99 /// which repolith explicitly does not do (see README "What it is NOT").
100 Docker {
101 /// Image tag to apply (`-t`). Required; validated against the
102 /// docker reference charset and the no-leading-dash rule.
103 tag: String,
104 /// Dockerfile path relative to the build context. Defaults to
105 /// `Dockerfile`. Must stay inside the node's checkout: relative,
106 /// no `..` component, no leading dash, no control characters.
107 #[serde(default)]
108 dockerfile: Option<PathBuf>,
109 /// Build context directory relative to the node's `path`.
110 /// Defaults to the node's `path` itself. Same containment rules
111 /// as `dockerfile`.
112 #[serde(default)]
113 context: Option<PathBuf>,
114 },
115}
116
117/// Errors that can occur while parsing or validating a manifest.
118#[derive(Debug, Error)]
119pub enum ManifestError {
120 /// Underlying TOML deserialization failed (syntax error, type mismatch, unknown variant).
121 #[error("toml parse: {0}")]
122 Toml(#[from] toml::de::Error),
123 /// `orchestrator.schema_version` does not satisfy the `~0.1` requirement.
124 #[error("schema version {got}: requires ~0.1")]
125 SchemaMismatch {
126 /// The offending version string from the manifest.
127 got: String,
128 },
129 /// Two or more `[[node]]` entries share the same `id`.
130 #[error("duplicate node id: {0}")]
131 DuplicateNodeId(String),
132 /// A `[[node]]` declares neither a `git` URL nor a local `path`.
133 #[error("node {0} has neither `git` nor `path`")]
134 NodeMissingSource(String),
135 /// A user-supplied URL would be interpreted as a CLI flag by the
136 /// downstream `git` / `cargo` invocation (CWE-88, argument injection).
137 #[error("invalid URL in node `{node}`: {reason}")]
138 InvalidUrl {
139 /// Node id that owns the offending URL.
140 node: String,
141 /// Why the URL was rejected (leading dash, unknown scheme, …).
142 reason: String,
143 },
144 /// A user-supplied identifier (crate name, feature name) would be
145 /// interpreted as a CLI flag or break shell argument parsing.
146 #[error("invalid argument in node `{node}`: {reason}")]
147 InvalidArg {
148 /// Node id that owns the offending field.
149 node: String,
150 /// Why the value was rejected.
151 reason: String,
152 },
153}
154
155/// URL schemes the orchestrator considers safe to hand to `git` and `cargo`.
156/// Anything else is rejected at parse time so a malicious manifest can't
157/// reach the subprocess layer.
158const ALLOWED_URL_SCHEMES: &[&str] = &["https://", "http://", "ssh://", "git@", "file://"];
159
160/// Reject strings that `git` / `cargo` would interpret as a CLI flag
161/// because they begin with `-`. The check is intentionally permissive
162/// for everything else — the goal is defense against trivial argument
163/// injection, not validation of the entire URL grammar.
164fn check_no_leading_dash(value: &str, node: &str, field: &str) -> Result<(), ManifestError> {
165 if value.starts_with('-') {
166 return Err(ManifestError::InvalidArg {
167 node: node.to_string(),
168 reason: format!("`{field}` starts with `-`, would be parsed as a CLI flag"),
169 });
170 }
171 Ok(())
172}
173
174/// Reject node ids that would let the manifest escape the workspace
175/// when used to construct a default clone path (`./{id}`):
176///
177/// - Empty ids (no directory).
178/// - Path separators `/` and `\`.
179/// - Drive-letter / alternate-data-stream separator `:` — on Windows
180/// `id = "C:foo"` makes `PathBuf::from("./C:foo")` resolve drive-relative
181/// and escape the workspace.
182/// - Control characters — NUL truncates at the syscall layer (the path
183/// actually opened on Unix is `./foo` not `./foo\0bar`); newlines confuse
184/// downstream tooling.
185/// - The literal `.` / `..` path-traversal tokens.
186fn check_node_id_safe(id: &str) -> Result<(), ManifestError> {
187 if id.is_empty() {
188 return Err(ManifestError::InvalidArg {
189 node: id.to_string(),
190 reason: "node id is empty".to_string(),
191 });
192 }
193 if let Some(c) = id.chars().find(|c| matches!(c, '/' | '\\' | ':')) {
194 return Err(ManifestError::InvalidArg {
195 node: id.to_string(),
196 reason: format!(
197 "node id `{id}` contains path separator `{c}` — would create nested, escaping, or drive-relative directories"
198 ),
199 });
200 }
201 if let Some(c) = id.chars().find(|c| c.is_control()) {
202 return Err(ManifestError::InvalidArg {
203 node: id.to_string(),
204 reason: format!("node id contains control character U+{:04X}", c as u32),
205 });
206 }
207 if id == "." || id == ".." {
208 return Err(ManifestError::InvalidArg {
209 node: id.to_string(),
210 reason: format!("node id `{id}` is a path-traversal token"),
211 });
212 }
213 Ok(())
214}
215
216/// Validate `url` against [`ALLOWED_URL_SCHEMES`], the no-leading-dash rule
217/// (including for host and path components — so `ssh://-oProxyCommand=evil@h/r`
218/// is rejected), and the no-control-characters rule. Empty URLs are rejected
219/// to keep `git ls-remote ""` from producing confusing diagnostics.
220fn check_url(url: &str, node: &str) -> Result<(), ManifestError> {
221 if url.is_empty() {
222 return Err(ManifestError::InvalidUrl {
223 node: node.to_string(),
224 reason: "URL is empty".into(),
225 });
226 }
227 if let Some(c) = url.chars().find(|c| c.is_control()) {
228 return Err(ManifestError::InvalidUrl {
229 node: node.to_string(),
230 reason: format!("URL contains control character U+{:04X}", c as u32),
231 });
232 }
233 if url.starts_with('-') {
234 return Err(ManifestError::InvalidUrl {
235 node: node.to_string(),
236 reason: format!("`{url}` starts with `-`, would be parsed as a CLI flag"),
237 });
238 }
239 let scheme = ALLOWED_URL_SCHEMES
240 .iter()
241 .find(|s| url.starts_with(*s))
242 .ok_or_else(|| ManifestError::InvalidUrl {
243 node: node.to_string(),
244 reason: format!("scheme not in allowlist {ALLOWED_URL_SCHEMES:?} (got `{url}`)"),
245 })?;
246
247 // For RFC-3986 URL schemes, parse and check host + each path segment for
248 // a leading `-`. This catches `ssh://-oProxyCommand=evil@host/r.git` and
249 // friends, which `starts_with('-')` on the whole URL misses.
250 // `git@host:org/repo` is SCP-style — not a valid URL — so we walk it
251 // manually instead.
252 if *scheme == "git@" {
253 check_scp_url(url, node)
254 } else {
255 check_rfc_url(url, node)
256 }
257}
258
259fn check_rfc_url(url: &str, node: &str) -> Result<(), ManifestError> {
260 let parsed = url::Url::parse(url).map_err(|e| ManifestError::InvalidUrl {
261 node: node.to_string(),
262 reason: format!("URL parse failed: {e}"),
263 })?;
264 // Userinfo: `ssh://-oProxyCommand=evil@host/r.git` parses with user
265 // `-oProxyCommand=evil`, which git/ssh can be tricked into treating as
266 // a flag depending on version.
267 let user = parsed.username();
268 if user.starts_with('-') {
269 return Err(ManifestError::InvalidUrl {
270 node: node.to_string(),
271 reason: format!("URL userinfo `{user}` starts with `-`"),
272 });
273 }
274 if let Some(host) = parsed.host_str()
275 && host.starts_with('-')
276 {
277 return Err(ManifestError::InvalidUrl {
278 node: node.to_string(),
279 reason: format!("host `{host}` starts with `-`"),
280 });
281 }
282 if let Some(segments) = parsed.path_segments() {
283 for s in segments {
284 if s.starts_with('-') {
285 return Err(ManifestError::InvalidUrl {
286 node: node.to_string(),
287 reason: format!("path segment `{s}` starts with `-`"),
288 });
289 }
290 }
291 }
292 Ok(())
293}
294
295fn check_scp_url(url: &str, node: &str) -> Result<(), ManifestError> {
296 let after_at = &url["git@".len()..];
297 let (host, path) = after_at.split_once(':').unwrap_or((after_at, ""));
298 if host.is_empty() || host.starts_with('-') {
299 return Err(ManifestError::InvalidUrl {
300 node: node.to_string(),
301 reason: format!("host `{host}` is empty or starts with `-`"),
302 });
303 }
304 for segment in path.split('/') {
305 if segment.starts_with('-') {
306 return Err(ManifestError::InvalidUrl {
307 node: node.to_string(),
308 reason: format!("path segment `{segment}` starts with `-`"),
309 });
310 }
311 }
312 Ok(())
313}
314
315/// Lexical containment check for `docker` action paths (`dockerfile`,
316/// `context`), mirroring [`check_node_id_safe`]. Runs at parse time with
317/// no filesystem access, so it can only guarantee the *lexical* form —
318/// symlink escapes are caught at run time by the action's canonicalize
319/// check (defense in depth).
320///
321/// Rejected:
322/// - Absolute paths — the whole point of these fields is "inside the
323/// node's checkout".
324/// - Any `..` component — lexical directory traversal.
325/// - A leading `-` on the path — `docker` would parse it as a flag
326/// (CWE-88), even behind `-f`/`--`.
327/// - Control characters — NUL truncates at the syscall layer, newlines
328/// confuse downstream tooling.
329fn check_docker_path_safe(
330 path: &std::path::Path,
331 node: &str,
332 field: &str,
333) -> Result<(), ManifestError> {
334 let s = path.to_string_lossy();
335 if s.is_empty() {
336 return Err(ManifestError::InvalidArg {
337 node: node.to_string(),
338 reason: format!("`{field}` is empty"),
339 });
340 }
341 if let Some(c) = s.chars().find(|c| c.is_control()) {
342 return Err(ManifestError::InvalidArg {
343 node: node.to_string(),
344 reason: format!("`{field}` contains control character U+{:04X}", c as u32),
345 });
346 }
347 if s.starts_with('-') {
348 return Err(ManifestError::InvalidArg {
349 node: node.to_string(),
350 reason: format!("`{field}` `{s}` starts with `-`, would be parsed as a CLI flag"),
351 });
352 }
353 if path.is_absolute() {
354 return Err(ManifestError::InvalidArg {
355 node: node.to_string(),
356 reason: format!("`{field}` `{s}` is absolute — must stay inside the node's checkout"),
357 });
358 }
359 if path
360 .components()
361 .any(|c| matches!(c, std::path::Component::ParentDir))
362 {
363 return Err(ManifestError::InvalidArg {
364 node: node.to_string(),
365 reason: format!(
366 "`{field}` `{s}` contains `..` — path traversal outside the node's checkout"
367 ),
368 });
369 }
370 Ok(())
371}
372
373/// Validate a `docker` action image tag: non-empty, no leading dash
374/// (CWE-88), and restricted to the docker reference charset
375/// (`[a-zA-Z0-9._:/-]`) so the value can never smuggle whitespace or
376/// shell-relevant bytes into the `-t` argument.
377fn check_docker_tag(tag: &str, node: &str) -> Result<(), ManifestError> {
378 if tag.is_empty() {
379 return Err(ManifestError::InvalidArg {
380 node: node.to_string(),
381 reason: "`tag` is empty".to_string(),
382 });
383 }
384 if tag.starts_with('-') {
385 return Err(ManifestError::InvalidArg {
386 node: node.to_string(),
387 reason: format!("`tag` `{tag}` starts with `-`, would be parsed as a CLI flag"),
388 });
389 }
390 if let Some(c) = tag
391 .chars()
392 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '-')))
393 {
394 return Err(ManifestError::InvalidArg {
395 node: node.to_string(),
396 reason: format!("`tag` `{tag}` contains `{c}` — allowed charset is [a-zA-Z0-9._:/-]"),
397 });
398 }
399 Ok(())
400}
401
402impl Manifest {
403 /// Parse and validate a manifest from a TOML source string.
404 ///
405 /// Returns a fully-validated [`Manifest`] on success. Validation covers
406 /// schema version range, node-id uniqueness, and the per-node "must have
407 /// `git` or `path`" rule.
408 ///
409 /// # Errors
410 /// See [`ManifestError`] for the validation rules and the corresponding variants.
411 pub fn from_toml(src: &str) -> Result<Self, ManifestError> {
412 let m: Manifest = toml::from_str(src)?;
413 m.validate()?;
414 Ok(m)
415 }
416
417 /// Run all post-deserialize semantic checks. Called by [`Self::from_toml`].
418 fn validate(&self) -> Result<(), ManifestError> {
419 // schema_version is in 0.1.x range
420 let req = semver::VersionReq::parse("~0.1").unwrap();
421 let v = semver::Version::parse(&format!("{}.0", self.orchestrator.schema_version))
422 .map_err(|_| ManifestError::SchemaMismatch {
423 got: self.orchestrator.schema_version.clone(),
424 })?;
425 if !req.matches(&v) {
426 return Err(ManifestError::SchemaMismatch {
427 got: self.orchestrator.schema_version.clone(),
428 });
429 }
430 // Per-node checks: id uniqueness, source presence, then the
431 // argument-injection sweep on every user-controlled string field
432 // that ultimately reaches a subprocess.
433 let mut seen = std::collections::HashSet::new();
434 for n in &self.nodes {
435 check_node_id_safe(&n.id)?;
436 if !seen.insert(&n.id) {
437 return Err(ManifestError::DuplicateNodeId(n.id.clone()));
438 }
439 if n.git.is_none() && n.path.is_none() {
440 return Err(ManifestError::NodeMissingSource(n.id.clone()));
441 }
442 if let Some(url) = &n.git {
443 check_url(url, &n.id)?;
444 }
445 for action in &n.actions {
446 match action {
447 ActionEntry::CargoInstall {
448 crate_name,
449 features,
450 ..
451 } => {
452 if let Some(name) = crate_name {
453 check_no_leading_dash(name, &n.id, "crate")?;
454 if name.contains(',') {
455 return Err(ManifestError::InvalidArg {
456 node: n.id.clone(),
457 reason: format!(
458 "`crate` name `{name}` contains `,` which would split cargo's --features list"
459 ),
460 });
461 }
462 }
463 for feature in features {
464 check_no_leading_dash(feature, &n.id, "features entry")?;
465 if feature.contains(',') {
466 return Err(ManifestError::InvalidArg {
467 node: n.id.clone(),
468 reason: format!(
469 "feature `{feature}` contains `,` which would split cargo's --features list"
470 ),
471 });
472 }
473 }
474 }
475 ActionEntry::Docker {
476 tag,
477 dockerfile,
478 context,
479 } => {
480 check_docker_tag(tag, &n.id)?;
481 if let Some(df) = dockerfile {
482 check_docker_path_safe(df, &n.id, "dockerfile")?;
483 }
484 if let Some(ctx) = context {
485 check_docker_path_safe(ctx, &n.id, "context")?;
486 }
487 // A docker build needs a checkout to build from.
488 if n.path.is_none() {
489 return Err(ManifestError::InvalidArg {
490 node: n.id.clone(),
491 reason:
492 "docker action requires `path` on the node (build context base)"
493 .to_string(),
494 });
495 }
496 }
497 ActionEntry::GitClone => {}
498 }
499 }
500 }
501 Ok(())
502 }
503
504 /// Enumerate every action across every node as a stable [`ActionId`].
505 ///
506 /// Each id has the shape `"{node.id}::{action-kind}::{index}"`, where
507 /// `index` is the action's position within its node (0-based). Ordering
508 /// matches manifest declaration order; downstream consumers (Plan, Cache)
509 /// rely on this for deterministic builds.
510 #[must_use]
511 pub fn action_ids(&self) -> Vec<ActionId> {
512 self.nodes
513 .iter()
514 .flat_map(|n| {
515 n.actions
516 .iter()
517 .enumerate()
518 .map(move |(i, a)| ActionId(format!("{}::{}::{}", n.id, action_kind(a), i)))
519 })
520 .collect()
521 }
522}
523
524/// Kebab-case discriminator string for an [`ActionEntry`], matching
525/// the TOML `kind` tag. Public so the CLI's manifest factory uses the
526/// same single source of truth — adding a new action variant therefore
527/// only needs touching this match plus the variant declaration.
528#[must_use]
529pub fn action_kind(a: &ActionEntry) -> &'static str {
530 match a {
531 ActionEntry::GitClone => "git-clone",
532 ActionEntry::CargoInstall { .. } => "cargo-install",
533 ActionEntry::Docker { .. } => "docker",
534 }
535}