Menu
AkurAI-Build
publicLatest change 2e8f5031c5c5b53391a730897f85d8e534670e9f - Reject Git-forbidden ref forms by fastcoder-engineer
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path};
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
const MAX_PIPELINE_BYTES: usize = 256 * 1024;
const MAX_JOBS: usize = 64;
const MAX_VARIANTS: usize = 32;
const MAX_EXPANDED_JOBS: usize = 256;
const MAX_COMMAND_BYTES: usize = 64 * 1024;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PipelineInput {
version: u8,
jobs: Vec<JobInput>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JobInput {
name: String,
needs: Option<Vec<String>>,
#[serde(default = "docker")]
executor: String,
image: Option<String>,
shell: Option<String>,
run: Commands,
#[serde(default)]
matrix: BTreeMap<String, Vec<String>>,
#[serde(default)]
artifacts: Vec<String>,
#[serde(default)]
cache: Vec<String>,
#[serde(default)]
network: bool,
#[serde(default)]
secrets: Vec<String>,
#[serde(default)]
installation_id: Option<String>,
#[serde(default)]
installation_revision: Option<i64>,
#[serde(default)]
rollback_of_deployment_id: Option<i64>,
#[serde(default)]
recovery_of_deployment_id: Option<i64>,
environment: Option<String>,
#[serde(default)]
approval: bool,
#[serde(default)]
branches: Vec<String>,
#[serde(default = "default_timeout")]
timeout: u64,
#[serde(default)]
profile: Option<ProfileInput>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Commands {
One(String),
Many(Vec<String>),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileSpec {
pub workload: String,
#[serde(default = "default_profile_samples")]
pub samples: u32,
}
type ProfileInput = ProfileSpec;
const fn default_profile_samples() -> u32 {
5
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JobSpec {
pub base_name: String,
pub name: String,
pub needs: Vec<String>,
pub executor: String,
pub image: Option<String>,
pub shell: Option<String>,
pub command: String,
pub matrix: BTreeMap<String, String>,
pub artifacts: Vec<String>,
pub cache: Vec<String>,
pub network: bool,
pub secrets: Vec<String>,
#[serde(default)]
pub installation_id: Option<String>,
#[serde(default)]
pub installation_revision: Option<i64>,
#[serde(default)]
pub rollback_of_deployment_id: Option<i64>,
#[serde(default)]
pub recovery_of_deployment_id: Option<i64>,
pub environment: Option<String>,
pub approval: bool,
pub branches: Vec<String>,
pub timeout: u64,
#[serde(default)]
pub profile: Option<ProfileSpec>,
}
fn docker() -> String {
"docker".into()
}
const fn default_timeout() -> u64 {
1800
}
pub fn parse(source: &str) -> Result<Vec<JobSpec>> {
ensure!(source.len() <= MAX_PIPELINE_BYTES, "pipeline is too large");
let pipeline: PipelineInput = serde_yaml::from_str(source).context("parse .akurai.yml")?;
ensure!(pipeline.version == 1, "pipeline version must be 1");
ensure!(
!pipeline.jobs.is_empty() && pipeline.jobs.len() <= MAX_JOBS,
"pipeline must contain 1..={MAX_JOBS} jobs"
);
let mut names = BTreeSet::new();
for job in &pipeline.jobs {
validate_name("job", &job.name)?;
ensure!(
names.insert(job.name.clone()),
"duplicate job: {}",
job.name
);
}
let mut expanded = Vec::new();
let mut graph = BTreeMap::new();
let mut previous: Option<String> = None;
for job in pipeline.jobs {
validate_job(&job)?;
let needs = job
.needs
.clone()
.unwrap_or_else(|| previous.iter().cloned().collect());
for need in &needs {
ensure!(
names.contains(need),
"job {} needs unknown job {need}",
job.name
);
ensure!(need != &job.name, "job {} cannot need itself", job.name);
}
graph.insert(job.name.clone(), needs.clone());
let variants = expand_matrix(&job.matrix)?;
for matrix in variants {
let suffix = matrix
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(",");
ensure!(
expanded.len() < MAX_EXPANDED_JOBS,
"pipeline expands beyond {MAX_EXPANDED_JOBS} jobs"
);
expanded.push(JobSpec {
base_name: job.name.clone(),
name: if suffix.is_empty() {
job.name.clone()
} else {
format!("{}[{suffix}]", job.name)
},
needs: needs.clone(),
executor: job.executor.clone(),
image: job.image.clone(),
shell: job.shell.clone(),
command: commands(&job.run)?,
matrix,
artifacts: job.artifacts.clone(),
cache: job.cache.clone(),
network: job.network,
installation_id: job.installation_id.clone(),
installation_revision: job.installation_revision,
rollback_of_deployment_id: job.rollback_of_deployment_id,
recovery_of_deployment_id: job.recovery_of_deployment_id,
environment: job.environment.clone(),
secrets: job.secrets.clone(),
approval: job.approval,
branches: job.branches.clone(),
timeout: job.timeout,
profile: job.profile.clone(),
});
}
previous = Some(job.name);
}
validate_dag(&graph)?;
Ok(expanded)
}
fn validate_job(job: &JobInput) -> Result<()> {
ensure!(
matches!(job.executor.as_str(), "docker" | "native"),
"job {} executor must be docker or native",
job.name
);
if job.executor == "docker" {
let image = job.image.as_deref().context("docker job requires image")?;
ensure!(
!image.starts_with('-') && !image.chars().any(char::is_whitespace),
"invalid Docker image"
);
ensure!(image.len() <= 256, "Docker image is too long");
} else {
ensure!(job.image.is_none(), "native job cannot set image");
}
if job.installation_id.is_some() || job.installation_revision.is_some() {
ensure!(
job.environment.is_some(),
"managed installation provenance requires an environment"
);
ensure!(
job.installation_id.is_some() && job.installation_revision.is_some(),
"managed installation provenance requires installation_id and installation_revision"
);
ensure!(
job.installation_revision
.is_some_and(|revision| revision > 0),
"installation_revision must be positive"
);
ensure!(
job.installation_id
.as_ref()
.is_some_and(|id| !id.is_empty() && id.len() <= 128),
"installation_id must be 1..=128 bytes"
);
}
if let Some(profile) = &job.profile {
ensure!(
!profile.workload.trim().is_empty(),
"profile workload must not be empty"
);
ensure!(
(1..=100).contains(&profile.samples),
"profile samples must be 1..=100"
);
}
for (name, id) in [
("rollback_of_deployment_id", job.rollback_of_deployment_id),
("recovery_of_deployment_id", job.recovery_of_deployment_id),
] {
ensure!(id.is_none_or(|value| value > 0), "{name} must be positive");
}
ensure!(
(job.rollback_of_deployment_id.is_none() && job.recovery_of_deployment_id.is_none())
|| (job.installation_id.is_some() && job.installation_revision.is_some()),
"rollback/recovery lineage requires managed installation provenance"
);
ensure!(
!job.approval || job.environment.is_some(),
"approval requires an environment"
);
if let Some(environment) = &job.environment {
validate_name("environment", environment)?;
}
if let Some(shell) = &job.shell {
ensure!(
matches!(shell.as_str(), "sh" | "cmd" | "powershell"),
"unsupported shell {shell}"
);
}
if let Some(platforms) = job.matrix.get("platform") {
for platform in platforms {
validate_platform(platform)?;
}
}
ensure!(
(1..=14_400).contains(&job.timeout),
"timeout must be 1..=14400 seconds"
);
ensure!(job.artifacts.len() <= 64, "too many artifact patterns");
ensure!(job.cache.len() <= 16, "too many cache paths");
for path in &job.artifacts {
validate_relative_path(path, true)?;
}
for path in &job.cache {
validate_relative_path(path, false)?;
}
for secret in &job.secrets {
ensure!(valid_env_name(secret), "invalid secret name: {secret}");
}
for branch in &job.branches {
validate_ref(branch)?;
}
commands(&job.run)?;
Ok(())
}
fn commands(value: &Commands) -> Result<String> {
let values = match value {
Commands::One(value) => vec![value.as_str()],
Commands::Many(values) => values.iter().map(String::as_str).collect(),
};
ensure!(!values.is_empty(), "run must not be empty");
ensure!(
values.iter().all(|value| !value.trim().is_empty()),
"run command must not be empty"
);
let joined = values.join("\n");
ensure!(
joined.len() <= MAX_COMMAND_BYTES,
"run command is too large"
);
Ok(joined)
}
fn expand_matrix(matrix: &BTreeMap<String, Vec<String>>) -> Result<Vec<BTreeMap<String, String>>> {
let mut variants = vec![BTreeMap::new()];
for (key, values) in matrix {
validate_name("matrix key", key)?;
ensure!(!values.is_empty(), "matrix axis {key} is empty");
ensure!(
values.len() <= MAX_VARIANTS,
"matrix axis {key} is too large"
);
let mut next = Vec::new();
for variant in &variants {
for value in values {
ensure!(
!value.is_empty() && value.len() <= 128,
"invalid matrix value"
);
let mut item = variant.clone();
item.insert(key.clone(), value.clone());
next.push(item);
ensure!(
next.len() <= MAX_VARIANTS,
"matrix expands beyond {MAX_VARIANTS} jobs"
);
}
}
variants = next;
}
Ok(variants)
}
fn validate_dag(graph: &BTreeMap<String, Vec<String>>) -> Result<()> {
let mut done = BTreeSet::new();
while done.len() < graph.len() {
let before = done.len();
for (name, needs) in graph {
if needs.iter().all(|need| done.contains(need)) {
done.insert(name.clone());
}
}
if done.len() == before {
bail!("pipeline contains a dependency cycle");
}
}
Ok(())
}
pub fn validate_ref(value: &str) -> Result<()> {
ensure!(!value.is_empty() && value.len() <= 200, "invalid Git ref");
ensure!(
!value.starts_with('-') && !value.starts_with('/') && !value.ends_with('/'),
"invalid Git ref"
);
ensure!(
value != "@"
&& !value.contains("..")
&& !value.contains("@{")
&& !value.contains("//")
&& !value.contains('\\')
&& !value.ends_with('.'),
"invalid Git ref"
);
ensure!(
value.split('/').all(|component| {
!component.starts_with('.')
&& !component.ends_with(".lock")
&& component
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
}),
"invalid Git ref"
);
Ok(())
}
pub fn validate_repo_name(name: &str) -> Result<()> {
ensure!(
!name.is_empty()
&& name.len() <= 64
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
"invalid repository name"
);
Ok(())
}
pub fn validate_repository(name: &str, url: &str, branch: &str) -> Result<()> {
validate_repo_name(name)?;
ensure!(
!url.starts_with('-') && url.len() <= 2048,
"invalid repository URL"
);
ensure!(
Path::new(url).is_absolute()
|| url.starts_with("https://")
|| url.starts_with("ssh://")
|| url.starts_with("git@"),
"repository must use HTTPS, SSH, or an absolute local path"
);
validate_ref(branch)
}
pub fn validate_commit(value: &str) -> Result<()> {
ensure!(
value.len() == 40 || value.len() == 64,
"commit must be a full hexadecimal object id"
);
ensure!(
value.bytes().all(|byte| byte.is_ascii_hexdigit()),
"invalid commit"
);
Ok(())
}
fn validate_relative_path(value: &str, allow_glob: bool) -> Result<()> {
ensure!(
!value.is_empty() && value.len() <= 512,
"invalid relative path"
);
ensure!(
!value.contains(['\\', ':', '\0']) && (allow_glob || !value.contains(['*', '?', '[', ']'])),
"invalid path syntax"
);
let plain = value.replace(['*', '?', '[', ']'], "x");
ensure!(
Path::new(&plain)
.components()
.all(|component| matches!(component, Component::Normal(_))),
"path must stay inside the workspace"
);
Ok(())
}
fn validate_platform(value: &str) -> Result<()> {
let mut parts = value.split('/');
let os = parts.next().unwrap_or_default();
let architecture = parts.next().unwrap_or_default();
ensure!(
os == "linux"
&& !architecture.is_empty()
&& parts.next().is_none()
&& [os, architecture].into_iter().all(|part| {
part.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'_' | b'-')
})
}),
"invalid Docker platform: {value}"
);
Ok(())
}
fn validate_name(label: &str, value: &str) -> Result<()> {
ensure!(!value.is_empty() && value.len() <= 64, "invalid {label}");
ensure!(
value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
"invalid {label}: {value}"
);
Ok(())
}
fn valid_env_name(value: &str) -> bool {
let mut bytes = value.bytes();
matches!(bytes.next(), Some(b'A'..=b'Z') | Some(b'_'))
&& bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
// ── parse ────────────────────────────────────────────────────────
const MINIMAL: &str =
"version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo ok\n";
#[test]
fn parses_minimal_valid_pipeline() -> Result<()> {
let jobs = parse(MINIMAL)?;
assert_eq!(jobs.len(), 1);
let job = &jobs[0];
assert_eq!(job.name, "build");
assert_eq!(job.executor, "docker");
assert_eq!(job.image.as_deref(), Some("alpine:3"));
assert_eq!(job.command, "echo ok");
assert!(job.cache.is_empty());
assert!(job.artifacts.is_empty());
Ok(())
}
#[test]
fn parses_native_executor() -> Result<()> {
let jobs = parse(
"version: 1\njobs:\n - name: lint\n executor: native\n run: cargo fmt --check\n",
)?;
assert_eq!(jobs[0].executor, "native");
assert!(jobs[0].image.is_none());
Ok(())
}
#[test]
fn expands_matrix_and_defaults_to_linear_dependencies() -> Result<()> {
let jobs = parse(
"version: 1\njobs:\n - name: test\n image: rust:1\n run: cargo test\n matrix:\n platform: [linux/amd64, linux/arm64]\n - name: package\n image: rust:1\n run: cargo build\n",
)?;
assert_eq!(jobs.len(), 3);
assert_eq!(jobs[2].needs, ["test"]);
Ok(())
}
#[test]
fn repository_pipeline_is_valid() -> Result<()> {
let jobs = parse(include_str!("../.akurai.yml"))?;
assert_eq!(jobs.len(), 3);
let deploy = &jobs[2];
assert_eq!(deploy.name, "deploy-production");
assert_eq!(deploy.needs, ["package"]);
assert_eq!(deploy.environment.as_deref(), Some("production"));
assert!(
!deploy.approval,
"deploy-production runs unattended once verify+package pass"
);
Ok(())
}
#[test]
fn parse_rejects_missing_version() {
assert!(parse("jobs:\n - name: build\n image: alpine:3\n run: echo ok\n").is_err());
}
#[test]
fn parse_rejects_wrong_version() {
assert!(
parse("version: 2\njobs:\n - name: build\n image: alpine:3\n run: echo ok\n")
.is_err()
);
}
#[test]
fn parse_rejects_empty_jobs() {
assert!(parse("version: 1\njobs: []\n").is_err());
assert!(parse("version: 1\n").is_err());
}
#[test]
fn parse_rejects_too_many_jobs() {
let mut yaml = String::from("version: 1\njobs:\n");
for i in 0..65 {
yaml.push_str(&format!(
" - name: job{i}\n image: alpine:3\n run: echo ok\n"
));
}
assert!(parse(&yaml).is_err());
}
#[test]
fn parse_rejects_missing_job_name() {
assert!(parse("version: 1\njobs:\n - image: alpine:3\n run: echo ok\n").is_err());
}
#[test]
fn parse_rejects_missing_run() {
assert!(parse("version: 1\njobs:\n - name: build\n image: alpine:3\n").is_err());
}
#[test]
fn parse_rejects_empty_run() {
assert!(
parse("version: 1\njobs:\n - name: build\n image: alpine:3\n run: \"\"\n")
.is_err()
);
assert!(
parse(
"version: 1\njobs:\n - name: build\n image: alpine:3\n run:\n - \"\"\n"
)
.is_err()
);
}
#[test]
fn parse_rejects_duplicate_job_names() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo a\n - name: build\n image: alpine:3\n run: echo b\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_self_dependency() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo a\n needs: [build]\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_unknown_dependency() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo a\n needs: [nonexistent]\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_invalid_executor() {
let source = "version: 1\njobs:\n - name: build\n executor: podman\n image: alpine:3\n run: echo ok\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_native_with_image() {
let source = "version: 1\njobs:\n - name: build\n executor: native\n image: alpine:3\n run: echo ok\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_docker_without_image() {
let source = "version: 1\njobs:\n - name: build\n executor: docker\n run: echo ok\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_approval_without_environment() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo ok\n approval: true\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_accepts_approval_with_environment() -> Result<()> {
let source = "version: 1\njobs:\n - name: deploy\n image: alpine:3\n run: echo ok\n approval: true\n environment: production\n";
let jobs = parse(source)?;
assert!(jobs[0].approval);
assert_eq!(jobs[0].environment.as_deref(), Some("production"));
Ok(())
}
#[test]
fn parse_rejects_cycles_traversal_and_unknown_fields() {
for source in [
"version: 1\njobs:\n- name: a\n needs: [b]\n image: x\n run: x\n- name: b\n needs: [a]\n image: x\n run: x\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n artifacts: [../secret]\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n magic: true\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n approval: true\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n cache: ['cache/**']\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n matrix:\n platform: ['--privileged']\n",
] {
assert!(parse(source).is_err(), "expected error for: {source}");
}
}
// ── validate_repo_name ───────────────────────────────────────────
#[test]
fn repo_name_accepts_valid() -> Result<()> {
for name in [
"app",
"my-app",
"app_v2",
"my.repo",
"a",
"123",
"_underscore",
] {
validate_repo_name(name).with_context(|| format!("should accept {name:?}"))?;
}
Ok(())
}
#[test]
fn repo_name_rejects_empty() {
assert!(validate_repo_name("").is_err());
}
#[test]
fn repo_name_rejects_path_traversal() {
for name in ["../escape", "./hidden", "sub/dir", "/etc", "a\\b"] {
assert!(validate_repo_name(name).is_err(), "should reject {name:?}");
}
}
#[test]
fn repo_name_rejects_control_chars() {
for name in ["a\nb", "tab\there", "null\0byte"] {
assert!(
validate_repo_name(name).is_err(),
"should reject control chars in {name:?}"
);
}
}
#[test]
fn repo_name_rejects_over_length() {
let long = "a".repeat(65);
assert!(validate_repo_name(&long).is_err());
}
#[test]
fn repo_name_rejects_shell_injection() {
for name in ["$(whoami)", "`id`", "a;rm -rf /", "a|cat /etc/passwd"] {
assert!(validate_repo_name(name).is_err(), "should reject {name:?}");
}
}
// ── validate_ref ─────────────────────────────────────────────────
#[test]
fn ref_accepts_valid() -> Result<()> {
for value in [
"main",
"refs/heads/main",
"feature/branch",
"v1.0.0",
"fix-123",
"a",
"release/2024.01",
] {
validate_ref(value).with_context(|| format!("should accept {value:?}"))?;
}
Ok(())
}
#[test]
fn ref_rejects_empty() {
assert!(validate_ref("").is_err());
}
#[test]
fn ref_rejects_leading_slash() {
assert!(validate_ref("/etc").is_err());
}
#[test]
fn ref_rejects_leading_dash() {
assert!(validate_ref("-f").is_err());
}
#[test]
fn ref_rejects_trailing_slash() {
assert!(validate_ref("branch/").is_err());
}
#[test]
fn ref_rejects_dotdot() {
assert!(validate_ref("a..b").is_err());
assert!(validate_ref("../escape").is_err());
}
#[test]
fn ref_rejects_at_brace() {
assert!(validate_ref("ref@{upstream}").is_err());
}
#[test]
fn ref_rejects_backslash() {
assert!(validate_ref("a\\b").is_err());
}
#[test]
fn ref_rejects_control_chars() {
for value in ["a\nb", "tab\there"] {
assert!(validate_ref(value).is_err(), "should reject {value:?}");
}
}
#[test]
fn ref_rejects_over_length() {
let long = "a".repeat(201);
assert!(validate_ref(&long).is_err());
}
#[test]
fn ref_rejects_git_forbidden_forms() {
for value in ["@", "a//b", ".hidden/main", "main.lock", "main."] {
assert!(validate_ref(value).is_err(), "should reject {value:?}");
}
}
#[test]
fn ref_rejects_shell_injection() {
for value in ["$(whoami)", "`id`", "a;rm -rf /"] {
assert!(validate_ref(value).is_err(), "should reject {value:?}");
}
}
}