Merge pull request #70 from bahdotsh/fix/48-resolve-action-yml-for-docker-image

fix: resolve action.yml from remote repos to determine correct Docker image
This commit is contained in:
Gokul
2026-03-31 17:11:08 +05:30
committed by GitHub
7 changed files with 1715 additions and 364 deletions

245
Cargo.lock generated
View File

@@ -152,6 +152,27 @@ version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-channel"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
dependencies = [
"concurrent-queue",
"event-listener",
"futures-core",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -456,6 +477,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -596,7 +626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"rand_core 0.6.4",
"typenum",
]
@@ -609,6 +639,25 @@ dependencies = [
"cipher",
]
[[package]]
name = "deadpool"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e"
dependencies = [
"async-trait",
"deadpool-runtime",
"num_cpus",
"retain_mut",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "deranged"
version = "0.4.0"
@@ -693,6 +742,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -703,6 +758,15 @@ dependencies = [
"regex",
]
[[package]]
name = "fastrand"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
dependencies = [
"instant",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -794,6 +858,21 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
dependencies = [
"fastrand 1.9.0",
"futures-core",
"futures-io",
"memchr",
"parking",
"pin-project-lite",
"waker-fn",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@@ -817,6 +896,12 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.31"
@@ -845,6 +930,17 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [
"cfg-if",
"libc",
"wasi 0.9.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -1004,6 +1100,27 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-types"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad"
dependencies = [
"anyhow",
"async-channel",
"base64 0.13.1",
"futures-lite",
"http",
"infer",
"pin-project-lite",
"rand 0.7.3",
"serde",
"serde_json",
"serde_qs",
"serde_urlencoded",
"url",
]
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1242,6 +1359,12 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "infer"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac"
[[package]]
name = "inout"
version = "0.1.4"
@@ -1251,6 +1374,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "io-uring"
version = "0.7.9"
@@ -1613,6 +1745,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.4"
@@ -1778,6 +1916,19 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom 0.1.16",
"libc",
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
]
[[package]]
name = "rand"
version = "0.8.5"
@@ -1785,8 +1936,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
dependencies = [
"ppv-lite86",
"rand_core 0.5.1",
]
[[package]]
@@ -1796,7 +1957,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
dependencies = [
"getrandom 0.1.16",
]
[[package]]
@@ -1808,6 +1978,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "ratatui"
version = "0.23.0"
@@ -1935,6 +2114,12 @@ dependencies = [
"winreg",
]
[[package]]
name = "retain_mut"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0"
[[package]]
name = "ring"
version = "0.17.14"
@@ -2081,6 +2266,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_qs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6"
dependencies = [
"percent-encoding",
"serde",
"thiserror",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -2317,7 +2513,7 @@ version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
dependencies = [
"fastrand",
"fastrand 2.3.0",
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
@@ -2571,6 +2767,7 @@ dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
@@ -2608,6 +2805,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "waker-fn"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -2627,6 +2830,12 @@ dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3063,6 +3272,28 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "wiremock"
version = "0.5.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9"
dependencies = [
"assert-json-diff",
"async-trait",
"base64 0.21.7",
"deadpool",
"futures",
"futures-timer",
"http-types",
"hyper",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
@@ -3150,6 +3381,7 @@ dependencies = [
"num_cpus",
"once_cell",
"regex",
"reqwest",
"serde",
"serde_json",
"serde_yaml",
@@ -3158,6 +3390,7 @@ dependencies = [
"thiserror",
"tokio",
"uuid",
"wiremock",
"wrkflw-logging",
"wrkflw-matrix",
"wrkflw-models",
@@ -3276,7 +3509,7 @@ dependencies = [
"hmac",
"lazy_static",
"pbkdf2",
"rand",
"rand 0.8.5",
"regex",
"serde",
"serde_json",

View File

@@ -65,6 +65,7 @@ reqwest = { version = "0.11", default-features = false, features = [
libc = "0.2"
nix = { version = "0.27.1", features = ["fs"] }
urlencoding = "2.1.3"
wiremock = "0.5"
[profile.release]
codegen-units = 1

View File

@@ -32,6 +32,7 @@ lazy_static.workspace = true
num_cpus.workspace = true
once_cell.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
@@ -40,3 +41,6 @@ tempfile.workspace = true
thiserror.workspace = true
tokio.workspace = true
uuid.workspace = true
[dev-dependencies]
wiremock.workspace = true

View File

@@ -0,0 +1,736 @@
use once_cell::sync::Lazy;
use std::collections::{HashMap, VecDeque};
use tokio::sync::RwLock;
/// Maximum number of entries in the action resolution cache.
const MAX_CACHE_ENTRIES: usize = 256;
/// Represents the type of a GitHub Action as declared in its action.yml `runs.using` field.
#[derive(Debug, Clone)]
pub enum ActionType {
Node {
version: u32,
},
/// A Docker action that references a registry image (e.g., `rust:latest`).
Docker {
image: String,
},
/// A Docker action that bundles its own Dockerfile and needs to be built.
DockerBuild,
Composite,
}
/// Result of resolving a remote action's action.yml.
#[derive(Debug, Clone)]
pub struct ResolvedAction {
pub action_type: ActionType,
/// The raw parsed action.yml, available for composite action execution.
pub definition: Option<serde_yaml::Value>,
}
/// Bounded FIFO cache for successfully resolved actions keyed by "owner/repo@version".
/// Only successful resolutions are cached — transient failures are not persisted
/// so that retries can succeed if network conditions improve.
/// Eviction is insertion-order (FIFO), not access-order, which is sufficient here
/// because actions are typically resolved once per workflow run.
struct BoundedCache {
map: HashMap<String, ResolvedAction>,
/// Insertion order for FIFO eviction (oldest at front).
order: VecDeque<String>,
}
impl BoundedCache {
fn new() -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
}
}
fn get(&self, key: &str) -> Option<&ResolvedAction> {
self.map.get(key)
}
#[allow(clippy::map_entry)]
fn insert(&mut self, key: String, value: ResolvedAction) {
if self.map.contains_key(&key) {
// Already cached — update value, don't change insertion order
self.map.insert(key, value);
return;
}
// Evict oldest entries if at capacity
while self.map.len() >= MAX_CACHE_ENTRIES {
if let Some(oldest) = self.order.pop_front() {
self.map.remove(&oldest);
}
}
self.order.push_back(key.clone());
self.map.insert(key, value);
}
}
static ACTION_CACHE: Lazy<RwLock<BoundedCache>> = Lazy::new(|| RwLock::new(BoundedCache::new()));
/// Shared HTTP client to avoid repeated TLS initialization.
/// Timeout is kept low (5s) since resolution is best-effort with a fallback.
static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.user_agent("wrkflw")
.build()
.expect("Failed to create HTTP client")
});
/// Shared no-redirect HTTP client for authenticated requests.
/// Prevents leaking the GITHUB_TOKEN to redirect targets (e.g., CDN hosts).
/// Reused across requests to avoid per-request TLS initialization.
static NO_REDIRECT_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.user_agent("wrkflw")
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Failed to create no-redirect HTTP client")
});
const GITHUB_RAW_BASE_URL: &str = "https://raw.githubusercontent.com";
/// Fetch and parse `action.yml` (or `action.yaml`) from a remote GitHub repository.
///
/// `sub_path` is the optional path within the repo (e.g., for `owner/repo/path@ref`,
/// `sub_path` is `Some("path")`). When present, the action metadata is fetched from
/// `{repo}/{version}/{sub_path}/action.yml` instead of `{repo}/{version}/action.yml`.
///
/// Returns `Ok(ResolvedAction)` on success, or `Err` if the action metadata cannot be
/// fetched or parsed. Callers should fall back to hardcoded image mappings on error.
pub async fn resolve_remote_action(
repo: &str,
version: &str,
sub_path: Option<&str>,
) -> Result<ResolvedAction, String> {
let cache_key = match sub_path {
Some(p) => format!("{}/{}@{}", repo, p, version),
None => format!("{}@{}", repo, version),
};
// Check cache first (read lock — allows concurrent reads)
{
let cache = ACTION_CACHE.read().await;
if let Some(cached) = cache.get(&cache_key) {
return Ok(cached.clone());
}
}
let token = std::env::var("GITHUB_TOKEN").ok();
// Try action.yml first, then action.yaml
let result = match fetch_and_parse(
GITHUB_RAW_BASE_URL,
repo,
version,
sub_path,
"action.yml",
token.as_deref(),
)
.await
{
Ok(resolved) => Ok(resolved),
Err(yml_err) => fetch_and_parse(
GITHUB_RAW_BASE_URL,
repo,
version,
sub_path,
"action.yaml",
token.as_deref(),
)
.await
.map_err(|yaml_err| {
format!(
"Neither action.yml ({}) nor action.yaml ({}) could be resolved",
yml_err, yaml_err
)
}),
};
// Only cache successful resolutions — transient failures should be retryable
if let Ok(ref resolved) = result {
let mut cache = ACTION_CACHE.write().await;
cache.insert(cache_key, resolved.clone());
}
result
}
async fn fetch_and_parse(
base_url: &str,
repo: &str,
version: &str,
sub_path: Option<&str>,
filename: &str,
token: Option<&str>,
) -> Result<ResolvedAction, String> {
let url = match sub_path {
Some(p) => format!("{}/{}/{}/{}/{}", base_url, repo, version, p, filename),
None => format!("{}/{}/{}/{}", base_url, repo, version, filename),
};
// Try unauthenticated first; only send GITHUB_TOKEN on 404 (private repos).
let response = HTTP_CLIENT
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch {}: {}", url, e))?;
let response =
if response.status() == reqwest::StatusCode::NOT_FOUND {
// Retry with auth if token is available — the repo may be private.
// NO_REDIRECT_CLIENT prevents leaking the token to a non-GitHub host.
if let Some(token) = token {
let auth_response = NO_REDIRECT_CLIENT
.get(&url)
.header("Authorization", format!("token {}", token))
.send()
.await
.map_err(|e| format!("Failed to fetch {}: {}", url, e))?;
// The no-redirect policy prevents token leakage, but the server may
// legitimately redirect (CDN routing). If we get a 3xx, follow it
// without the auth header to avoid leaking the token.
if auth_response.status().is_redirection() {
if let Some(location) = auth_response.headers().get(reqwest::header::LOCATION) {
let redirect_url = location
.to_str()
.map_err(|_| "Invalid redirect URL encoding".to_string())?;
HTTP_CLIENT.get(redirect_url).send().await.map_err(|e| {
format!("Failed to follow redirect {}: {}", redirect_url, e)
})?
} else {
return Err(format!(
"HTTP {} (redirect with no Location header) fetching {}",
auth_response.status(),
url
));
}
} else {
auth_response
}
} else {
response
}
} else {
response
};
if !response.status().is_success() {
return Err(format!("HTTP {} fetching {}", response.status(), url));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
parse_action_definition(&body)
}
/// Parse an action.yml body and extract the action type from the `runs` section.
fn parse_action_definition(content: &str) -> Result<ResolvedAction, String> {
let def: serde_yaml::Value =
serde_yaml::from_str(content).map_err(|e| format!("Invalid action YAML: {}", e))?;
let runs = def
.get("runs")
.ok_or_else(|| "action.yml missing 'runs' section".to_string())?;
let using = runs
.get("using")
.and_then(|v| v.as_str())
.ok_or_else(|| "action.yml missing 'runs.using' field".to_string())?;
let action_type = parse_using(using, runs)?;
Ok(ResolvedAction {
action_type,
definition: Some(def),
})
}
/// Map the `runs.using` value to an `ActionType`.
fn parse_using(using: &str, runs: &serde_yaml::Value) -> Result<ActionType, String> {
match using {
"composite" => Ok(ActionType::Composite),
"docker" => {
let image = runs
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| "Docker action missing 'runs.image' field".to_string())?;
// Strip "docker://" prefix if present (some actions use it, some don't)
let image = image.trim_start_matches("docker://");
// If the image is "Dockerfile" or a relative path, it means the action
// bundles its own Dockerfile that needs to be built — not pulled from a registry.
if image == "Dockerfile"
|| image.starts_with("./")
|| image.starts_with("../")
|| image.ends_with("/Dockerfile")
{
Ok(ActionType::DockerBuild)
} else {
Ok(ActionType::Docker {
image: image.to_string(),
})
}
}
s if s.starts_with("node") => {
let version_str = s.trim_start_matches("node");
let version: u32 = version_str.parse().map_err(|_| {
format!(
"Invalid node version in runs.using '{}': expected 'node<N>' (e.g., 'node20')",
s
)
})?;
Ok(ActionType::Node { version })
}
other => Err(format!("Unknown runs.using value: {}", other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_node_action() {
let yaml = r#"
name: 'My Action'
runs:
using: 'node20'
main: 'index.js'
"#;
let resolved = parse_action_definition(yaml).unwrap();
match resolved.action_type {
ActionType::Node { version } => assert_eq!(version, 20),
other => panic!("Expected Node action, got {:?}", other),
}
}
#[test]
fn test_parse_docker_action() {
let yaml = r#"
name: 'Docker Action'
runs:
using: 'docker'
image: 'docker://rust:latest'
"#;
let resolved = parse_action_definition(yaml).unwrap();
match &resolved.action_type {
ActionType::Docker { image } => assert_eq!(image, "rust:latest"),
other => panic!("Expected Docker action, got {:?}", other),
}
}
#[test]
fn test_parse_docker_action_with_dockerfile() {
let yaml = r#"
name: 'Docker Action'
runs:
using: 'docker'
image: 'Dockerfile'
"#;
let resolved = parse_action_definition(yaml).unwrap();
assert!(
matches!(resolved.action_type, ActionType::DockerBuild),
"Expected DockerBuild, got {:?}",
resolved.action_type
);
}
#[test]
fn test_parse_docker_action_with_relative_dockerfile() {
let yaml = r#"
name: 'Docker Action'
runs:
using: 'docker'
image: './docker/Dockerfile'
"#;
let resolved = parse_action_definition(yaml).unwrap();
assert!(
matches!(resolved.action_type, ActionType::DockerBuild),
"Expected DockerBuild, got {:?}",
resolved.action_type
);
}
#[test]
fn test_parse_composite_action() {
let yaml = r#"
name: 'Composite Action'
runs:
using: 'composite'
steps:
- run: echo hello
"#;
let resolved = parse_action_definition(yaml).unwrap();
assert!(matches!(resolved.action_type, ActionType::Composite));
}
#[test]
fn test_parse_missing_runs() {
let yaml = r#"
name: 'Bad Action'
"#;
assert!(parse_action_definition(yaml).is_err());
}
#[test]
fn test_parse_node16_action() {
let yaml = r#"
name: 'Legacy Node Action'
runs:
using: 'node16'
main: 'index.js'
"#;
let resolved = parse_action_definition(yaml).unwrap();
match resolved.action_type {
ActionType::Node { version } => assert_eq!(version, 16),
other => panic!("Expected Node 16, got {:?}", other),
}
}
#[test]
fn test_parse_unknown_using_value() {
let yaml = r#"
name: 'Unknown Action'
runs:
using: 'python3'
"#;
let err = parse_action_definition(yaml).unwrap_err();
assert!(err.contains("Unknown runs.using value"));
}
#[test]
fn test_parse_missing_using_field() {
let yaml = r#"
name: 'Bad Action'
runs:
main: 'index.js'
"#;
let err = parse_action_definition(yaml).unwrap_err();
assert!(err.contains("runs.using"));
}
#[test]
fn test_parse_docker_missing_image() {
let yaml = r#"
name: 'Bad Docker Action'
runs:
using: 'docker'
"#;
let err = parse_action_definition(yaml).unwrap_err();
assert!(err.contains("runs.image"));
}
#[test]
fn test_parse_docker_with_docker_prefix_and_dockerfile() {
let yaml = r#"
name: 'Docker Action'
runs:
using: 'docker'
image: 'docker://Dockerfile'
"#;
let resolved = parse_action_definition(yaml).unwrap();
assert!(
matches!(resolved.action_type, ActionType::DockerBuild),
"docker://Dockerfile should be DockerBuild, got {:?}",
resolved.action_type
);
}
#[test]
fn test_resolved_action_has_definition() {
let yaml = r#"
name: 'My Action'
description: 'Test'
runs:
using: 'node20'
main: 'index.js'
"#;
let resolved = parse_action_definition(yaml).unwrap();
let def = resolved.definition.unwrap();
assert_eq!(def.get("name").unwrap().as_str().unwrap(), "My Action");
}
#[test]
fn test_parse_malformed_node_version_returns_error() {
let yaml = r#"
name: 'Bad Node Action'
runs:
using: 'nodefoo'
main: 'index.js'
"#;
let err = parse_action_definition(yaml).unwrap_err();
assert!(
err.contains("Invalid node version"),
"Expected error about invalid node version, got: {}",
err
);
}
#[test]
fn test_parse_bare_node_returns_error() {
let yaml = r#"
name: 'Bare Node Action'
runs:
using: 'node'
main: 'index.js'
"#;
let err = parse_action_definition(yaml).unwrap_err();
assert!(
err.contains("Invalid node version"),
"Expected error about invalid node version, got: {}",
err
);
}
#[tokio::test]
async fn test_cache_respects_max_capacity() {
let mut cache = BoundedCache::new();
// Fill beyond capacity
for i in 0..MAX_CACHE_ENTRIES + 10 {
cache.insert(
format!("owner/repo@v{}", i),
ResolvedAction {
action_type: ActionType::Node { version: 20 },
definition: None,
},
);
}
assert!(
cache.map.len() <= MAX_CACHE_ENTRIES,
"Cache size {} exceeds max {}",
cache.map.len(),
MAX_CACHE_ENTRIES
);
// Oldest entries should have been evicted
assert!(cache.get("owner/repo@v0").is_none());
// Newest entries should still be present
assert!(cache
.get(&format!("owner/repo@v{}", MAX_CACHE_ENTRIES + 9))
.is_some());
}
#[tokio::test]
async fn test_cache_duplicate_insert_does_not_grow() {
let mut cache = BoundedCache::new();
cache.insert(
"owner/repo@v1".to_string(),
ResolvedAction {
action_type: ActionType::Node { version: 20 },
definition: None,
},
);
cache.insert(
"owner/repo@v1".to_string(),
ResolvedAction {
action_type: ActionType::Node { version: 16 },
definition: None,
},
);
assert_eq!(cache.map.len(), 1);
// Value should be updated
match &cache.get("owner/repo@v1").unwrap().action_type {
ActionType::Node { version } => assert_eq!(*version, 16),
other => panic!("Expected Node, got {:?}", other),
}
}
/// Tests for `fetch_and_parse` HTTP behavior using wiremock.
///
/// Token is passed as a parameter to `fetch_and_parse`, so no env mutation is needed.
mod fetch_tests {
use super::super::*;
use wiremock::matchers::{header_exists, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const ACTION_YML_BODY: &str =
"name: Test Action\nruns:\n using: 'node20'\n main: 'index.js'\n";
#[tokio::test]
async fn fetch_success_parses_action_yml() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/owner/repo/v1/action.yml"))
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
.mount(&server)
.await;
let result =
fetch_and_parse(&server.uri(), "owner/repo", "v1", None, "action.yml", None).await;
let resolved = result.unwrap();
match resolved.action_type {
ActionType::Node { version } => assert_eq!(version, 20),
other => panic!("Expected Node action, got {:?}", other),
}
}
#[tokio::test]
async fn fetch_with_sub_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/owner/repo/v1/my/action/action.yml"))
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
.mount(&server)
.await;
let result = fetch_and_parse(
&server.uri(),
"owner/repo",
"v1",
Some("my/action"),
"action.yml",
None,
)
.await;
let resolved = result.unwrap();
assert!(matches!(
resolved.action_type,
ActionType::Node { version: 20 }
));
}
#[tokio::test]
async fn fetch_404_without_token_returns_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/owner/repo/v1/action.yml"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let result =
fetch_and_parse(&server.uri(), "owner/repo", "v1", None, "action.yml", None).await;
assert!(result.is_err());
assert!(
result.as_ref().unwrap_err().contains("404"),
"Expected 404 in error, got: {}",
result.unwrap_err()
);
}
/// Verifies the security-critical property: when the auth request gets a
/// redirect response (e.g., to a CDN), the redirect is followed WITHOUT
/// the Authorization header, preventing the GITHUB_TOKEN from leaking
/// to a non-GitHub host.
#[tokio::test]
async fn auth_redirect_does_not_leak_token() {
let server = MockServer::start().await;
// 1. Unauthenticated request → 404 (triggers auth retry).
// Mounted first so it has lowest priority in wiremock's LIFO matching.
Mock::given(method("GET"))
.and(path("/owner/repo/v1/action.yml"))
.respond_with(ResponseTemplate::new(404))
.up_to_n_times(1)
.mount(&server)
.await;
// 2. Authenticated retry → 302 redirect to a different path.
let redirect_url = format!("{}/cdn/redirected/action.yml", server.uri());
Mock::given(method("GET"))
.and(path("/owner/repo/v1/action.yml"))
.and(header_exists("Authorization"))
.respond_with(
ResponseTemplate::new(302).insert_header("Location", redirect_url.as_str()),
)
.mount(&server)
.await;
// 3. Redirect target → 200 with action.yml body.
Mock::given(method("GET"))
.and(path("/cdn/redirected/action.yml"))
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
.mount(&server)
.await;
let result = fetch_and_parse(
&server.uri(),
"owner/repo",
"v1",
None,
"action.yml",
Some("ghp_test_token_for_redirect_test"),
)
.await;
// The resolution should succeed via the redirect path
let resolved = result.unwrap();
assert!(matches!(
resolved.action_type,
ActionType::Node { version: 20 }
));
// Verify the redirect request did NOT include the Authorization header.
// This is the core security invariant: tokens must not leak to redirect targets.
let requests = server.received_requests().await.unwrap();
let redirect_req = requests
.iter()
.find(|r| r.url.path() == "/cdn/redirected/action.yml")
.expect("Expected a request to the redirect target");
let has_auth = redirect_req
.headers
.iter()
.any(|(name, _)| name.as_str() == "authorization");
assert!(
!has_auth,
"GITHUB_TOKEN leaked to redirect target! Authorization header found on redirect request."
);
}
#[tokio::test]
async fn auth_retry_on_404_with_token_succeeds() {
let server = MockServer::start().await;
// 1. Unauthenticated → 404
Mock::given(method("GET"))
.and(path("/owner/repo/v1/action.yml"))
.respond_with(ResponseTemplate::new(404))
.up_to_n_times(1)
.mount(&server)
.await;
// 2. Authenticated → 200 (private repo, no redirect)
Mock::given(method("GET"))
.and(path("/owner/repo/v1/action.yml"))
.and(header_exists("Authorization"))
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
.mount(&server)
.await;
let result = fetch_and_parse(
&server.uri(),
"owner/repo",
"v1",
None,
"action.yml",
Some("ghp_test_token_for_auth_test"),
)
.await;
let resolved = result.unwrap();
assert!(matches!(
resolved.action_type,
ActionType::Node { version: 20 }
));
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
#![allow(unused_variables, unused_assignments)]
pub mod action_resolver;
pub mod dependency;
pub mod docker;
pub mod engine;

View File

@@ -117,25 +117,71 @@ pub struct Step {
impl WorkflowDefinition {
pub fn resolve_action(&self, action_ref: &str) -> ActionInfo {
// Parse GitHub action reference like "actions/checkout@v3"
let parts: Vec<&str> = action_ref.split('@').collect();
let is_docker = action_ref.starts_with("docker://");
let is_local = action_ref.starts_with("./");
let (repo, _) = if parts.len() > 1 {
(parts[0], parts[1])
// Docker references can contain `@sha256:digest` (e.g., `docker://alpine@sha256:abc`).
// Don't split on `@` for Docker refs — the full string is the image reference.
// Local paths also never have a meaningful `@version`.
if is_docker {
return ActionInfo {
repository: action_ref.to_string(),
version: String::new(),
sub_path: None,
is_docker: true,
is_local: false,
};
}
if is_local {
return ActionInfo {
repository: action_ref.to_string(),
version: String::new(),
sub_path: None,
is_docker: false,
is_local: true,
};
}
// For GitHub action references, split on the first `@` to get repo and version.
let (full_repo, version) = if let Some(at_pos) = action_ref.find('@') {
(&action_ref[..at_pos], &action_ref[at_pos + 1..])
} else {
(parts[0], "main") // Default to main if no version specified
(action_ref, "main") // Default to main if no version specified
};
// GitHub action refs can include a sub-path: `owner/repo/path/to/action@ref`.
// Split into the repo (`owner/repo`) and optional sub-path (`path/to/action`).
let parts: Vec<&str> = full_repo.splitn(3, '/').collect();
let (repo, sub_path) = if parts.len() == 3 {
(
format!("{}/{}", parts[0], parts[1]),
Some(parts[2].to_string()),
)
} else {
(full_repo.to_string(), None)
};
ActionInfo {
repository: repo.to_string(),
is_docker: repo.starts_with("docker://"),
is_local: repo.starts_with("./"),
repository: repo,
version: version.to_string(),
sub_path,
is_docker: false,
is_local: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ActionInfo {
/// The repository identifier (`owner/repo`), Docker image ref, or local path.
pub repository: String,
/// The git ref (tag, branch, or SHA) for GitHub action references.
/// Empty for Docker refs (`docker://...`) and local paths (`./...`).
/// Defaults to `"main"` when a GitHub action ref omits `@version`.
pub version: String,
/// Optional sub-path within the repository for actions like `owner/repo/path@ref`.
/// `None` for simple `owner/repo@ref`, Docker refs, and local paths.
pub sub_path: Option<String>,
pub is_docker: bool,
pub is_local: bool,
}
@@ -193,10 +239,130 @@ fn normalize_triggers(on_value: &serde_yaml::Value) -> Result<Vec<String>, Strin
#[cfg(test)]
mod tests {
use super::parse_workflow;
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn resolve_action_parses_version() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
let info = wd.resolve_action("actions/checkout@v4");
assert_eq!(info.repository, "actions/checkout");
assert_eq!(info.version, "v4");
assert!(info.sub_path.is_none());
assert!(!info.is_docker);
assert!(!info.is_local);
}
#[test]
fn resolve_action_defaults_version_to_main() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
let info = wd.resolve_action("owner/repo");
assert_eq!(info.repository, "owner/repo");
assert_eq!(info.version, "main");
assert!(info.sub_path.is_none());
}
#[test]
fn resolve_action_docker_reference() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
let info = wd.resolve_action("docker://alpine:3.18");
assert_eq!(info.repository, "docker://alpine:3.18");
assert_eq!(info.version, "");
assert!(info.is_docker);
assert!(!info.is_local);
}
#[test]
fn resolve_action_local_path() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
let info = wd.resolve_action("./my-action");
assert_eq!(info.repository, "./my-action");
assert_eq!(info.version, "");
assert!(!info.is_docker);
assert!(info.is_local);
}
#[test]
fn resolve_action_docker_with_digest() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
// Docker image references can use @sha256:digest — the full string is the image ref
let info = wd.resolve_action("docker://alpine@sha256:abcdef1234567890");
assert_eq!(info.repository, "docker://alpine@sha256:abcdef1234567890");
assert_eq!(info.version, "");
assert!(info.is_docker);
assert!(!info.is_local);
}
#[test]
fn resolve_action_with_sha_version() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
let info = wd.resolve_action("actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675");
assert_eq!(info.repository, "actions/checkout");
assert_eq!(info.version, "a81bbbf8298c0fa03ea29cdc473d45769f953675");
assert!(info.sub_path.is_none());
}
#[test]
fn resolve_action_with_sub_path() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
let info = wd.resolve_action("owner/repo/path/to/action@v2");
assert_eq!(info.repository, "owner/repo");
assert_eq!(info.version, "v2");
assert_eq!(info.sub_path.as_deref(), Some("path/to/action"));
assert!(!info.is_docker);
assert!(!info.is_local);
}
#[test]
fn resolve_action_with_single_sub_path() {
let wd = WorkflowDefinition {
name: String::new(),
on: vec![],
on_raw: serde_yaml::Value::Null,
jobs: Default::default(),
};
let info = wd.resolve_action("github/codeql-action/init@v3");
assert_eq!(info.repository, "github/codeql-action");
assert_eq!(info.version, "v3");
assert_eq!(info.sub_path.as_deref(), Some("init"));
}
#[test]
fn parse_workflow_allows_null_workflow_dispatch_with_other_triggers() {
let temp_dir = tempdir().unwrap();