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"`).
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}
97
98/// Errors that can occur while parsing or validating a manifest.
99#[derive(Debug, Error)]
100pub enum ManifestError {
101    /// Underlying TOML deserialization failed (syntax error, type mismatch, unknown variant).
102    #[error("toml parse: {0}")]
103    Toml(#[from] toml::de::Error),
104    /// `orchestrator.schema_version` does not satisfy the `~0.1` requirement.
105    #[error("schema version {got}: requires ~0.1")]
106    SchemaMismatch {
107        /// The offending version string from the manifest.
108        got: String,
109    },
110    /// Two or more `[[node]]` entries share the same `id`.
111    #[error("duplicate node id: {0}")]
112    DuplicateNodeId(String),
113    /// A `[[node]]` declares neither a `git` URL nor a local `path`.
114    #[error("node {0} has neither `git` nor `path`")]
115    NodeMissingSource(String),
116    /// A user-supplied URL would be interpreted as a CLI flag by the
117    /// downstream `git` / `cargo` invocation (CWE-88, argument injection).
118    #[error("invalid URL in node `{node}`: {reason}")]
119    InvalidUrl {
120        /// Node id that owns the offending URL.
121        node: String,
122        /// Why the URL was rejected (leading dash, unknown scheme, …).
123        reason: String,
124    },
125    /// A user-supplied identifier (crate name, feature name) would be
126    /// interpreted as a CLI flag or break shell argument parsing.
127    #[error("invalid argument in node `{node}`: {reason}")]
128    InvalidArg {
129        /// Node id that owns the offending field.
130        node: String,
131        /// Why the value was rejected.
132        reason: String,
133    },
134}
135
136/// URL schemes the orchestrator considers safe to hand to `git` and `cargo`.
137/// Anything else is rejected at parse time so a malicious manifest can't
138/// reach the subprocess layer.
139const ALLOWED_URL_SCHEMES: &[&str] = &["https://", "http://", "ssh://", "git@", "file://"];
140
141/// Reject strings that `git` / `cargo` would interpret as a CLI flag
142/// because they begin with `-`. The check is intentionally permissive
143/// for everything else — the goal is defense against trivial argument
144/// injection, not validation of the entire URL grammar.
145fn check_no_leading_dash(value: &str, node: &str, field: &str) -> Result<(), ManifestError> {
146    if value.starts_with('-') {
147        return Err(ManifestError::InvalidArg {
148            node: node.to_string(),
149            reason: format!("`{field}` starts with `-`, would be parsed as a CLI flag"),
150        });
151    }
152    Ok(())
153}
154
155/// Reject node ids that would let the manifest escape the workspace
156/// when used to construct a default clone path (`./{id}`):
157///
158/// - Empty ids (no directory).
159/// - Path separators `/` and `\`.
160/// - Drive-letter / alternate-data-stream separator `:` — on Windows
161///   `id = "C:foo"` makes `PathBuf::from("./C:foo")` resolve drive-relative
162///   and escape the workspace.
163/// - Control characters — NUL truncates at the syscall layer (the path
164///   actually opened on Unix is `./foo` not `./foo\0bar`); newlines confuse
165///   downstream tooling.
166/// - The literal `.` / `..` path-traversal tokens.
167fn check_node_id_safe(id: &str) -> Result<(), ManifestError> {
168    if id.is_empty() {
169        return Err(ManifestError::InvalidArg {
170            node: id.to_string(),
171            reason: "node id is empty".to_string(),
172        });
173    }
174    if let Some(c) = id.chars().find(|c| matches!(c, '/' | '\\' | ':')) {
175        return Err(ManifestError::InvalidArg {
176            node: id.to_string(),
177            reason: format!(
178                "node id `{id}` contains path separator `{c}` — would create nested, escaping, or drive-relative directories"
179            ),
180        });
181    }
182    if let Some(c) = id.chars().find(|c| c.is_control()) {
183        return Err(ManifestError::InvalidArg {
184            node: id.to_string(),
185            reason: format!("node id contains control character U+{:04X}", c as u32),
186        });
187    }
188    if id == "." || id == ".." {
189        return Err(ManifestError::InvalidArg {
190            node: id.to_string(),
191            reason: format!("node id `{id}` is a path-traversal token"),
192        });
193    }
194    Ok(())
195}
196
197/// Validate `url` against [`ALLOWED_URL_SCHEMES`], the no-leading-dash rule
198/// (including for host and path components — so `ssh://-oProxyCommand=evil@h/r`
199/// is rejected), and the no-control-characters rule. Empty URLs are rejected
200/// to keep `git ls-remote ""` from producing confusing diagnostics.
201fn check_url(url: &str, node: &str) -> Result<(), ManifestError> {
202    if url.is_empty() {
203        return Err(ManifestError::InvalidUrl {
204            node: node.to_string(),
205            reason: "URL is empty".into(),
206        });
207    }
208    if let Some(c) = url.chars().find(|c| c.is_control()) {
209        return Err(ManifestError::InvalidUrl {
210            node: node.to_string(),
211            reason: format!("URL contains control character U+{:04X}", c as u32),
212        });
213    }
214    if url.starts_with('-') {
215        return Err(ManifestError::InvalidUrl {
216            node: node.to_string(),
217            reason: format!("`{url}` starts with `-`, would be parsed as a CLI flag"),
218        });
219    }
220    let scheme = ALLOWED_URL_SCHEMES
221        .iter()
222        .find(|s| url.starts_with(*s))
223        .ok_or_else(|| ManifestError::InvalidUrl {
224            node: node.to_string(),
225            reason: format!("scheme not in allowlist {ALLOWED_URL_SCHEMES:?} (got `{url}`)"),
226        })?;
227
228    // For RFC-3986 URL schemes, parse and check host + each path segment for
229    // a leading `-`. This catches `ssh://-oProxyCommand=evil@host/r.git` and
230    // friends, which `starts_with('-')` on the whole URL misses.
231    // `git@host:org/repo` is SCP-style — not a valid URL — so we walk it
232    // manually instead.
233    if *scheme == "git@" {
234        check_scp_url(url, node)
235    } else {
236        check_rfc_url(url, node)
237    }
238}
239
240fn check_rfc_url(url: &str, node: &str) -> Result<(), ManifestError> {
241    let parsed = url::Url::parse(url).map_err(|e| ManifestError::InvalidUrl {
242        node: node.to_string(),
243        reason: format!("URL parse failed: {e}"),
244    })?;
245    // Userinfo: `ssh://-oProxyCommand=evil@host/r.git` parses with user
246    // `-oProxyCommand=evil`, which git/ssh can be tricked into treating as
247    // a flag depending on version.
248    let user = parsed.username();
249    if user.starts_with('-') {
250        return Err(ManifestError::InvalidUrl {
251            node: node.to_string(),
252            reason: format!("URL userinfo `{user}` starts with `-`"),
253        });
254    }
255    if let Some(host) = parsed.host_str()
256        && host.starts_with('-')
257    {
258        return Err(ManifestError::InvalidUrl {
259            node: node.to_string(),
260            reason: format!("host `{host}` starts with `-`"),
261        });
262    }
263    if let Some(segments) = parsed.path_segments() {
264        for s in segments {
265            if s.starts_with('-') {
266                return Err(ManifestError::InvalidUrl {
267                    node: node.to_string(),
268                    reason: format!("path segment `{s}` starts with `-`"),
269                });
270            }
271        }
272    }
273    Ok(())
274}
275
276fn check_scp_url(url: &str, node: &str) -> Result<(), ManifestError> {
277    let after_at = &url["git@".len()..];
278    let (host, path) = after_at.split_once(':').unwrap_or((after_at, ""));
279    if host.is_empty() || host.starts_with('-') {
280        return Err(ManifestError::InvalidUrl {
281            node: node.to_string(),
282            reason: format!("host `{host}` is empty or starts with `-`"),
283        });
284    }
285    for segment in path.split('/') {
286        if segment.starts_with('-') {
287            return Err(ManifestError::InvalidUrl {
288                node: node.to_string(),
289                reason: format!("path segment `{segment}` starts with `-`"),
290            });
291        }
292    }
293    Ok(())
294}
295
296impl Manifest {
297    /// Parse and validate a manifest from a TOML source string.
298    ///
299    /// Returns a fully-validated [`Manifest`] on success. Validation covers
300    /// schema version range, node-id uniqueness, and the per-node "must have
301    /// `git` or `path`" rule.
302    ///
303    /// # Errors
304    /// See [`ManifestError`] for the validation rules and the corresponding variants.
305    pub fn from_toml(src: &str) -> Result<Self, ManifestError> {
306        let m: Manifest = toml::from_str(src)?;
307        m.validate()?;
308        Ok(m)
309    }
310
311    /// Run all post-deserialize semantic checks. Called by [`Self::from_toml`].
312    fn validate(&self) -> Result<(), ManifestError> {
313        // schema_version is in 0.1.x range
314        let req = semver::VersionReq::parse("~0.1").unwrap();
315        let v = semver::Version::parse(&format!("{}.0", self.orchestrator.schema_version))
316            .map_err(|_| ManifestError::SchemaMismatch {
317                got: self.orchestrator.schema_version.clone(),
318            })?;
319        if !req.matches(&v) {
320            return Err(ManifestError::SchemaMismatch {
321                got: self.orchestrator.schema_version.clone(),
322            });
323        }
324        // Per-node checks: id uniqueness, source presence, then the
325        // argument-injection sweep on every user-controlled string field
326        // that ultimately reaches a subprocess.
327        let mut seen = std::collections::HashSet::new();
328        for n in &self.nodes {
329            check_node_id_safe(&n.id)?;
330            if !seen.insert(&n.id) {
331                return Err(ManifestError::DuplicateNodeId(n.id.clone()));
332            }
333            if n.git.is_none() && n.path.is_none() {
334                return Err(ManifestError::NodeMissingSource(n.id.clone()));
335            }
336            if let Some(url) = &n.git {
337                check_url(url, &n.id)?;
338            }
339            for action in &n.actions {
340                if let ActionEntry::CargoInstall {
341                    crate_name,
342                    features,
343                    ..
344                } = action
345                {
346                    if let Some(name) = crate_name {
347                        check_no_leading_dash(name, &n.id, "crate")?;
348                        if name.contains(',') {
349                            return Err(ManifestError::InvalidArg {
350                                node: n.id.clone(),
351                                reason: format!(
352                                    "`crate` name `{name}` contains `,` which would split cargo's --features list"
353                                ),
354                            });
355                        }
356                    }
357                    for feature in features {
358                        check_no_leading_dash(feature, &n.id, "features entry")?;
359                        if feature.contains(',') {
360                            return Err(ManifestError::InvalidArg {
361                                node: n.id.clone(),
362                                reason: format!(
363                                    "feature `{feature}` contains `,` which would split cargo's --features list"
364                                ),
365                            });
366                        }
367                    }
368                }
369            }
370        }
371        Ok(())
372    }
373
374    /// Enumerate every action across every node as a stable [`ActionId`].
375    ///
376    /// Each id has the shape `"{node.id}::{action-kind}::{index}"`, where
377    /// `index` is the action's position within its node (0-based). Ordering
378    /// matches manifest declaration order; downstream consumers (Plan, Cache)
379    /// rely on this for deterministic builds.
380    #[must_use]
381    pub fn action_ids(&self) -> Vec<ActionId> {
382        self.nodes
383            .iter()
384            .flat_map(|n| {
385                n.actions
386                    .iter()
387                    .enumerate()
388                    .map(move |(i, a)| ActionId(format!("{}::{}::{}", n.id, action_kind(a), i)))
389            })
390            .collect()
391    }
392}
393
394/// Kebab-case discriminator string for an [`ActionEntry`], matching
395/// the TOML `kind` tag. Public so the CLI's manifest factory uses the
396/// same single source of truth — adding a new action variant therefore
397/// only needs touching this match plus the variant declaration.
398#[must_use]
399pub fn action_kind(a: &ActionEntry) -> &'static str {
400    match a {
401        ActionEntry::GitClone => "git-clone",
402        ActionEntry::CargoInstall { .. } => "cargo-install",
403    }
404}