use crate::rules::Rule;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024;
const MAX_SERVICES: usize = 10_000;
const MAX_SERVICE_NAME_LEN: usize = 256;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Manifest {
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub discovery: DiscoveryConfig,
#[serde(default)]
pub policy: PolicyConfig,
pub services: Vec<ServiceEntry>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
let metadata = std::fs::metadata(path)
.with_context(|| format!("cannot stat manifest: {}", path.display()))?;
if metadata.len() > MAX_MANIFEST_SIZE {
anyhow::bail!(
"manifest file is too large ({} bytes, max {} bytes). This check prevents resource exhaustion from YAML expansion attacks.",
metadata.len(),
MAX_MANIFEST_SIZE
);
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read manifest: {}", path.display()))?;
let manifest: Self = serde_yaml::from_str(&text)
.with_context(|| format!("cannot parse manifest: {}", path.display()))?;
Self::validate_limits(&manifest, path)?;
Ok(manifest)
}
fn validate_limits(manifest: &Manifest, path: &Path) -> Result<()> {
if manifest.services.len() > MAX_SERVICES {
anyhow::bail!(
"manifest has too many services ({}, max {})",
manifest.services.len(),
MAX_SERVICES
);
}
for svc in &manifest.services {
if svc.name.len() > MAX_SERVICE_NAME_LEN {
anyhow::bail!(
"service name too long in {}: '{}' ({} bytes, max {})",
path.display(),
&svc.name[..MAX_SERVICE_NAME_LEN.min(50)],
svc.name.len(),
MAX_SERVICE_NAME_LEN
);
}
if svc.depends_on.len() > 1000 {
anyhow::bail!(
"service '{}' has too many dependencies ({}, max 1000)",
svc.name,
svc.depends_on.len()
);
}
svc.validate()
.with_context(|| format!("service '{}' has invalid paths", svc.name))?;
}
Ok(())
}
pub fn effective_discovery_paths(&self) -> Vec<String> {
if self.discovery.paths.is_empty() {
DEFAULT_DISCOVERY_PATHS
.iter()
.map(|s| s.to_string())
.collect()
} else {
self.discovery.paths.clone()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PolicyConfig {
#[serde(default)]
pub require_fields: Vec<String>,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DiscoveryConfig {
#[serde(default)]
pub paths: Vec<String>,
#[serde(default = "default_markers")]
pub markers: Vec<String>,
#[serde(default)]
pub ignore: Vec<String>,
}
pub const DEFAULT_DISCOVERY_PATHS: &[&str] =
&["services/*", "microservices/*", "apps/*", "packages/*"];
fn default_markers() -> Vec<String> {
default_markers_pub()
}
pub fn default_markers_pub() -> Vec<String> {
[
"Cargo.toml",
"Dockerfile",
"go.mod",
"package.json",
"pyproject.toml",
"requirements.txt",
"build.gradle",
"build.gradle.kts",
"pom.xml",
"CMakeLists.txt",
"Directory.Build.props",
"Gemfile",
"mix.exs",
"pubspec.yaml",
]
.iter()
.map(|s| s.to_string())
.collect()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[non_exhaustive]
pub struct ServiceEntry {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oncall: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub submodule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub docs: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ci: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
}
impl ServiceEntry {
pub fn declared_path(&self) -> Option<&str> {
self.path.as_deref().or(self.submodule.as_deref())
}
pub fn validate(&self) -> Result<()> {
validate_optional_path(&self.path, "path")?;
validate_optional_path(&self.submodule, "submodule")?;
validate_optional_path(&self.docs, "docs")?;
validate_optional_path(&self.ci, "ci")?;
Ok(())
}
}
fn validate_optional_path(path_opt: &Option<String>, field_name: &str) -> Result<()> {
let path = match path_opt {
Some(p) => p,
None => return Ok(()),
};
if path.is_empty() {
anyhow::bail!("{} field cannot be empty", field_name);
}
if path.starts_with('/') || path.starts_with('\\') {
anyhow::bail!(
"{}: absolute paths not allowed (must be relative to repo root): {}",
field_name,
path
);
}
if path.contains("..") {
anyhow::bail!(
"{}: path traversal not allowed (contains '..'): {}",
field_name,
path
);
}
if path.contains('\0') {
anyhow::bail!("{}: path contains null bytes", field_name);
}
if path.len() >= 2 && path.chars().nth(1) == Some(':') {
anyhow::bail!(
"{}: absolute paths (Windows drive letters) not allowed: {}",
field_name,
path
);
}
Ok(())
}
fn default_version() -> String {
"1".to_string()
}
pub fn find_default(root: &Path) -> PathBuf {
for name in &["svccat.yaml", "svccat.yml", "services.yaml", "services.yml"] {
let p = root.join(name);
if p.exists() {
return p;
}
}
root.join("services.yaml")
}