Skip to main content

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"`, `"repolith"`).
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    /// `kind = "repolith"` — federation: the node's checkout contains its
116    /// own `repolith.toml`, executed as a nested plan
117    /// (orchestrator-of-orchestrators).
118    ///
119    /// The nested stack keeps its **own local cache**, exactly as if
120    /// `repolith sync` had been run in that directory by hand. Cycle
121    /// detection and a maximum federation depth are enforced by the
122    /// executing action (`repolith-engine::federation`).
123    Repolith {
124        /// Child manifest path relative to the node's `path`. Defaults to
125        /// `repolith.toml`. Must stay inside the node's checkout: relative,
126        /// no `..` component, no leading dash, no control characters.
127        #[serde(default)]
128        manifest: Option<PathBuf>,
129    },
130}
131
132/// Errors that can occur while parsing or validating a manifest.
133#[derive(Debug, Error)]
134pub enum ManifestError {
135    /// Underlying TOML deserialization failed (syntax error, type mismatch, unknown variant).
136    #[error("toml parse: {0}")]
137    Toml(#[from] toml::de::Error),
138    /// `orchestrator.schema_version` does not satisfy the `~0.1` requirement.
139    #[error("schema version {got}: requires ~0.1")]
140    SchemaMismatch {
141        /// The offending version string from the manifest.
142        got: String,
143    },
144    /// Two or more `[[node]]` entries share the same `id`.
145    #[error("duplicate node id: {0}")]
146    DuplicateNodeId(String),
147    /// A `[[node]]` declares neither a `git` URL nor a local `path`.
148    #[error("node {0} has neither `git` nor `path`")]
149    NodeMissingSource(String),
150    /// A user-supplied URL would be interpreted as a CLI flag by the
151    /// downstream `git` / `cargo` invocation (CWE-88, argument injection).
152    #[error("invalid URL in node `{node}`: {reason}")]
153    InvalidUrl {
154        /// Node id that owns the offending URL.
155        node: String,
156        /// Why the URL was rejected (leading dash, unknown scheme, …).
157        reason: String,
158    },
159    /// A user-supplied identifier (crate name, feature name) would be
160    /// interpreted as a CLI flag or break shell argument parsing.
161    #[error("invalid argument in node `{node}`: {reason}")]
162    InvalidArg {
163        /// Node id that owns the offending field.
164        node: String,
165        /// Why the value was rejected.
166        reason: String,
167    },
168}
169
170/// URL schemes the orchestrator considers safe to hand to `git` and `cargo`.
171/// Anything else is rejected at parse time so a malicious manifest can't
172/// reach the subprocess layer.
173const ALLOWED_URL_SCHEMES: &[&str] = &["https://", "http://", "ssh://", "git@", "file://"];
174
175/// Reject strings that `git` / `cargo` would interpret as a CLI flag
176/// because they begin with `-`. The check is intentionally permissive
177/// for everything else — the goal is defense against trivial argument
178/// injection, not validation of the entire URL grammar.
179fn check_no_leading_dash(value: &str, node: &str, field: &str) -> Result<(), ManifestError> {
180    if value.starts_with('-') {
181        return Err(ManifestError::InvalidArg {
182            node: node.to_string(),
183            reason: format!("`{field}` starts with `-`, would be parsed as a CLI flag"),
184        });
185    }
186    Ok(())
187}
188
189/// Reject node ids that would let the manifest escape the workspace
190/// when used to construct a default clone path (`./{id}`):
191///
192/// - Empty ids (no directory).
193/// - Path separators `/` and `\`.
194/// - Drive-letter / alternate-data-stream separator `:` — on Windows
195///   `id = "C:foo"` makes `PathBuf::from("./C:foo")` resolve drive-relative
196///   and escape the workspace.
197/// - Control characters — NUL truncates at the syscall layer (the path
198///   actually opened on Unix is `./foo` not `./foo\0bar`); newlines confuse
199///   downstream tooling.
200/// - The literal `.` / `..` path-traversal tokens.
201fn check_node_id_safe(id: &str) -> Result<(), ManifestError> {
202    if id.is_empty() {
203        return Err(ManifestError::InvalidArg {
204            node: id.to_string(),
205            reason: "node id is empty".to_string(),
206        });
207    }
208    if let Some(c) = id.chars().find(|c| matches!(c, '/' | '\\' | ':')) {
209        return Err(ManifestError::InvalidArg {
210            node: id.to_string(),
211            reason: format!(
212                "node id `{id}` contains path separator `{c}` — would create nested, escaping, or drive-relative directories"
213            ),
214        });
215    }
216    if let Some(c) = id.chars().find(|c| c.is_control()) {
217        return Err(ManifestError::InvalidArg {
218            node: id.to_string(),
219            reason: format!("node id contains control character U+{:04X}", c as u32),
220        });
221    }
222    if id == "." || id == ".." {
223        return Err(ManifestError::InvalidArg {
224            node: id.to_string(),
225            reason: format!("node id `{id}` is a path-traversal token"),
226        });
227    }
228    Ok(())
229}
230
231/// Validate `url` against [`ALLOWED_URL_SCHEMES`], the no-leading-dash rule
232/// (including for host and path components — so `ssh://-oProxyCommand=evil@h/r`
233/// is rejected), and the no-control-characters rule. Empty URLs are rejected
234/// to keep `git ls-remote ""` from producing confusing diagnostics.
235fn check_url(url: &str, node: &str) -> Result<(), ManifestError> {
236    if url.is_empty() {
237        return Err(ManifestError::InvalidUrl {
238            node: node.to_string(),
239            reason: "URL is empty".into(),
240        });
241    }
242    if let Some(c) = url.chars().find(|c| c.is_control()) {
243        return Err(ManifestError::InvalidUrl {
244            node: node.to_string(),
245            reason: format!("URL contains control character U+{:04X}", c as u32),
246        });
247    }
248    if url.starts_with('-') {
249        return Err(ManifestError::InvalidUrl {
250            node: node.to_string(),
251            reason: format!("`{url}` starts with `-`, would be parsed as a CLI flag"),
252        });
253    }
254    let scheme = ALLOWED_URL_SCHEMES
255        .iter()
256        .find(|s| url.starts_with(*s))
257        .ok_or_else(|| ManifestError::InvalidUrl {
258            node: node.to_string(),
259            reason: format!("scheme not in allowlist {ALLOWED_URL_SCHEMES:?} (got `{url}`)"),
260        })?;
261
262    // For RFC-3986 URL schemes, parse and check host + each path segment for
263    // a leading `-`. This catches `ssh://-oProxyCommand=evil@host/r.git` and
264    // friends, which `starts_with('-')` on the whole URL misses.
265    // `git@host:org/repo` is SCP-style — not a valid URL — so we walk it
266    // manually instead.
267    if *scheme == "git@" {
268        check_scp_url(url, node)
269    } else {
270        check_rfc_url(url, node)
271    }
272}
273
274fn check_rfc_url(url: &str, node: &str) -> Result<(), ManifestError> {
275    let parsed = url::Url::parse(url).map_err(|e| ManifestError::InvalidUrl {
276        node: node.to_string(),
277        reason: format!("URL parse failed: {e}"),
278    })?;
279    // Userinfo: `ssh://-oProxyCommand=evil@host/r.git` parses with user
280    // `-oProxyCommand=evil`, which git/ssh can be tricked into treating as
281    // a flag depending on version.
282    let user = parsed.username();
283    if user.starts_with('-') {
284        return Err(ManifestError::InvalidUrl {
285            node: node.to_string(),
286            reason: format!("URL userinfo `{user}` starts with `-`"),
287        });
288    }
289    if let Some(host) = parsed.host_str()
290        && host.starts_with('-')
291    {
292        return Err(ManifestError::InvalidUrl {
293            node: node.to_string(),
294            reason: format!("host `{host}` starts with `-`"),
295        });
296    }
297    if let Some(segments) = parsed.path_segments() {
298        for s in segments {
299            if s.starts_with('-') {
300                return Err(ManifestError::InvalidUrl {
301                    node: node.to_string(),
302                    reason: format!("path segment `{s}` starts with `-`"),
303                });
304            }
305        }
306    }
307    Ok(())
308}
309
310fn check_scp_url(url: &str, node: &str) -> Result<(), ManifestError> {
311    let after_at = &url["git@".len()..];
312    let (host, path) = after_at.split_once(':').unwrap_or((after_at, ""));
313    if host.is_empty() || host.starts_with('-') {
314        return Err(ManifestError::InvalidUrl {
315            node: node.to_string(),
316            reason: format!("host `{host}` is empty or starts with `-`"),
317        });
318    }
319    for segment in path.split('/') {
320        if segment.starts_with('-') {
321            return Err(ManifestError::InvalidUrl {
322                node: node.to_string(),
323                reason: format!("path segment `{segment}` starts with `-`"),
324            });
325        }
326    }
327    Ok(())
328}
329
330/// Lexical containment check for `docker` action paths (`dockerfile`,
331/// `context`), mirroring [`check_node_id_safe`]. Runs at parse time with
332/// no filesystem access, so it can only guarantee the *lexical* form —
333/// symlink escapes are caught at run time by the action's canonicalize
334/// check (defense in depth).
335///
336/// Rejected:
337/// - Absolute paths — the whole point of these fields is "inside the
338///   node's checkout".
339/// - Any `..` component — lexical directory traversal.
340/// - A leading `-` on the path — `docker` would parse it as a flag
341///   (CWE-88), even behind `-f`/`--`.
342/// - Control characters — NUL truncates at the syscall layer, newlines
343///   confuse downstream tooling.
344fn check_contained_path(
345    path: &std::path::Path,
346    node: &str,
347    field: &str,
348) -> Result<(), ManifestError> {
349    let s = path.to_string_lossy();
350    if s.is_empty() {
351        return Err(ManifestError::InvalidArg {
352            node: node.to_string(),
353            reason: format!("`{field}` is empty"),
354        });
355    }
356    if let Some(c) = s.chars().find(|c| c.is_control()) {
357        return Err(ManifestError::InvalidArg {
358            node: node.to_string(),
359            reason: format!("`{field}` contains control character U+{:04X}", c as u32),
360        });
361    }
362    if s.starts_with('-') {
363        return Err(ManifestError::InvalidArg {
364            node: node.to_string(),
365            reason: format!("`{field}` `{s}` starts with `-`, would be parsed as a CLI flag"),
366        });
367    }
368    if path.is_absolute() {
369        return Err(ManifestError::InvalidArg {
370            node: node.to_string(),
371            reason: format!("`{field}` `{s}` is absolute — must stay inside the node's checkout"),
372        });
373    }
374    if path
375        .components()
376        .any(|c| matches!(c, std::path::Component::ParentDir))
377    {
378        return Err(ManifestError::InvalidArg {
379            node: node.to_string(),
380            reason: format!(
381                "`{field}` `{s}` contains `..` — path traversal outside the node's checkout"
382            ),
383        });
384    }
385    Ok(())
386}
387
388/// Validate a `docker` action image tag: non-empty, no leading dash
389/// (CWE-88), and restricted to the docker reference charset
390/// (`[a-zA-Z0-9._:/-]`) so the value can never smuggle whitespace or
391/// shell-relevant bytes into the `-t` argument.
392fn check_docker_tag(tag: &str, node: &str) -> Result<(), ManifestError> {
393    if tag.is_empty() {
394        return Err(ManifestError::InvalidArg {
395            node: node.to_string(),
396            reason: "`tag` is empty".to_string(),
397        });
398    }
399    if tag.starts_with('-') {
400        return Err(ManifestError::InvalidArg {
401            node: node.to_string(),
402            reason: format!("`tag` `{tag}` starts with `-`, would be parsed as a CLI flag"),
403        });
404    }
405    if let Some(c) = tag
406        .chars()
407        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '-')))
408    {
409        return Err(ManifestError::InvalidArg {
410            node: node.to_string(),
411            reason: format!("`tag` `{tag}` contains `{c}` — allowed charset is [a-zA-Z0-9._:/-]"),
412        });
413    }
414    Ok(())
415}
416
417impl Manifest {
418    /// Parse and validate a manifest from a TOML source string.
419    ///
420    /// Returns a fully-validated [`Manifest`] on success. Validation covers
421    /// schema version range, node-id uniqueness, and the per-node "must have
422    /// `git` or `path`" rule.
423    ///
424    /// # Errors
425    /// See [`ManifestError`] for the validation rules and the corresponding variants.
426    pub fn from_toml(src: &str) -> Result<Self, ManifestError> {
427        let m: Manifest = toml::from_str(src)?;
428        m.validate()?;
429        Ok(m)
430    }
431
432    /// Run all post-deserialize semantic checks. Called by [`Self::from_toml`].
433    fn validate(&self) -> Result<(), ManifestError> {
434        // schema_version is in 0.1.x range
435        let req = semver::VersionReq::parse("~0.1").unwrap();
436        let v = semver::Version::parse(&format!("{}.0", self.orchestrator.schema_version))
437            .map_err(|_| ManifestError::SchemaMismatch {
438                got: self.orchestrator.schema_version.clone(),
439            })?;
440        if !req.matches(&v) {
441            return Err(ManifestError::SchemaMismatch {
442                got: self.orchestrator.schema_version.clone(),
443            });
444        }
445        // Per-node checks: id uniqueness, source presence, then the
446        // argument-injection sweep on every user-controlled string field
447        // that ultimately reaches a subprocess.
448        let mut seen = std::collections::HashSet::new();
449        for n in &self.nodes {
450            check_node_id_safe(&n.id)?;
451            if !seen.insert(&n.id) {
452                return Err(ManifestError::DuplicateNodeId(n.id.clone()));
453            }
454            if n.git.is_none() && n.path.is_none() {
455                return Err(ManifestError::NodeMissingSource(n.id.clone()));
456            }
457            if let Some(url) = &n.git {
458                check_url(url, &n.id)?;
459            }
460            for action in &n.actions {
461                match action {
462                    ActionEntry::CargoInstall {
463                        crate_name,
464                        features,
465                        ..
466                    } => {
467                        if let Some(name) = crate_name {
468                            check_no_leading_dash(name, &n.id, "crate")?;
469                            if name.contains(',') {
470                                return Err(ManifestError::InvalidArg {
471                                    node: n.id.clone(),
472                                    reason: format!(
473                                        "`crate` name `{name}` contains `,` which would split cargo's --features list"
474                                    ),
475                                });
476                            }
477                        }
478                        for feature in features {
479                            check_no_leading_dash(feature, &n.id, "features entry")?;
480                            if feature.contains(',') {
481                                return Err(ManifestError::InvalidArg {
482                                    node: n.id.clone(),
483                                    reason: format!(
484                                        "feature `{feature}` contains `,` which would split cargo's --features list"
485                                    ),
486                                });
487                            }
488                        }
489                    }
490                    ActionEntry::Docker {
491                        tag,
492                        dockerfile,
493                        context,
494                    } => {
495                        check_docker_tag(tag, &n.id)?;
496                        if let Some(df) = dockerfile {
497                            check_contained_path(df, &n.id, "dockerfile")?;
498                        }
499                        if let Some(ctx) = context {
500                            check_contained_path(ctx, &n.id, "context")?;
501                        }
502                        // A docker build needs a checkout to build from.
503                        if n.path.is_none() {
504                            return Err(ManifestError::InvalidArg {
505                                node: n.id.clone(),
506                                reason:
507                                    "docker action requires `path` on the node (build context base)"
508                                        .to_string(),
509                            });
510                        }
511                    }
512                    ActionEntry::Repolith { manifest } => {
513                        if let Some(m) = manifest {
514                            check_contained_path(m, &n.id, "manifest")?;
515                        }
516                        // Federation descends into the node's checkout.
517                        if n.path.is_none() {
518                            return Err(ManifestError::InvalidArg {
519                                node: n.id.clone(),
520                                reason:
521                                    "repolith action requires `path` on the node (child stack root)"
522                                        .to_string(),
523                            });
524                        }
525                    }
526                    ActionEntry::GitClone => {}
527                }
528            }
529        }
530        Ok(())
531    }
532
533    /// Enumerate every action across every node as a stable [`ActionId`].
534    ///
535    /// Each id has the shape `"{node.id}::{action-kind}::{index}"`, where
536    /// `index` is the action's position within its node (0-based). Ordering
537    /// matches manifest declaration order; downstream consumers (Plan, Cache)
538    /// rely on this for deterministic builds.
539    #[must_use]
540    pub fn action_ids(&self) -> Vec<ActionId> {
541        self.nodes
542            .iter()
543            .flat_map(|n| {
544                n.actions
545                    .iter()
546                    .enumerate()
547                    .map(move |(i, a)| ActionId(format!("{}::{}::{}", n.id, action_kind(a), i)))
548            })
549            .collect()
550    }
551}
552
553/// Kebab-case discriminator string for an [`ActionEntry`], matching
554/// the TOML `kind` tag. Public so the CLI's manifest factory uses the
555/// same single source of truth — adding a new action variant therefore
556/// only needs touching this match plus the variant declaration.
557#[must_use]
558pub fn action_kind(a: &ActionEntry) -> &'static str {
559    match a {
560        ActionEntry::GitClone => "git-clone",
561        ActionEntry::CargoInstall { .. } => "cargo-install",
562        ActionEntry::Docker { .. } => "docker",
563        ActionEntry::Repolith { .. } => "repolith",
564    }
565}