Skip to main content

wrkflw_executor/
action_resolver.rs

1use once_cell::sync::Lazy;
2use std::collections::{HashMap, VecDeque};
3use tokio::sync::RwLock;
4
5/// Maximum number of entries in the action resolution cache.
6const MAX_CACHE_ENTRIES: usize = 256;
7
8/// Represents the type of a GitHub Action as declared in its action.yml `runs.using` field.
9#[derive(Debug, Clone)]
10pub enum ActionType {
11    Node {
12        version: u32,
13    },
14    /// A Docker action that references a registry image (e.g., `rust:latest`).
15    Docker {
16        image: String,
17    },
18    /// A Docker action that bundles its own Dockerfile and needs to be built.
19    DockerBuild,
20    Composite,
21}
22
23/// Result of resolving a remote action's action.yml.
24#[derive(Debug, Clone)]
25pub struct ResolvedAction {
26    pub action_type: ActionType,
27    /// The raw parsed action.yml, available for composite action execution.
28    pub definition: Option<serde_yaml::Value>,
29}
30
31/// Bounded FIFO cache for successfully resolved actions keyed by "owner/repo@version".
32/// Only successful resolutions are cached — transient failures are not persisted
33/// so that retries can succeed if network conditions improve.
34/// Eviction is insertion-order (FIFO), not access-order, which is sufficient here
35/// because actions are typically resolved once per workflow run.
36struct BoundedCache {
37    map: HashMap<String, ResolvedAction>,
38    /// Insertion order for FIFO eviction (oldest at front).
39    order: VecDeque<String>,
40}
41
42impl BoundedCache {
43    fn new() -> Self {
44        Self {
45            map: HashMap::new(),
46            order: VecDeque::new(),
47        }
48    }
49
50    fn get(&self, key: &str) -> Option<&ResolvedAction> {
51        self.map.get(key)
52    }
53
54    #[allow(clippy::map_entry)]
55    fn insert(&mut self, key: String, value: ResolvedAction) {
56        if self.map.contains_key(&key) {
57            // Already cached — update value, don't change insertion order
58            self.map.insert(key, value);
59            return;
60        }
61        // Evict oldest entries if at capacity
62        while self.map.len() >= MAX_CACHE_ENTRIES {
63            if let Some(oldest) = self.order.pop_front() {
64                self.map.remove(&oldest);
65            }
66        }
67        self.order.push_back(key.clone());
68        self.map.insert(key, value);
69    }
70}
71
72static ACTION_CACHE: Lazy<RwLock<BoundedCache>> = Lazy::new(|| RwLock::new(BoundedCache::new()));
73
74/// Shared HTTP client to avoid repeated TLS initialization.
75/// Timeout is kept low (5s) since resolution is best-effort with a fallback.
76static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
77    reqwest::Client::builder()
78        .timeout(std::time::Duration::from_secs(5))
79        .user_agent("wrkflw")
80        .build()
81        .expect("Failed to create HTTP client")
82});
83
84/// Shared no-redirect HTTP client for authenticated requests.
85/// Prevents leaking the GITHUB_TOKEN to redirect targets (e.g., CDN hosts).
86/// Reused across requests to avoid per-request TLS initialization.
87static NO_REDIRECT_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
88    reqwest::Client::builder()
89        .timeout(std::time::Duration::from_secs(5))
90        .user_agent("wrkflw")
91        .redirect(reqwest::redirect::Policy::none())
92        .build()
93        .expect("Failed to create no-redirect HTTP client")
94});
95
96const GITHUB_RAW_BASE_URL: &str = "https://raw.githubusercontent.com";
97
98/// Fetch and parse `action.yml` (or `action.yaml`) from a remote GitHub repository.
99///
100/// `sub_path` is the optional path within the repo (e.g., for `owner/repo/path@ref`,
101/// `sub_path` is `Some("path")`). When present, the action metadata is fetched from
102/// `{repo}/{version}/{sub_path}/action.yml` instead of `{repo}/{version}/action.yml`.
103///
104/// Returns `Ok(ResolvedAction)` on success, or `Err` if the action metadata cannot be
105/// fetched or parsed. Callers should fall back to hardcoded image mappings on error.
106pub async fn resolve_remote_action(
107    repo: &str,
108    version: &str,
109    sub_path: Option<&str>,
110) -> Result<ResolvedAction, String> {
111    let cache_key = match sub_path {
112        Some(p) => format!("{}/{}@{}", repo, p, version),
113        None => format!("{}@{}", repo, version),
114    };
115
116    // Check cache first (read lock — allows concurrent reads)
117    {
118        let cache = ACTION_CACHE.read().await;
119        if let Some(cached) = cache.get(&cache_key) {
120            return Ok(cached.clone());
121        }
122    }
123
124    let token = std::env::var("GITHUB_TOKEN").ok();
125
126    // Try action.yml first, then action.yaml
127    let result = match fetch_and_parse(
128        GITHUB_RAW_BASE_URL,
129        repo,
130        version,
131        sub_path,
132        "action.yml",
133        token.as_deref(),
134    )
135    .await
136    {
137        Ok(resolved) => Ok(resolved),
138        Err(yml_err) => fetch_and_parse(
139            GITHUB_RAW_BASE_URL,
140            repo,
141            version,
142            sub_path,
143            "action.yaml",
144            token.as_deref(),
145        )
146        .await
147        .map_err(|yaml_err| {
148            format!(
149                "Neither action.yml ({}) nor action.yaml ({}) could be resolved",
150                yml_err, yaml_err
151            )
152        }),
153    };
154
155    // Only cache successful resolutions — transient failures should be retryable
156    if let Ok(ref resolved) = result {
157        let mut cache = ACTION_CACHE.write().await;
158        cache.insert(cache_key, resolved.clone());
159    }
160
161    result
162}
163
164async fn fetch_and_parse(
165    base_url: &str,
166    repo: &str,
167    version: &str,
168    sub_path: Option<&str>,
169    filename: &str,
170    token: Option<&str>,
171) -> Result<ResolvedAction, String> {
172    let url = match sub_path {
173        Some(p) => format!("{}/{}/{}/{}/{}", base_url, repo, version, p, filename),
174        None => format!("{}/{}/{}/{}", base_url, repo, version, filename),
175    };
176
177    // Try unauthenticated first; only send GITHUB_TOKEN on 404 (private repos).
178    let response = HTTP_CLIENT
179        .get(&url)
180        .send()
181        .await
182        .map_err(|e| format!("Failed to fetch {}: {}", url, e))?;
183
184    let response =
185        if response.status() == reqwest::StatusCode::NOT_FOUND {
186            // Retry with auth if token is available — the repo may be private.
187            // NO_REDIRECT_CLIENT prevents leaking the token to a non-GitHub host.
188            if let Some(token) = token {
189                let auth_response = NO_REDIRECT_CLIENT
190                    .get(&url)
191                    .header("Authorization", format!("token {}", token))
192                    .send()
193                    .await
194                    .map_err(|e| format!("Failed to fetch {}: {}", url, e))?;
195
196                // The no-redirect policy prevents token leakage, but the server may
197                // legitimately redirect (CDN routing). If we get a 3xx, follow it
198                // without the auth header to avoid leaking the token.
199                if auth_response.status().is_redirection() {
200                    if let Some(location) = auth_response.headers().get(reqwest::header::LOCATION) {
201                        let redirect_url = location
202                            .to_str()
203                            .map_err(|_| "Invalid redirect URL encoding".to_string())?;
204                        HTTP_CLIENT.get(redirect_url).send().await.map_err(|e| {
205                            format!("Failed to follow redirect {}: {}", redirect_url, e)
206                        })?
207                    } else {
208                        return Err(format!(
209                            "HTTP {} (redirect with no Location header) fetching {}",
210                            auth_response.status(),
211                            url
212                        ));
213                    }
214                } else {
215                    auth_response
216                }
217            } else {
218                response
219            }
220        } else {
221            response
222        };
223
224    if !response.status().is_success() {
225        return Err(format!("HTTP {} fetching {}", response.status(), url));
226    }
227
228    let body = response
229        .text()
230        .await
231        .map_err(|e| format!("Failed to read response body: {}", e))?;
232
233    parse_action_definition(&body)
234}
235
236/// Parse an action.yml body and extract the action type from the `runs` section.
237fn parse_action_definition(content: &str) -> Result<ResolvedAction, String> {
238    let def: serde_yaml::Value =
239        serde_yaml::from_str(content).map_err(|e| format!("Invalid action YAML: {}", e))?;
240
241    let runs = def
242        .get("runs")
243        .ok_or_else(|| "action.yml missing 'runs' section".to_string())?;
244
245    let using = runs
246        .get("using")
247        .and_then(|v| v.as_str())
248        .ok_or_else(|| "action.yml missing 'runs.using' field".to_string())?;
249
250    let action_type = parse_using(using, runs)?;
251
252    Ok(ResolvedAction {
253        action_type,
254        definition: Some(def),
255    })
256}
257
258/// Map the `runs.using` value to an `ActionType`.
259fn parse_using(using: &str, runs: &serde_yaml::Value) -> Result<ActionType, String> {
260    match using {
261        "composite" => Ok(ActionType::Composite),
262
263        "docker" => {
264            let image = runs
265                .get("image")
266                .and_then(|v| v.as_str())
267                .ok_or_else(|| "Docker action missing 'runs.image' field".to_string())?;
268
269            // Strip "docker://" prefix if present (some actions use it, some don't)
270            let image = image.trim_start_matches("docker://");
271
272            // If the image is "Dockerfile" or a relative path, it means the action
273            // bundles its own Dockerfile that needs to be built — not pulled from a registry.
274            if image == "Dockerfile"
275                || image.starts_with("./")
276                || image.starts_with("../")
277                || image.ends_with("/Dockerfile")
278            {
279                Ok(ActionType::DockerBuild)
280            } else {
281                Ok(ActionType::Docker {
282                    image: image.to_string(),
283                })
284            }
285        }
286
287        s if s.starts_with("node") => {
288            let version_str = s.trim_start_matches("node");
289            let version: u32 = version_str.parse().map_err(|_| {
290                format!(
291                    "Invalid node version in runs.using '{}': expected 'node<N>' (e.g., 'node20')",
292                    s
293                )
294            })?;
295            Ok(ActionType::Node { version })
296        }
297
298        other => Err(format!("Unknown runs.using value: {}", other)),
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_parse_node_action() {
308        let yaml = r#"
309name: 'My Action'
310runs:
311  using: 'node20'
312  main: 'index.js'
313"#;
314        let resolved = parse_action_definition(yaml).unwrap();
315        match resolved.action_type {
316            ActionType::Node { version } => assert_eq!(version, 20),
317            other => panic!("Expected Node action, got {:?}", other),
318        }
319    }
320
321    #[test]
322    fn test_parse_docker_action() {
323        let yaml = r#"
324name: 'Docker Action'
325runs:
326  using: 'docker'
327  image: 'docker://rust:latest'
328"#;
329        let resolved = parse_action_definition(yaml).unwrap();
330        match &resolved.action_type {
331            ActionType::Docker { image } => assert_eq!(image, "rust:latest"),
332            other => panic!("Expected Docker action, got {:?}", other),
333        }
334    }
335
336    #[test]
337    fn test_parse_docker_action_with_dockerfile() {
338        let yaml = r#"
339name: 'Docker Action'
340runs:
341  using: 'docker'
342  image: 'Dockerfile'
343"#;
344        let resolved = parse_action_definition(yaml).unwrap();
345        assert!(
346            matches!(resolved.action_type, ActionType::DockerBuild),
347            "Expected DockerBuild, got {:?}",
348            resolved.action_type
349        );
350    }
351
352    #[test]
353    fn test_parse_docker_action_with_relative_dockerfile() {
354        let yaml = r#"
355name: 'Docker Action'
356runs:
357  using: 'docker'
358  image: './docker/Dockerfile'
359"#;
360        let resolved = parse_action_definition(yaml).unwrap();
361        assert!(
362            matches!(resolved.action_type, ActionType::DockerBuild),
363            "Expected DockerBuild, got {:?}",
364            resolved.action_type
365        );
366    }
367
368    #[test]
369    fn test_parse_composite_action() {
370        let yaml = r#"
371name: 'Composite Action'
372runs:
373  using: 'composite'
374  steps:
375    - run: echo hello
376"#;
377        let resolved = parse_action_definition(yaml).unwrap();
378        assert!(matches!(resolved.action_type, ActionType::Composite));
379    }
380
381    #[test]
382    fn test_parse_missing_runs() {
383        let yaml = r#"
384name: 'Bad Action'
385"#;
386        assert!(parse_action_definition(yaml).is_err());
387    }
388
389    #[test]
390    fn test_parse_node16_action() {
391        let yaml = r#"
392name: 'Legacy Node Action'
393runs:
394  using: 'node16'
395  main: 'index.js'
396"#;
397        let resolved = parse_action_definition(yaml).unwrap();
398        match resolved.action_type {
399            ActionType::Node { version } => assert_eq!(version, 16),
400            other => panic!("Expected Node 16, got {:?}", other),
401        }
402    }
403
404    #[test]
405    fn test_parse_unknown_using_value() {
406        let yaml = r#"
407name: 'Unknown Action'
408runs:
409  using: 'python3'
410"#;
411        let err = parse_action_definition(yaml).unwrap_err();
412        assert!(err.contains("Unknown runs.using value"));
413    }
414
415    #[test]
416    fn test_parse_missing_using_field() {
417        let yaml = r#"
418name: 'Bad Action'
419runs:
420  main: 'index.js'
421"#;
422        let err = parse_action_definition(yaml).unwrap_err();
423        assert!(err.contains("runs.using"));
424    }
425
426    #[test]
427    fn test_parse_docker_missing_image() {
428        let yaml = r#"
429name: 'Bad Docker Action'
430runs:
431  using: 'docker'
432"#;
433        let err = parse_action_definition(yaml).unwrap_err();
434        assert!(err.contains("runs.image"));
435    }
436
437    #[test]
438    fn test_parse_docker_with_docker_prefix_and_dockerfile() {
439        let yaml = r#"
440name: 'Docker Action'
441runs:
442  using: 'docker'
443  image: 'docker://Dockerfile'
444"#;
445        let resolved = parse_action_definition(yaml).unwrap();
446        assert!(
447            matches!(resolved.action_type, ActionType::DockerBuild),
448            "docker://Dockerfile should be DockerBuild, got {:?}",
449            resolved.action_type
450        );
451    }
452
453    #[test]
454    fn test_resolved_action_has_definition() {
455        let yaml = r#"
456name: 'My Action'
457description: 'Test'
458runs:
459  using: 'node20'
460  main: 'index.js'
461"#;
462        let resolved = parse_action_definition(yaml).unwrap();
463        let def = resolved.definition.unwrap();
464        assert_eq!(def.get("name").unwrap().as_str().unwrap(), "My Action");
465    }
466
467    #[test]
468    fn test_parse_malformed_node_version_returns_error() {
469        let yaml = r#"
470name: 'Bad Node Action'
471runs:
472  using: 'nodefoo'
473  main: 'index.js'
474"#;
475        let err = parse_action_definition(yaml).unwrap_err();
476        assert!(
477            err.contains("Invalid node version"),
478            "Expected error about invalid node version, got: {}",
479            err
480        );
481    }
482
483    #[test]
484    fn test_parse_bare_node_returns_error() {
485        let yaml = r#"
486name: 'Bare Node Action'
487runs:
488  using: 'node'
489  main: 'index.js'
490"#;
491        let err = parse_action_definition(yaml).unwrap_err();
492        assert!(
493            err.contains("Invalid node version"),
494            "Expected error about invalid node version, got: {}",
495            err
496        );
497    }
498
499    #[tokio::test]
500    async fn test_cache_respects_max_capacity() {
501        let mut cache = BoundedCache::new();
502        // Fill beyond capacity
503        for i in 0..MAX_CACHE_ENTRIES + 10 {
504            cache.insert(
505                format!("owner/repo@v{}", i),
506                ResolvedAction {
507                    action_type: ActionType::Node { version: 20 },
508                    definition: None,
509                },
510            );
511        }
512        assert!(
513            cache.map.len() <= MAX_CACHE_ENTRIES,
514            "Cache size {} exceeds max {}",
515            cache.map.len(),
516            MAX_CACHE_ENTRIES
517        );
518        // Oldest entries should have been evicted
519        assert!(cache.get("owner/repo@v0").is_none());
520        // Newest entries should still be present
521        assert!(cache
522            .get(&format!("owner/repo@v{}", MAX_CACHE_ENTRIES + 9))
523            .is_some());
524    }
525
526    #[tokio::test]
527    async fn test_cache_duplicate_insert_does_not_grow() {
528        let mut cache = BoundedCache::new();
529        cache.insert(
530            "owner/repo@v1".to_string(),
531            ResolvedAction {
532                action_type: ActionType::Node { version: 20 },
533                definition: None,
534            },
535        );
536        cache.insert(
537            "owner/repo@v1".to_string(),
538            ResolvedAction {
539                action_type: ActionType::Node { version: 16 },
540                definition: None,
541            },
542        );
543        assert_eq!(cache.map.len(), 1);
544        // Value should be updated
545        match &cache.get("owner/repo@v1").unwrap().action_type {
546            ActionType::Node { version } => assert_eq!(*version, 16),
547            other => panic!("Expected Node, got {:?}", other),
548        }
549    }
550
551    /// Tests for `fetch_and_parse` HTTP behavior using wiremock.
552    ///
553    /// Token is passed as a parameter to `fetch_and_parse`, so no env mutation is needed.
554    mod fetch_tests {
555        use super::super::*;
556        use wiremock::matchers::{header_exists, method, path};
557        use wiremock::{Mock, MockServer, ResponseTemplate};
558
559        const ACTION_YML_BODY: &str =
560            "name: Test Action\nruns:\n  using: 'node20'\n  main: 'index.js'\n";
561
562        #[tokio::test]
563        async fn fetch_success_parses_action_yml() {
564            let server = MockServer::start().await;
565
566            Mock::given(method("GET"))
567                .and(path("/owner/repo/v1/action.yml"))
568                .respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
569                .mount(&server)
570                .await;
571
572            let result =
573                fetch_and_parse(&server.uri(), "owner/repo", "v1", None, "action.yml", None).await;
574
575            let resolved = result.unwrap();
576            match resolved.action_type {
577                ActionType::Node { version } => assert_eq!(version, 20),
578                other => panic!("Expected Node action, got {:?}", other),
579            }
580        }
581
582        #[tokio::test]
583        async fn fetch_with_sub_path() {
584            let server = MockServer::start().await;
585
586            Mock::given(method("GET"))
587                .and(path("/owner/repo/v1/my/action/action.yml"))
588                .respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
589                .mount(&server)
590                .await;
591
592            let result = fetch_and_parse(
593                &server.uri(),
594                "owner/repo",
595                "v1",
596                Some("my/action"),
597                "action.yml",
598                None,
599            )
600            .await;
601
602            let resolved = result.unwrap();
603            assert!(matches!(
604                resolved.action_type,
605                ActionType::Node { version: 20 }
606            ));
607        }
608
609        #[tokio::test]
610        async fn fetch_404_without_token_returns_error() {
611            let server = MockServer::start().await;
612
613            Mock::given(method("GET"))
614                .and(path("/owner/repo/v1/action.yml"))
615                .respond_with(ResponseTemplate::new(404))
616                .mount(&server)
617                .await;
618
619            let result =
620                fetch_and_parse(&server.uri(), "owner/repo", "v1", None, "action.yml", None).await;
621
622            assert!(result.is_err());
623            assert!(
624                result.as_ref().unwrap_err().contains("404"),
625                "Expected 404 in error, got: {}",
626                result.unwrap_err()
627            );
628        }
629
630        /// Verifies the security-critical property: when the auth request gets a
631        /// redirect response (e.g., to a CDN), the redirect is followed WITHOUT
632        /// the Authorization header, preventing the GITHUB_TOKEN from leaking
633        /// to a non-GitHub host.
634        #[tokio::test]
635        async fn auth_redirect_does_not_leak_token() {
636            let server = MockServer::start().await;
637
638            // 1. Unauthenticated request → 404 (triggers auth retry).
639            //    Mounted first so it has lowest priority in wiremock's LIFO matching.
640            Mock::given(method("GET"))
641                .and(path("/owner/repo/v1/action.yml"))
642                .respond_with(ResponseTemplate::new(404))
643                .up_to_n_times(1)
644                .mount(&server)
645                .await;
646
647            // 2. Authenticated retry → 302 redirect to a different path.
648            let redirect_url = format!("{}/cdn/redirected/action.yml", server.uri());
649            Mock::given(method("GET"))
650                .and(path("/owner/repo/v1/action.yml"))
651                .and(header_exists("Authorization"))
652                .respond_with(
653                    ResponseTemplate::new(302).insert_header("Location", redirect_url.as_str()),
654                )
655                .mount(&server)
656                .await;
657
658            // 3. Redirect target → 200 with action.yml body.
659            Mock::given(method("GET"))
660                .and(path("/cdn/redirected/action.yml"))
661                .respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
662                .mount(&server)
663                .await;
664
665            let result = fetch_and_parse(
666                &server.uri(),
667                "owner/repo",
668                "v1",
669                None,
670                "action.yml",
671                Some("ghp_test_token_for_redirect_test"),
672            )
673            .await;
674
675            // The resolution should succeed via the redirect path
676            let resolved = result.unwrap();
677            assert!(matches!(
678                resolved.action_type,
679                ActionType::Node { version: 20 }
680            ));
681
682            // Verify the redirect request did NOT include the Authorization header.
683            // This is the core security invariant: tokens must not leak to redirect targets.
684            let requests = server.received_requests().await.unwrap();
685            let redirect_req = requests
686                .iter()
687                .find(|r| r.url.path() == "/cdn/redirected/action.yml")
688                .expect("Expected a request to the redirect target");
689            let has_auth = redirect_req
690                .headers
691                .iter()
692                .any(|(name, _)| name.as_str() == "authorization");
693            assert!(
694                !has_auth,
695                "GITHUB_TOKEN leaked to redirect target! Authorization header found on redirect request."
696            );
697        }
698
699        #[tokio::test]
700        async fn auth_retry_on_404_with_token_succeeds() {
701            let server = MockServer::start().await;
702
703            // 1. Unauthenticated → 404
704            Mock::given(method("GET"))
705                .and(path("/owner/repo/v1/action.yml"))
706                .respond_with(ResponseTemplate::new(404))
707                .up_to_n_times(1)
708                .mount(&server)
709                .await;
710
711            // 2. Authenticated → 200 (private repo, no redirect)
712            Mock::given(method("GET"))
713                .and(path("/owner/repo/v1/action.yml"))
714                .and(header_exists("Authorization"))
715                .respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
716                .mount(&server)
717                .await;
718
719            let result = fetch_and_parse(
720                &server.uri(),
721                "owner/repo",
722                "v1",
723                None,
724                "action.yml",
725                Some("ghp_test_token_for_auth_test"),
726            )
727            .await;
728
729            let resolved = result.unwrap();
730            assert!(matches!(
731                resolved.action_type,
732                ActionType::Node { version: 20 }
733            ));
734        }
735    }
736}