1use once_cell::sync::Lazy;
2use std::collections::{HashMap, VecDeque};
3use tokio::sync::RwLock;
4
5const MAX_CACHE_ENTRIES: usize = 256;
7
8#[derive(Debug, Clone)]
10pub enum ActionType {
11 Node {
12 version: u32,
13 },
14 Docker {
16 image: String,
17 },
18 DockerBuild,
20 Composite,
21}
22
23#[derive(Debug, Clone)]
25pub struct ResolvedAction {
26 pub action_type: ActionType,
27 pub definition: Option<serde_yaml::Value>,
29}
30
31struct BoundedCache {
37 map: HashMap<String, ResolvedAction>,
38 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 self.map.insert(key, value);
59 return;
60 }
61 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
74static 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
84static 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
98pub 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 {
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 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 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 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 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 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
236fn 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
258fn 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 let image = image.trim_start_matches("docker://");
271
272 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 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 assert!(cache.get("owner/repo@v0").is_none());
520 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 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 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 #[tokio::test]
635 async fn auth_redirect_does_not_leak_token() {
636 let server = MockServer::start().await;
637
638 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 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 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 let resolved = result.unwrap();
677 assert!(matches!(
678 resolved.action_type,
679 ActionType::Node { version: 20 }
680 ));
681
682 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 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 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}