1use std::fmt::{self, Display};
4
5use indexmap::IndexMap;
6use self_cell::self_cell;
7use serde::{Deserialize, Deserializer, Serialize, de};
8
9pub mod expr;
10
11#[derive(Deserialize, Debug, PartialEq)]
13#[serde(rename_all = "kebab-case", untagged)]
14pub enum Permissions {
15 Base(BasePermission),
17 Explicit(IndexMap<String, Permission>),
22}
23
24impl Default for Permissions {
25 fn default() -> Self {
26 Self::Base(BasePermission::Default)
27 }
28}
29
30#[derive(Deserialize, Debug, Default, PartialEq)]
33#[serde(rename_all = "kebab-case")]
34pub enum BasePermission {
35 #[default]
37 Default,
38 ReadAll,
40 WriteAll,
42}
43
44#[derive(Deserialize, Debug, Default, PartialEq)]
46#[serde(rename_all = "kebab-case")]
47pub enum Permission {
48 Read,
50
51 Write,
53
54 #[default]
56 None,
57}
58
59pub type Env = IndexMap<String, EnvValue>;
61
62#[derive(Deserialize, Serialize, Debug, PartialEq)]
70#[serde(untagged)]
71pub enum EnvValue {
72 #[serde(deserialize_with = "null_to_default")]
74 String(String),
75 Number(f64),
76 Boolean(bool),
77}
78
79impl Display for EnvValue {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Self::String(s) => write!(f, "{s}"),
83 Self::Number(n) => write!(f, "{n}"),
84 Self::Boolean(b) => write!(f, "{b}"),
85 }
86 }
87}
88
89impl EnvValue {
90 pub fn csharp_trueish(&self) -> bool {
97 match self {
98 EnvValue::Boolean(true) => true,
99 EnvValue::String(maybe) => maybe.trim().eq_ignore_ascii_case("true"),
100 _ => false,
101 }
102 }
103}
104
105#[derive(Deserialize, Debug, PartialEq)]
110#[serde(untagged)]
111enum SoV<T> {
112 One(T),
113 Many(Vec<T>),
114}
115
116impl<T> From<SoV<T>> for Vec<T> {
117 fn from(val: SoV<T>) -> Vec<T> {
118 match val {
119 SoV::One(v) => vec![v],
120 SoV::Many(vs) => vs,
121 }
122 }
123}
124
125pub(crate) fn scalar_or_vector<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
126where
127 D: Deserializer<'de>,
128 T: Deserialize<'de>,
129{
130 SoV::deserialize(de).map(Into::into)
131}
132
133#[derive(Deserialize, Debug, PartialEq)]
137#[serde(untagged)]
138enum BoS {
139 Bool(bool),
140 String(String),
141}
142
143impl From<BoS> for String {
144 fn from(value: BoS) -> Self {
145 match value {
146 BoS::Bool(b) => b.to_string(),
147 BoS::String(s) => s,
148 }
149 }
150}
151
152#[derive(Deserialize, Serialize, Debug, PartialEq)]
156#[serde(untagged)]
157pub enum If {
158 Bool(bool),
159 Expr(String),
162}
163
164pub(crate) fn bool_is_string<'de, D>(de: D) -> Result<String, D::Error>
165where
166 D: Deserializer<'de>,
167{
168 BoS::deserialize(de).map(Into::into)
169}
170
171fn null_to_default<'de, D, T>(de: D) -> Result<T, D::Error>
172where
173 D: Deserializer<'de>,
174 T: Default + Deserialize<'de>,
175{
176 let key = Option::<T>::deserialize(de)?;
177 Ok(key.unwrap_or_default())
178}
179
180#[derive(Debug, PartialEq)]
182pub struct UsesError(String);
183
184impl fmt::Display for UsesError {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 write!(f, "malformed `uses` ref: {}", self.0)
187 }
188}
189
190#[derive(Debug, PartialEq)]
191pub enum Uses {
192 Local(LocalUses),
194
195 Repository(RepositoryUses),
197
198 Docker(DockerUses),
200}
201
202impl Uses {
203 pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
205 let uses = uses.into();
206
207 if uses.starts_with("./") {
208 Ok(Self::Local(LocalUses::new(uses)))
209 } else if let Some(image) = uses.strip_prefix("docker://") {
210 DockerUses::parse(image).map(Self::Docker)
211 } else {
212 RepositoryUses::parse(uses).map(Self::Repository)
213 }
214 }
215}
216
217#[derive(Debug, PartialEq)]
219#[non_exhaustive]
220pub struct LocalUses {
221 pub path: String,
222}
223
224impl LocalUses {
225 fn new(path: String) -> Self {
226 LocalUses { path }
227 }
228}
229
230#[derive(Debug, PartialEq)]
231struct RepositoryUsesInner<'a> {
232 owner: &'a str,
234 repo: &'a str,
236 slug: &'a str,
238 subpath: Option<&'a str>,
240 git_ref: &'a str,
242}
243
244impl<'a> RepositoryUsesInner<'a> {
245 fn from_str(uses: &'a str) -> Result<Self, UsesError> {
246 let (path, git_ref) = match uses.rsplit_once('@') {
249 Some((path, git_ref)) => (path, git_ref),
250 None => return Err(UsesError(format!("missing `@<ref>` in {uses}"))),
251 };
252
253 let mut components = path.splitn(3, '/');
254
255 if let Some(owner) = components.next()
256 && let Some(repo) = components.next()
257 {
258 let subpath = components.next();
259
260 let slug = if subpath.is_none() {
261 path
262 } else {
263 &path[..owner.len() + 1 + repo.len()]
264 };
265
266 Ok(RepositoryUsesInner {
267 owner,
268 repo,
269 slug,
270 subpath,
271 git_ref,
272 })
273 } else {
274 Err(UsesError(format!("owner/repo slug is too short: {uses}")))
275 }
276 }
277}
278
279self_cell!(
280 pub struct RepositoryUses {
282 owner: String,
283
284 #[covariant]
285 dependent: RepositoryUsesInner,
286 }
287
288 impl {Debug, PartialEq}
289);
290
291impl RepositoryUses {
292 pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
294 RepositoryUses::try_new(uses.into(), |s| {
295 let inner = RepositoryUsesInner::from_str(s)?;
296 Ok(inner)
297 })
298 }
299
300 pub fn raw(&self) -> &str {
302 self.borrow_owner()
303 }
304
305 pub fn owner(&self) -> &str {
307 self.borrow_dependent().owner
308 }
309
310 pub fn repo(&self) -> &str {
312 self.borrow_dependent().repo
313 }
314
315 pub fn slug(&self) -> &str {
317 self.borrow_dependent().slug
318 }
319
320 pub fn subpath(&self) -> Option<&str> {
322 self.borrow_dependent().subpath
323 }
324
325 pub fn git_ref(&self) -> &str {
327 self.borrow_dependent().git_ref
328 }
329}
330
331#[derive(Debug, PartialEq)]
332#[non_exhaustive]
333pub struct DockerUsesInner<'a> {
334 registry: Option<&'a str>,
336 image: &'a str,
338 tag: Option<&'a str>,
340 hash: Option<&'a str>,
342}
343
344impl<'a> DockerUsesInner<'a> {
345 fn is_registry(registry: &str) -> bool {
346 registry == "localhost" || registry.contains('.') || registry.contains(':')
348 }
349
350 fn from_str(uses: &'a str) -> Result<Self, UsesError> {
351 let (registry, image) = match uses.split_once('/') {
352 Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
353 _ => (None, uses),
354 };
355
356 if let Some(at_pos) = image.find('@') {
360 let (image, hash) = image.split_at(at_pos);
361
362 let hash = if hash.is_empty() {
363 None
364 } else {
365 Some(&hash[1..])
366 };
367
368 Ok(DockerUsesInner {
369 registry,
370 image,
371 tag: None,
372 hash,
373 })
374 } else {
375 let (image, tag) = match image.split_once(':') {
376 Some((image, "")) => (image, None),
377 Some((image, tag)) => (image, Some(tag)),
378 _ => (image, None),
379 };
380
381 Ok(DockerUsesInner {
382 registry,
383 image,
384 tag,
385 hash: None,
386 })
387 }
388 }
389}
390
391self_cell!(
392 pub struct DockerUses {
394 owner: String,
395
396 #[covariant]
397 dependent: DockerUsesInner,
398 }
399
400 impl {Debug, PartialEq}
401);
402
403impl DockerUses {
404 pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
406 DockerUses::try_new(uses.into(), |s| {
407 let inner = DockerUsesInner::from_str(s)?;
408 Ok(inner)
409 })
410 }
411
412 pub fn raw(&self) -> &str {
414 self.borrow_owner()
415 }
416
417 pub fn registry(&self) -> Option<&str> {
419 self.borrow_dependent().registry
420 }
421
422 pub fn image(&self) -> &str {
424 self.borrow_dependent().image
425 }
426
427 pub fn tag(&self) -> Option<&str> {
429 self.borrow_dependent().tag
430 }
431
432 pub fn hash(&self) -> Option<&str> {
434 self.borrow_dependent().hash
435 }
436}
437
438pub(crate) fn custom_error<'de, D>(msg: impl Display) -> D::Error
444where
445 D: Deserializer<'de>,
446{
447 let msg = msg.to_string();
448 tracing::error!(msg);
449 de::Error::custom(msg)
450}
451
452pub(crate) fn docker_uses<'de, D>(de: D) -> Result<DockerUses, D::Error>
454where
455 D: Deserializer<'de>,
456{
457 let uses = <String>::deserialize(de)?;
458 DockerUses::parse(uses).map_err(custom_error::<D>)
459}
460
461pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
463where
464 D: Deserializer<'de>,
465{
466 let uses = <String>::deserialize(de)?;
467 Uses::parse(uses).map_err(custom_error::<D>)
468}
469
470pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
472where
473 D: Deserializer<'de>,
474{
475 let uses = step_uses(de)?;
476
477 match uses {
478 Uses::Repository(_) => Ok(uses),
479 Uses::Local(ref local) => {
480 if local.path.contains('@') {
485 Err(custom_error::<D>(
486 "local reusable workflow reference can't specify `@<ref>`",
487 ))
488 } else {
489 Ok(uses)
490 }
491 }
492 Uses::Docker(_) => Err(custom_error::<D>(
494 "docker action invalid in reusable workflow `uses`",
495 )),
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use indexmap::IndexMap;
502 use serde::Deserialize;
503
504 use crate::common::{BasePermission, Env, EnvValue, Permission};
505
506 use super::{Permissions, Uses, reusable_step_uses};
507
508 #[test]
509 fn test_permissions() {
510 assert_eq!(
511 serde_yaml::from_str::<Permissions>("read-all").unwrap(),
512 Permissions::Base(BasePermission::ReadAll)
513 );
514
515 let perm = "security-events: write";
516 assert_eq!(
517 serde_yaml::from_str::<Permissions>(perm).unwrap(),
518 Permissions::Explicit(IndexMap::from([(
519 "security-events".into(),
520 Permission::Write
521 )]))
522 );
523 }
524
525 #[test]
526 fn test_env_empty_value() {
527 let env = "foo:";
528 assert_eq!(
529 serde_yaml::from_str::<Env>(env).unwrap()["foo"],
530 EnvValue::String("".into())
531 );
532 }
533
534 #[test]
535 fn test_env_value_csharp_trueish() {
536 let vectors = [
537 (EnvValue::Boolean(true), true),
538 (EnvValue::Boolean(false), false),
539 (EnvValue::String("true".to_string()), true),
540 (EnvValue::String("TRUE".to_string()), true),
541 (EnvValue::String("TrUe".to_string()), true),
542 (EnvValue::String(" true ".to_string()), true),
543 (EnvValue::String(" \n\r\t True\n\n".to_string()), true),
544 (EnvValue::String("false".to_string()), false),
545 (EnvValue::String("1".to_string()), false),
546 (EnvValue::String("yes".to_string()), false),
547 (EnvValue::String("on".to_string()), false),
548 (EnvValue::String("random".to_string()), false),
549 (EnvValue::Number(1.0), false),
550 (EnvValue::Number(0.0), false),
551 (EnvValue::Number(666.0), false),
552 ];
553
554 for (val, expected) in vectors {
555 assert_eq!(val.csharp_trueish(), expected, "failed for {val:?}");
556 }
557 }
558
559 #[test]
560 fn test_uses_parses() {
561 insta::assert_debug_snapshot!(
563 Uses::parse("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
564 @r#"
565 Repository(
566 RepositoryUses {
567 owner: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
568 dependent: RepositoryUsesInner {
569 owner: "actions",
570 repo: "checkout",
571 slug: "actions/checkout",
572 subpath: None,
573 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
574 },
575 },
576 )
577 "#,
578 );
579
580 insta::assert_debug_snapshot!(
582 Uses::parse("actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
583 @r#"
584 Repository(
585 RepositoryUses {
586 owner: "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
587 dependent: RepositoryUsesInner {
588 owner: "actions",
589 repo: "aws",
590 slug: "actions/aws",
591 subpath: Some(
592 "ec2",
593 ),
594 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
595 },
596 },
597 )
598 "#
599 );
600
601 insta::assert_debug_snapshot!(
603 Uses::parse("example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
604 @r#"
605 Repository(
606 RepositoryUses {
607 owner: "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
608 dependent: RepositoryUsesInner {
609 owner: "example",
610 repo: "foo",
611 slug: "example/foo",
612 subpath: Some(
613 "bar/baz/quux",
614 ),
615 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
616 },
617 },
618 )
619 "#
620 );
621
622 insta::assert_debug_snapshot!(
624 Uses::parse("actions/checkout@v4").unwrap(),
625 @r#"
626 Repository(
627 RepositoryUses {
628 owner: "actions/checkout@v4",
629 dependent: RepositoryUsesInner {
630 owner: "actions",
631 repo: "checkout",
632 slug: "actions/checkout",
633 subpath: None,
634 git_ref: "v4",
635 },
636 },
637 )
638 "#
639 );
640
641 insta::assert_debug_snapshot!(
642 Uses::parse("actions/checkout@abcd").unwrap(),
643 @r#"
644 Repository(
645 RepositoryUses {
646 owner: "actions/checkout@abcd",
647 dependent: RepositoryUsesInner {
648 owner: "actions",
649 repo: "checkout",
650 slug: "actions/checkout",
651 subpath: None,
652 git_ref: "abcd",
653 },
654 },
655 )
656 "#
657 );
658
659 insta::assert_debug_snapshot!(
661 Uses::parse("actions/checkout").unwrap_err(),
662 @r#"
663 UsesError(
664 "missing `@<ref>` in actions/checkout",
665 )
666 "#
667 );
668
669 insta::assert_debug_snapshot!(
671 Uses::parse("docker://alpine:3.8").unwrap(),
672 @r#"
673 Docker(
674 DockerUses {
675 owner: "alpine:3.8",
676 dependent: DockerUsesInner {
677 registry: None,
678 image: "alpine",
679 tag: Some(
680 "3.8",
681 ),
682 hash: None,
683 },
684 },
685 )
686 "#
687 );
688
689 insta::assert_debug_snapshot!(
691 Uses::parse("docker://localhost/alpine:3.8").unwrap(),
692 @r#"
693 Docker(
694 DockerUses {
695 owner: "localhost/alpine:3.8",
696 dependent: DockerUsesInner {
697 registry: Some(
698 "localhost",
699 ),
700 image: "alpine",
701 tag: Some(
702 "3.8",
703 ),
704 hash: None,
705 },
706 },
707 )
708 "#
709 );
710
711 insta::assert_debug_snapshot!(
713 Uses::parse("docker://localhost:1337/alpine:3.8").unwrap(),
714 @r#"
715 Docker(
716 DockerUses {
717 owner: "localhost:1337/alpine:3.8",
718 dependent: DockerUsesInner {
719 registry: Some(
720 "localhost:1337",
721 ),
722 image: "alpine",
723 tag: Some(
724 "3.8",
725 ),
726 hash: None,
727 },
728 },
729 )
730 "#
731 );
732
733 insta::assert_debug_snapshot!(
735 Uses::parse("docker://ghcr.io/foo/alpine:3.8").unwrap(),
736 @r#"
737 Docker(
738 DockerUses {
739 owner: "ghcr.io/foo/alpine:3.8",
740 dependent: DockerUsesInner {
741 registry: Some(
742 "ghcr.io",
743 ),
744 image: "foo/alpine",
745 tag: Some(
746 "3.8",
747 ),
748 hash: None,
749 },
750 },
751 )
752 "#
753 );
754
755 insta::assert_debug_snapshot!(
757 Uses::parse("docker://ghcr.io/foo/alpine").unwrap(),
758 @r#"
759 Docker(
760 DockerUses {
761 owner: "ghcr.io/foo/alpine",
762 dependent: DockerUsesInner {
763 registry: Some(
764 "ghcr.io",
765 ),
766 image: "foo/alpine",
767 tag: None,
768 hash: None,
769 },
770 },
771 )
772 "#
773 );
774
775 insta::assert_debug_snapshot!(
777 Uses::parse("docker://ghcr.io/foo/alpine:").unwrap(),
778 @r#"
779 Docker(
780 DockerUses {
781 owner: "ghcr.io/foo/alpine:",
782 dependent: DockerUsesInner {
783 registry: Some(
784 "ghcr.io",
785 ),
786 image: "foo/alpine",
787 tag: None,
788 hash: None,
789 },
790 },
791 )
792 "#
793 );
794
795 insta::assert_debug_snapshot!(
797 Uses::parse("docker://alpine").unwrap(),
798 @r#"
799 Docker(
800 DockerUses {
801 owner: "alpine",
802 dependent: DockerUsesInner {
803 registry: None,
804 image: "alpine",
805 tag: None,
806 hash: None,
807 },
808 },
809 )
810 "#
811 );
812
813 insta::assert_debug_snapshot!(
815 Uses::parse("docker://alpine@hash").unwrap(),
816 @r#"
817 Docker(
818 DockerUses {
819 owner: "alpine@hash",
820 dependent: DockerUsesInner {
821 registry: None,
822 image: "alpine",
823 tag: None,
824 hash: Some(
825 "hash",
826 ),
827 },
828 },
829 )
830 "#
831 );
832
833 insta::assert_debug_snapshot!(
835 Uses::parse("./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89").unwrap(),
836 @r#"
837 Local(
838 LocalUses {
839 path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
840 },
841 )
842 "#
843 );
844
845 insta::assert_debug_snapshot!(
847 Uses::parse("./.github/actions/hello-world-action").unwrap(),
848 @r#"
849 Local(
850 LocalUses {
851 path: "./.github/actions/hello-world-action",
852 },
853 )
854 "#
855 );
856
857 insta::assert_debug_snapshot!(
859 Uses::parse("checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap_err(),
860 @r#"
861 UsesError(
862 "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
863 )
864 "#
865 );
866 }
867
868 #[test]
869 fn test_uses_deser_reusable() {
870 #[derive(Deserialize)]
872 #[serde(transparent)]
873 struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
874
875 insta::assert_debug_snapshot!(
876 serde_yaml::from_str::<Dummy>(
877 "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
878 )
879 .map(|d| d.0)
880 .unwrap(),
881 @r#"
882 Repository(
883 RepositoryUses {
884 owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
885 dependent: RepositoryUsesInner {
886 owner: "octo-org",
887 repo: "this-repo",
888 slug: "octo-org/this-repo",
889 subpath: Some(
890 ".github/workflows/workflow-1.yml",
891 ),
892 git_ref: "172239021f7ba04fe7327647b213799853a9eb89",
893 },
894 },
895 )
896 "#
897 );
898
899 insta::assert_debug_snapshot!(
900 serde_yaml::from_str::<Dummy>(
901 "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash"
902 ).map(|d| d.0).unwrap(),
903 @r#"
904 Repository(
905 RepositoryUses {
906 owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
907 dependent: RepositoryUsesInner {
908 owner: "octo-org",
909 repo: "this-repo",
910 slug: "octo-org/this-repo",
911 subpath: Some(
912 ".github/workflows/workflow-1.yml",
913 ),
914 git_ref: "notahash",
915 },
916 },
917 )
918 "#
919 );
920
921 insta::assert_debug_snapshot!(
922 serde_yaml::from_str::<Dummy>(
923 "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd"
924 ).map(|d| d.0).unwrap(),
925 @r#"
926 Repository(
927 RepositoryUses {
928 owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
929 dependent: RepositoryUsesInner {
930 owner: "octo-org",
931 repo: "this-repo",
932 slug: "octo-org/this-repo",
933 subpath: Some(
934 ".github/workflows/workflow-1.yml",
935 ),
936 git_ref: "abcd",
937 },
938 },
939 )
940 "#
941 );
942
943 insta::assert_debug_snapshot!(
945 serde_yaml::from_str::<Dummy>(
946 "octo-org/this-repo/.github/workflows/workflow-1.yml"
947 ).map(|d| d.0).unwrap_err(),
948 @r#"Error("malformed `uses` ref: missing `@<ref>` in octo-org/this-repo/.github/workflows/workflow-1.yml")"#
949 );
950
951 insta::assert_debug_snapshot!(
953 serde_yaml::from_str::<Dummy>(
954 "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
955 ).map(|d| d.0).unwrap_err(),
956 @r#"Error("local reusable workflow reference can't specify `@<ref>`")"#
957 );
958
959 insta::assert_debug_snapshot!(
961 serde_yaml::from_str::<Dummy>(
962 ".github/workflows/workflow-1.yml"
963 ).map(|d| d.0).unwrap_err(),
964 @r#"Error("malformed `uses` ref: missing `@<ref>` in .github/workflows/workflow-1.yml")"#
965 );
966
967 insta::assert_debug_snapshot!(
969 serde_yaml::from_str::<Dummy>(
970 "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
971 ).map(|d| d.0).unwrap_err(),
972 @r#"Error("malformed `uses` ref: owner/repo slug is too short: workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89")"#
973 );
974 }
975}