use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ManifestError {
#[error("Failed to read manifest file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse manifest YAML: {0}")]
ParseError(#[from] serde_yaml::Error),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Path escapes workspace boundary: {0}")]
PathTraversal(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PlatformType {
#[default]
#[serde(rename = "github")]
GitHub,
#[serde(rename = "gitlab")]
GitLab,
#[serde(rename = "azure-devops")]
AzureDevOps,
#[serde(rename = "bitbucket")]
Bitbucket,
}
impl std::fmt::Display for PlatformType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlatformType::GitHub => write!(f, "github"),
PlatformType::GitLab => write!(f, "gitlab"),
PlatformType::AzureDevOps => write!(f, "azure-devops"),
PlatformType::Bitbucket => write!(f, "bitbucket"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformConfig {
#[serde(rename = "type")]
pub platform_type: PlatformType,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopyFileConfig {
pub src: String,
pub dest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkFileConfig {
pub src: String,
pub dest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoConfig {
pub url: String,
pub path: String,
#[serde(default = "default_branch")]
pub default_branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub copyfile: Option<Vec<CopyFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linkfile: Option<Vec<LinkFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<PlatformConfig>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub reference: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
fn default_branch() -> String {
"main".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestRepoConfig {
pub url: String,
#[serde(default = "default_branch")]
pub default_branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub copyfile: Option<Vec<CopyFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linkfile: Option<Vec<LinkFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<PlatformConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MergeStrategy {
#[default]
AllOrNothing,
Independent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestSettings {
#[serde(default = "default_pr_prefix")]
pub pr_prefix: String,
#[serde(default)]
pub merge_strategy: MergeStrategy,
}
fn default_pr_prefix() -> String {
"[cross-repo]".to_string()
}
impl Default for ManifestSettings {
fn default() -> Self {
Self {
pr_prefix: default_pr_prefix(),
merge_strategy: MergeStrategy::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptStep {
pub name: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceScript {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub steps: Option<Vec<ScriptStep>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookCommand {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceHooks {
#[serde(rename = "post-sync", skip_serializing_if = "Option::is_none")]
pub post_sync: Option<Vec<HookCommand>>,
#[serde(rename = "post-checkout", skip_serializing_if = "Option::is_none")]
pub post_checkout: Option<Vec<HookCommand>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiStep {
pub name: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub continue_on_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiPipeline {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub steps: Vec<CiStep>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CiConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub pipelines: Option<HashMap<String, CiPipeline>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripts: Option<HashMap<String, WorkspaceScript>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<WorkspaceHooks>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ci: Option<CiConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
#[serde(default = "default_version")]
pub version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<ManifestRepoConfig>,
pub repos: HashMap<String, RepoConfig>,
#[serde(default)]
pub settings: ManifestSettings,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<WorkspaceConfig>,
}
fn default_version() -> u32 {
1
}
impl Manifest {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)?;
Self::parse(&content)
}
pub fn parse(yaml: &str) -> Result<Self, ManifestError> {
let manifest: Manifest = serde_yaml::from_str(yaml)?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.repos.is_empty() {
return Err(ManifestError::ValidationError(
"Manifest must have at least one repository".to_string(),
));
}
for (name, repo) in &self.repos {
self.validate_repo_config(name, repo)?;
}
if let Some(ref manifest_config) = self.manifest {
self.validate_file_configs(
"manifest",
&manifest_config.copyfile,
&manifest_config.linkfile,
)?;
}
if let Some(ref workspace) = self.workspace {
self.validate_workspace_config(workspace)?;
}
Ok(())
}
fn validate_repo_config(&self, name: &str, repo: &RepoConfig) -> Result<(), ManifestError> {
if repo.url.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' must have a URL",
name
)));
}
if repo.path.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' must have a path",
name
)));
}
if path_escapes_boundary(&repo.path) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' path escapes workspace boundary: {}",
name, repo.path
)));
}
self.validate_file_configs(name, &repo.copyfile, &repo.linkfile)?;
Ok(())
}
fn validate_file_configs(
&self,
repo_name: &str,
copyfile: &Option<Vec<CopyFileConfig>>,
linkfile: &Option<Vec<LinkFileConfig>>,
) -> Result<(), ManifestError> {
if let Some(ref copyfiles) = copyfile {
for cf in copyfiles {
if cf.src.is_empty() || cf.dest.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' has copyfile with empty src or dest",
repo_name
)));
}
if path_escapes_boundary(&cf.src) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' copyfile src escapes boundary: {}",
repo_name, cf.src
)));
}
if path_escapes_boundary(&cf.dest) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' copyfile dest escapes boundary: {}",
repo_name, cf.dest
)));
}
}
}
if let Some(ref linkfiles) = linkfile {
for lf in linkfiles {
if lf.src.is_empty() || lf.dest.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' has linkfile with empty src or dest",
repo_name
)));
}
if path_escapes_boundary(&lf.src) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' linkfile src escapes boundary: {}",
repo_name, lf.src
)));
}
if path_escapes_boundary(&lf.dest) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' linkfile dest escapes boundary: {}",
repo_name, lf.dest
)));
}
}
}
Ok(())
}
fn validate_workspace_config(&self, workspace: &WorkspaceConfig) -> Result<(), ManifestError> {
if let Some(ref scripts) = workspace.scripts {
for (name, script) in scripts {
match (&script.command, &script.steps) {
(Some(_), Some(_)) => {
return Err(ManifestError::ValidationError(format!(
"Script '{}' cannot have both 'command' and 'steps'",
name
)));
}
(None, None) => {
return Err(ManifestError::ValidationError(format!(
"Script '{}' must have either 'command' or 'steps'",
name
)));
}
(None, Some(steps)) => {
for step in steps {
if step.name.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Script '{}' has a step with empty name",
name
)));
}
if step.command.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Script '{}' step '{}' has empty command",
name, step.name
)));
}
}
}
(Some(_), None) => {
}
}
}
}
Ok(())
}
}
fn path_escapes_boundary(path: &str) -> bool {
let normalized = path.replace('\\', "/");
if normalized.starts_with("..") || normalized.starts_with('/') || normalized.contains("/../") {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.repos.len(), 1);
assert!(manifest.repos.contains_key("myrepo"));
}
#[test]
fn test_parse_full_manifest() {
let yaml = r#"
version: 1
manifest:
url: git@github.com:user/manifest.git
default_branch: main
repos:
app:
url: git@github.com:user/app.git
path: app
default_branch: main
copyfile:
- src: README.md
dest: APP_README.md
linkfile:
- src: config.yaml
dest: app-config.yaml
settings:
pr_prefix: "[multi-repo]"
merge_strategy: all-or-nothing
workspace:
env:
NODE_ENV: development
scripts:
build:
description: Build all packages
command: npm run build
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.version, 1);
assert!(manifest.manifest.is_some());
assert_eq!(manifest.repos.len(), 1);
assert_eq!(manifest.settings.pr_prefix, "[multi-repo]");
}
#[test]
fn test_empty_repos_fails() {
let yaml = r#"
repos: {}
"#;
let result = Manifest::parse(yaml);
assert!(result.is_err());
}
#[test]
fn test_path_traversal_fails() {
let yaml = r#"
repos:
evil:
url: git@github.com:user/repo.git
path: ../outside
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::PathTraversal(_))));
}
#[test]
fn test_absolute_path_fails() {
let yaml = r#"
repos:
evil:
url: git@github.com:user/repo.git
path: /etc/passwd
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::PathTraversal(_))));
}
#[test]
fn test_script_with_both_command_and_steps_fails() {
let yaml = r#"
repos:
app:
url: git@github.com:user/app.git
path: app
workspace:
scripts:
bad:
command: echo hello
steps:
- name: step1
command: echo step
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_path_escapes_boundary() {
assert!(path_escapes_boundary(".."));
assert!(path_escapes_boundary("../foo"));
assert!(path_escapes_boundary("/etc"));
assert!(path_escapes_boundary("foo/../../../etc"));
assert!(!path_escapes_boundary("foo"));
assert!(!path_escapes_boundary("foo/bar"));
assert!(!path_escapes_boundary("./foo"));
}
#[test]
fn test_reference_repos() {
let yaml = r#"
repos:
main-repo:
url: git@github.com:user/main.git
path: main
ref-repo:
url: https://github.com/other/reference.git
path: ./ref/reference
reference: true
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.repos.len(), 2);
let main_repo = manifest.repos.get("main-repo").unwrap();
assert!(!main_repo.reference);
let ref_repo = manifest.repos.get("ref-repo").unwrap();
assert!(ref_repo.reference);
}
#[test]
fn test_manifest_groups_parse() {
let yaml = r#"
repos:
frontend:
url: git@github.com:user/frontend.git
path: frontend
groups: [core, ui]
backend:
url: git@github.com:user/backend.git
path: backend
groups: [core, api]
docs:
url: git@github.com:user/docs.git
path: docs
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.repos.len(), 3);
let frontend = manifest.repos.get("frontend").unwrap();
assert_eq!(frontend.groups, vec!["core", "ui"]);
let backend = manifest.repos.get("backend").unwrap();
assert_eq!(backend.groups, vec!["core", "api"]);
let docs = manifest.repos.get("docs").unwrap();
assert!(docs.groups.is_empty());
}
#[test]
fn test_repos_without_groups_default_empty() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = manifest.repos.get("myrepo").unwrap();
assert!(repo.groups.is_empty());
}
#[test]
fn test_reference_default_false() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = manifest.repos.get("myrepo").unwrap();
assert!(!repo.reference); }
}