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}