Skip to main content

github_actions_models/
common.rs

1//! Shared models and utilities.
2
3use std::{
4    borrow::Cow,
5    fmt::{self, Display},
6};
7
8use indexmap::IndexMap;
9use self_cell::self_cell;
10use serde::{Deserialize, Deserializer, Serialize, de};
11
12pub mod expr;
13
14/// `permissions` for a workflow, job, or step.
15#[derive(Deserialize, Debug, PartialEq)]
16#[serde(rename_all = "kebab-case", untagged)]
17pub enum Permissions {
18    /// Base, i.e. blanket permissions.
19    Base(BasePermission),
20    /// Fine-grained permissions.
21    ///
22    /// These are modeled with an open-ended mapping rather than a structure
23    /// to make iteration over all defined permissions easier.
24    Explicit(IndexMap<String, Permission>),
25}
26
27impl Default for Permissions {
28    fn default() -> Self {
29        Self::Base(BasePermission::Default)
30    }
31}
32
33/// "Base" permissions, where all individual permissions are configured
34/// with a blanket setting.
35#[derive(Deserialize, Debug, Default, PartialEq)]
36#[serde(rename_all = "kebab-case")]
37pub enum BasePermission {
38    /// Whatever default permissions come from the workflow's `GITHUB_TOKEN`.
39    #[default]
40    Default,
41    /// "Read" access to all resources.
42    ReadAll,
43    /// "Write" access to all resources (implies read).
44    WriteAll,
45}
46
47/// A singular permission setting.
48#[derive(Deserialize, Debug, Default, PartialEq)]
49#[serde(rename_all = "kebab-case")]
50pub enum Permission {
51    /// Read access.
52    Read,
53
54    /// Write access.
55    Write,
56
57    /// No access.
58    #[default]
59    None,
60}
61
62/// An environment mapping.
63pub type Env = IndexMap<String, EnvValue>;
64
65/// Environment variable values are always strings, but GitHub Actions
66/// allows users to configure them as various native YAML types before
67/// internal stringification.
68///
69/// This type also gets used for other places where GitHub Actions
70/// contextually reinterprets a YAML value as a string, e.g. trigger
71/// input values.
72#[derive(Deserialize, Serialize, Debug, PartialEq)]
73#[serde(untagged)]
74pub enum EnvValue {
75    // Missing values are empty strings.
76    #[serde(deserialize_with = "null_to_default")]
77    String(String),
78    Number(f64),
79    Boolean(bool),
80}
81
82impl Display for EnvValue {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::String(s) => write!(f, "{s}"),
86            Self::Number(n) => write!(f, "{n}"),
87            Self::Boolean(b) => write!(f, "{b}"),
88        }
89    }
90}
91
92impl EnvValue {
93    /// Returns whether the original value was empty.
94    ///
95    /// For example, `foo:` and `foo: ''` would both return true.
96    pub fn is_empty(&self) -> bool {
97        match self {
98            EnvValue::String(s) => s.is_empty(),
99            _ => false,
100        }
101    }
102
103    /// Returns whether this [`EnvValue`] is a "trueish" value
104    /// per C#'s `Boolean.TryParse`.
105    ///
106    /// This follows the semantics of C#'s `Boolean.TryParse`, where
107    /// the case-insensitive string "true" is considered true, but
108    /// "1", "yes", etc. are not.
109    pub fn csharp_trueish(&self) -> bool {
110        match self {
111            EnvValue::Boolean(true) => true,
112            EnvValue::String(maybe) => maybe.trim().eq_ignore_ascii_case("true"),
113            _ => false,
114        }
115    }
116}
117
118/// A "scalar or vector" type, for places in GitHub Actions where a
119/// key can have either a scalar value or an array of values.
120///
121/// This only appears internally, as an intermediate type for `scalar_or_vector`.
122#[derive(Deserialize, Debug, PartialEq)]
123#[serde(untagged)]
124enum SoV<T> {
125    One(T),
126    Many(Vec<T>),
127}
128
129impl<T> From<SoV<T>> for Vec<T> {
130    fn from(val: SoV<T>) -> Vec<T> {
131        match val {
132            SoV::One(v) => vec![v],
133            SoV::Many(vs) => vs,
134        }
135    }
136}
137
138pub(crate) fn scalar_or_vector<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
139where
140    D: Deserializer<'de>,
141    T: Deserialize<'de>,
142{
143    SoV::deserialize(de).map(Into::into)
144}
145
146/// A bool or string. This is useful for cases where GitHub Actions contextually
147/// reinterprets a YAML boolean as a string, e.g. `run: true` really means
148/// `run: 'true'`.
149#[derive(Deserialize, Debug, PartialEq)]
150#[serde(untagged)]
151enum BoS {
152    Bool(bool),
153    String(String),
154}
155
156impl From<BoS> for String {
157    fn from(value: BoS) -> Self {
158        match value {
159            BoS::Bool(b) => b.to_string(),
160            BoS::String(s) => s,
161        }
162    }
163}
164
165/// An `if:` condition in a job or action definition.
166///
167/// These are either booleans or bare (i.e. non-curly) expressions.
168#[derive(Deserialize, Serialize, Debug, PartialEq)]
169#[serde(untagged)]
170pub enum If {
171    Bool(bool),
172    // NOTE: condition expressions can be either "bare" or "curly", so we can't
173    // use `BoE` or anything else that assumes curly-only here.
174    Expr(String),
175}
176
177pub(crate) fn bool_is_string<'de, D>(de: D) -> Result<String, D::Error>
178where
179    D: Deserializer<'de>,
180{
181    BoS::deserialize(de).map(Into::into)
182}
183
184fn null_to_default<'de, D, T>(de: D) -> Result<T, D::Error>
185where
186    D: Deserializer<'de>,
187    T: Default + Deserialize<'de>,
188{
189    let key = Option::<T>::deserialize(de)?;
190    Ok(key.unwrap_or_default())
191}
192
193// TODO: Bother with enum variants here?
194#[derive(Debug, PartialEq)]
195pub struct UsesError(String);
196
197impl fmt::Display for UsesError {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        write!(f, "malformed `uses` ref: {}", self.0)
200    }
201}
202
203#[derive(Debug, PartialEq)]
204pub enum Uses {
205    /// A local `uses:` clause, e.g. `uses: ./foo/bar`.
206    Local(LocalUses),
207
208    /// A repository `uses:` clause, e.g. `uses: foo/bar`.
209    Repository(RepositoryUses),
210
211    /// A Docker image `uses: clause`, e.g. `uses: docker://ubuntu`.
212    Docker(DockerUses),
213}
214
215impl Uses {
216    /// Parse a `uses:` clause into its appropriate variant.
217    pub fn parse<'a>(uses: impl Into<Cow<'a, str>>) -> Result<Self, UsesError> {
218        let uses = uses.into();
219        let uses = uses.trim();
220
221        if uses.starts_with("./") {
222            Ok(Self::Local(LocalUses::new(uses)))
223        } else if let Some(image) = uses.strip_prefix("docker://") {
224            Ok(Self::Docker(DockerUses::parse(image)))
225        } else {
226            RepositoryUses::parse(uses).map(Self::Repository)
227        }
228    }
229
230    /// Returns the original raw `uses:` clause.
231    pub fn raw(&self) -> &str {
232        match self {
233            Uses::Local(local) => &local.path,
234            Uses::Repository(repo) => repo.raw(),
235            Uses::Docker(docker) => docker.raw(),
236        }
237    }
238}
239
240/// A `uses: ./some/path` clause.
241#[derive(Debug, PartialEq)]
242#[non_exhaustive]
243pub struct LocalUses {
244    pub path: String,
245}
246
247impl LocalUses {
248    fn new(path: impl Into<String>) -> Self {
249        LocalUses { path: path.into() }
250    }
251}
252
253#[derive(Debug, PartialEq)]
254struct RepositoryUsesInner<'a> {
255    /// The repo user or org.
256    owner: &'a str,
257    /// The repo name.
258    repo: &'a str,
259    /// The owner/repo slug.
260    slug: &'a str,
261    /// The subpath to the action or reusable workflow, if present.
262    subpath: Option<&'a str>,
263    /// The `@<ref>` that the `uses:` is pinned to.
264    git_ref: &'a str,
265}
266
267impl<'a> RepositoryUsesInner<'a> {
268    fn from_str(uses: &'a str) -> Result<Self, UsesError> {
269        // NOTE: Empirically, GitHub Actions strips whitespace from the start and end of `uses:` clauses.
270        let uses = uses.trim();
271
272        // NOTE: Both git refs and paths can contain `@`, but in practice
273        // GHA refuses to run a `uses:` clause with more than one `@` in it.
274        let (path, git_ref) = match uses.rsplit_once('@') {
275            Some((path, git_ref)) => (path, git_ref),
276            None => return Err(UsesError(format!("missing `@<ref>` in {uses}"))),
277        };
278
279        let mut components = path.splitn(3, '/');
280
281        if let Some(owner) = components.next()
282            && let Some(repo) = components.next()
283        {
284            let subpath = components.next();
285
286            let slug = if subpath.is_none() {
287                path
288            } else {
289                &path[..owner.len() + 1 + repo.len()]
290            };
291
292            Ok(RepositoryUsesInner {
293                owner,
294                repo,
295                slug,
296                subpath,
297                git_ref,
298            })
299        } else {
300            Err(UsesError(format!("owner/repo slug is too short: {uses}")))
301        }
302    }
303}
304
305self_cell!(
306    /// A `uses: some/repo` clause.
307    pub struct RepositoryUses {
308        owner: String,
309
310        #[covariant]
311        dependent: RepositoryUsesInner,
312    }
313
314    impl {Debug, PartialEq}
315);
316
317impl Display for RepositoryUses {
318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        write!(f, "{}", self.raw())
320    }
321}
322
323impl RepositoryUses {
324    /// Parse a `uses: some/repo` clause.
325    pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
326        RepositoryUses::try_new(uses.into(), |s| {
327            let inner = RepositoryUsesInner::from_str(s)?;
328            Ok(inner)
329        })
330    }
331
332    /// Get the raw `uses:` string.
333    pub fn raw(&self) -> &str {
334        self.borrow_owner()
335    }
336
337    /// Get the owner (user or org) of this repository `uses:` clause.
338    pub fn owner(&self) -> &str {
339        self.borrow_dependent().owner
340    }
341
342    /// Get the repository name of this repository `uses:` clause.
343    pub fn repo(&self) -> &str {
344        self.borrow_dependent().repo
345    }
346
347    /// Get the owner/repo slug of this repository `uses:` clause.
348    pub fn slug(&self) -> &str {
349        self.borrow_dependent().slug
350    }
351
352    /// Get the optional subpath of this repository `uses:` clause.
353    pub fn subpath(&self) -> Option<&str> {
354        self.borrow_dependent().subpath
355    }
356
357    /// Get the git ref (branch, tag, or SHA) of this repository `uses:` clause.
358    pub fn git_ref(&self) -> &str {
359        self.borrow_dependent().git_ref
360    }
361}
362
363#[derive(Debug, PartialEq)]
364#[non_exhaustive]
365pub struct DockerUsesInner<'a> {
366    /// The registry this image is on, if present.
367    registry: Option<&'a str>,
368    /// The name of the Docker image.
369    image: &'a str,
370    /// An optional tag for the image.
371    tag: Option<&'a str>,
372    /// An optional integrity hash for the image.
373    hash: Option<&'a str>,
374}
375
376impl<'a> DockerUsesInner<'a> {
377    fn is_registry(registry: &str) -> bool {
378        // https://stackoverflow.com/a/42116190
379        registry == "localhost" || registry.contains('.') || registry.contains(':')
380    }
381
382    fn from_str(uses: &'a str) -> Self {
383        // NOTE: Empirically, GitHub Actions strips whitespace from the start and end of `uses:` clauses.
384        let uses = uses.trim();
385
386        let (registry, image) = match uses.split_once('/') {
387            Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
388            _ => (None, uses),
389        };
390
391        // NOTE(ww): hashes aren't mentioned anywhere in Docker's own docs,
392        // but appear to be an OCI thing. GitHub doesn't support them
393        // yet either, but we expect them to soon (with "immutable actions").
394        if let Some(at_pos) = image.find('@') {
395            let (image, hash) = image.split_at(at_pos);
396
397            let hash = if hash.is_empty() {
398                None
399            } else {
400                Some(&hash[1..])
401            };
402
403            DockerUsesInner {
404                registry,
405                image,
406                tag: None,
407                hash,
408            }
409        } else {
410            let (image, tag) = match image.split_once(':') {
411                Some((image, "")) => (image, None),
412                Some((image, tag)) => (image, Some(tag)),
413                _ => (image, None),
414            };
415
416            DockerUsesInner {
417                registry,
418                image,
419                tag,
420                hash: None,
421            }
422        }
423    }
424}
425
426self_cell!(
427    /// A `uses: docker://some-image` clause.
428    pub struct DockerUses {
429        owner: String,
430
431        #[covariant]
432        dependent: DockerUsesInner,
433    }
434
435    impl {Debug, PartialEq}
436);
437
438impl DockerUses {
439    /// Parse a `uses: docker://some-image` clause.
440    pub fn parse(uses: impl Into<String>) -> Self {
441        DockerUses::new(uses.into(), |s| DockerUsesInner::from_str(s))
442    }
443
444    /// Get the raw uses clause. This does not include the `docker://` prefix.
445    pub fn raw(&self) -> &str {
446        self.borrow_owner()
447    }
448
449    /// Get the optional registry of this Docker image.
450    pub fn registry(&self) -> Option<&str> {
451        self.borrow_dependent().registry
452    }
453
454    /// Get the image name of this Docker image.
455    pub fn image(&self) -> &str {
456        self.borrow_dependent().image
457    }
458
459    /// Get the optional tag of this Docker image.
460    pub fn tag(&self) -> Option<&str> {
461        self.borrow_dependent().tag
462    }
463
464    /// Get the optional hash of this Docker image.
465    pub fn hash(&self) -> Option<&str> {
466        self.borrow_dependent().hash
467    }
468}
469
470impl<'de> Deserialize<'de> for DockerUses {
471    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
472    where
473        D: Deserializer<'de>,
474    {
475        let uses = <Cow<'de, str>>::deserialize(deserializer)?;
476        Ok(DockerUses::parse(uses))
477    }
478}
479
480/// Wraps a `de::Error::custom` call to log the same error as
481/// a `tracing::error!` event.
482///
483/// This is useful when doing custom deserialization within untagged
484/// enum variants, since serde loses track of the original error.
485pub(crate) fn custom_error<'de, D>(msg: impl Display) -> D::Error
486where
487    D: Deserializer<'de>,
488{
489    let msg = msg.to_string();
490    tracing::error!(msg);
491    de::Error::custom(msg)
492}
493
494/// Deserialize an ordinary step `uses:`.
495pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
496where
497    D: Deserializer<'de>,
498{
499    let uses = <Cow<'de, str>>::deserialize(de)?;
500    Uses::parse(uses).map_err(custom_error::<D>)
501}
502
503/// Deserialize a reusable workflow step `uses:`
504pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
505where
506    D: Deserializer<'de>,
507{
508    let uses = step_uses(de)?;
509
510    match uses {
511        Uses::Repository(_) => Ok(uses),
512        Uses::Local(ref local) => {
513            // Local reusable workflows cannot be pinned.
514            // We do this with a string scan because `@` *can* occur as
515            // a path component in local actions uses, just not local reusable
516            // workflow uses.
517            if local.path.contains('@') {
518                Err(custom_error::<D>(
519                    "local reusable workflow reference can't specify `@<ref>`",
520                ))
521            } else {
522                Ok(uses)
523            }
524        }
525        // `docker://` is never valid in reusable workflow uses.
526        Uses::Docker(_) => Err(custom_error::<D>(
527            "docker action invalid in reusable workflow `uses`",
528        )),
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use indexmap::IndexMap;
535    use serde::Deserialize;
536
537    use crate::common::{BasePermission, Env, EnvValue, Permission};
538
539    use super::{Permissions, Uses, reusable_step_uses};
540
541    #[test]
542    fn test_permissions() {
543        assert_eq!(
544            serde_yaml::from_str::<Permissions>("read-all").unwrap(),
545            Permissions::Base(BasePermission::ReadAll)
546        );
547
548        let perm = "security-events: write";
549        assert_eq!(
550            serde_yaml::from_str::<Permissions>(perm).unwrap(),
551            Permissions::Explicit(IndexMap::from([(
552                "security-events".into(),
553                Permission::Write
554            )]))
555        );
556    }
557
558    #[test]
559    fn test_env_empty_value() {
560        let env = "foo:";
561        assert_eq!(
562            serde_yaml::from_str::<Env>(env).unwrap()["foo"],
563            EnvValue::String("".into())
564        );
565    }
566
567    #[test]
568    fn test_env_value_csharp_trueish() {
569        let vectors = [
570            (EnvValue::Boolean(true), true),
571            (EnvValue::Boolean(false), false),
572            (EnvValue::String("true".to_string()), true),
573            (EnvValue::String("TRUE".to_string()), true),
574            (EnvValue::String("TrUe".to_string()), true),
575            (EnvValue::String(" true ".to_string()), true),
576            (EnvValue::String("   \n\r\t True\n\n".to_string()), true),
577            (EnvValue::String("false".to_string()), false),
578            (EnvValue::String("1".to_string()), false),
579            (EnvValue::String("yes".to_string()), false),
580            (EnvValue::String("on".to_string()), false),
581            (EnvValue::String("random".to_string()), false),
582            (EnvValue::Number(1.0), false),
583            (EnvValue::Number(0.0), false),
584            (EnvValue::Number(666.0), false),
585        ];
586
587        for (val, expected) in vectors {
588            assert_eq!(val.csharp_trueish(), expected, "failed for {val:?}");
589        }
590    }
591
592    #[test]
593    fn test_uses_parses() {
594        // Fully pinned.
595        insta::assert_debug_snapshot!(
596            Uses::parse("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
597            @r#"
598        Repository(
599            RepositoryUses {
600                owner: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
601                dependent: RepositoryUsesInner {
602                    owner: "actions",
603                    repo: "checkout",
604                    slug: "actions/checkout",
605                    subpath: None,
606                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
607                },
608            },
609        )
610        "#,
611        );
612
613        // Fully pinned, subpath.
614        insta::assert_debug_snapshot!(
615            Uses::parse("actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
616            @r#"
617        Repository(
618            RepositoryUses {
619                owner: "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
620                dependent: RepositoryUsesInner {
621                    owner: "actions",
622                    repo: "aws",
623                    slug: "actions/aws",
624                    subpath: Some(
625                        "ec2",
626                    ),
627                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
628                },
629            },
630        )
631        "#
632        );
633
634        // Fully pinned, complex subpath.
635        insta::assert_debug_snapshot!(
636            Uses::parse("example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
637            @r#"
638        Repository(
639            RepositoryUses {
640                owner: "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
641                dependent: RepositoryUsesInner {
642                    owner: "example",
643                    repo: "foo",
644                    slug: "example/foo",
645                    subpath: Some(
646                        "bar/baz/quux",
647                    ),
648                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
649                },
650            },
651        )
652        "#
653        );
654
655        // Pinned with branch/tag.
656        insta::assert_debug_snapshot!(
657            Uses::parse("actions/checkout@v4").unwrap(),
658            @r#"
659        Repository(
660            RepositoryUses {
661                owner: "actions/checkout@v4",
662                dependent: RepositoryUsesInner {
663                    owner: "actions",
664                    repo: "checkout",
665                    slug: "actions/checkout",
666                    subpath: None,
667                    git_ref: "v4",
668                },
669            },
670        )
671        "#
672        );
673
674        insta::assert_debug_snapshot!(
675            Uses::parse("actions/checkout@abcd").unwrap(),
676            @r#"
677        Repository(
678            RepositoryUses {
679                owner: "actions/checkout@abcd",
680                dependent: RepositoryUsesInner {
681                    owner: "actions",
682                    repo: "checkout",
683                    slug: "actions/checkout",
684                    subpath: None,
685                    git_ref: "abcd",
686                },
687            },
688        )
689        "#
690        );
691
692        // Invalid: unpinned.
693        insta::assert_debug_snapshot!(
694            Uses::parse("actions/checkout").unwrap_err(),
695            @r#"
696        UsesError(
697            "missing `@<ref>` in actions/checkout",
698        )
699        "#
700        );
701
702        // Valid: Docker ref, implicit registry.
703        insta::assert_debug_snapshot!(
704            Uses::parse("docker://alpine:3.8").unwrap(),
705            @r#"
706        Docker(
707            DockerUses {
708                owner: "alpine:3.8",
709                dependent: DockerUsesInner {
710                    registry: None,
711                    image: "alpine",
712                    tag: Some(
713                        "3.8",
714                    ),
715                    hash: None,
716                },
717            },
718        )
719        "#
720        );
721
722        // Valid: Docker ref, localhost.
723        insta::assert_debug_snapshot!(
724            Uses::parse("docker://localhost/alpine:3.8").unwrap(),
725            @r#"
726        Docker(
727            DockerUses {
728                owner: "localhost/alpine:3.8",
729                dependent: DockerUsesInner {
730                    registry: Some(
731                        "localhost",
732                    ),
733                    image: "alpine",
734                    tag: Some(
735                        "3.8",
736                    ),
737                    hash: None,
738                },
739            },
740        )
741        "#
742        );
743
744        // Valid: Docker ref, localhost with port.
745        insta::assert_debug_snapshot!(
746            Uses::parse("docker://localhost:1337/alpine:3.8").unwrap(),
747            @r#"
748        Docker(
749            DockerUses {
750                owner: "localhost:1337/alpine:3.8",
751                dependent: DockerUsesInner {
752                    registry: Some(
753                        "localhost:1337",
754                    ),
755                    image: "alpine",
756                    tag: Some(
757                        "3.8",
758                    ),
759                    hash: None,
760                },
761            },
762        )
763        "#
764        );
765
766        // Valid: Docker ref, custom registry.
767        insta::assert_debug_snapshot!(
768            Uses::parse("docker://ghcr.io/foo/alpine:3.8").unwrap(),
769            @r#"
770        Docker(
771            DockerUses {
772                owner: "ghcr.io/foo/alpine:3.8",
773                dependent: DockerUsesInner {
774                    registry: Some(
775                        "ghcr.io",
776                    ),
777                    image: "foo/alpine",
778                    tag: Some(
779                        "3.8",
780                    ),
781                    hash: None,
782                },
783            },
784        )
785        "#
786        );
787
788        // Valid: Docker ref, missing tag.
789        insta::assert_debug_snapshot!(
790            Uses::parse("docker://ghcr.io/foo/alpine").unwrap(),
791            @r#"
792        Docker(
793            DockerUses {
794                owner: "ghcr.io/foo/alpine",
795                dependent: DockerUsesInner {
796                    registry: Some(
797                        "ghcr.io",
798                    ),
799                    image: "foo/alpine",
800                    tag: None,
801                    hash: None,
802                },
803            },
804        )
805        "#
806        );
807
808        // Invalid, but allowed: Docker ref, empty tag
809        insta::assert_debug_snapshot!(
810            Uses::parse("docker://ghcr.io/foo/alpine:").unwrap(),
811            @r#"
812        Docker(
813            DockerUses {
814                owner: "ghcr.io/foo/alpine:",
815                dependent: DockerUsesInner {
816                    registry: Some(
817                        "ghcr.io",
818                    ),
819                    image: "foo/alpine",
820                    tag: None,
821                    hash: None,
822                },
823            },
824        )
825        "#
826        );
827
828        // Valid: Docker ref, bare.
829        insta::assert_debug_snapshot!(
830            Uses::parse("docker://alpine").unwrap(),
831            @r#"
832        Docker(
833            DockerUses {
834                owner: "alpine",
835                dependent: DockerUsesInner {
836                    registry: None,
837                    image: "alpine",
838                    tag: None,
839                    hash: None,
840                },
841            },
842        )
843        "#
844        );
845
846        // Valid: Docker ref, with hash.
847        insta::assert_debug_snapshot!(
848            Uses::parse("docker://alpine@hash").unwrap(),
849            @r#"
850        Docker(
851            DockerUses {
852                owner: "alpine@hash",
853                dependent: DockerUsesInner {
854                    registry: None,
855                    image: "alpine",
856                    tag: None,
857                    hash: Some(
858                        "hash",
859                    ),
860                },
861            },
862        )
863        "#
864        );
865
866        // Valid: Local action "ref", actually part of the path
867        insta::assert_debug_snapshot!(
868            Uses::parse("./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89").unwrap(),
869            @r#"
870        Local(
871            LocalUses {
872                path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
873            },
874        )
875        "#
876        );
877
878        // Valid: Local action ref, unpinned.
879        insta::assert_debug_snapshot!(
880            Uses::parse("./.github/actions/hello-world-action").unwrap(),
881            @r#"
882        Local(
883            LocalUses {
884                path: "./.github/actions/hello-world-action",
885            },
886        )
887        "#
888        );
889
890        // Invalid: missing user/repo
891        insta::assert_debug_snapshot!(
892            Uses::parse("checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap_err(),
893            @r#"
894        UsesError(
895            "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
896        )
897        "#
898        );
899
900        // Valid: leading/trailing whitespace.
901        insta::assert_debug_snapshot!(
902            Uses::parse("\nactions/checkout@v4  \n").unwrap(),
903            @r#"
904        Repository(
905            RepositoryUses {
906                owner: "actions/checkout@v4",
907                dependent: RepositoryUsesInner {
908                    owner: "actions",
909                    repo: "checkout",
910                    slug: "actions/checkout",
911                    subpath: None,
912                    git_ref: "v4",
913                },
914            },
915        )
916        "#,
917        );
918
919        insta::assert_debug_snapshot!(
920            Uses::parse("\ndocker://alpine:3.8  \n").unwrap(),
921            @r#"
922        Docker(
923            DockerUses {
924                owner: "alpine:3.8",
925                dependent: DockerUsesInner {
926                    registry: None,
927                    image: "alpine",
928                    tag: Some(
929                        "3.8",
930                    ),
931                    hash: None,
932                },
933            },
934        )
935        "#
936        );
937
938        insta::assert_debug_snapshot!(
939            Uses::parse("\n./.github/workflows/example.yml  \n").unwrap(),
940            @r#"
941        Local(
942            LocalUses {
943                path: "./.github/workflows/example.yml",
944            },
945        )
946        "#
947        );
948    }
949
950    #[test]
951    fn test_uses_deser_reusable() {
952        // Dummy type for testing deser of `Uses`.
953        #[derive(Deserialize)]
954        #[serde(transparent)]
955        struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
956
957        insta::assert_debug_snapshot!(
958            serde_yaml::from_str::<Dummy>(
959                "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
960            )
961            .map(|d| d.0)
962            .unwrap(),
963            @r#"
964        Repository(
965            RepositoryUses {
966                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
967                dependent: RepositoryUsesInner {
968                    owner: "octo-org",
969                    repo: "this-repo",
970                    slug: "octo-org/this-repo",
971                    subpath: Some(
972                        ".github/workflows/workflow-1.yml",
973                    ),
974                    git_ref: "172239021f7ba04fe7327647b213799853a9eb89",
975                },
976            },
977        )
978        "#
979        );
980
981        insta::assert_debug_snapshot!(
982            serde_yaml::from_str::<Dummy>(
983                "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash"
984            ).map(|d| d.0).unwrap(),
985            @r#"
986        Repository(
987            RepositoryUses {
988                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
989                dependent: RepositoryUsesInner {
990                    owner: "octo-org",
991                    repo: "this-repo",
992                    slug: "octo-org/this-repo",
993                    subpath: Some(
994                        ".github/workflows/workflow-1.yml",
995                    ),
996                    git_ref: "notahash",
997                },
998            },
999        )
1000        "#
1001        );
1002
1003        insta::assert_debug_snapshot!(
1004            serde_yaml::from_str::<Dummy>(
1005                "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd"
1006            ).map(|d| d.0).unwrap(),
1007            @r#"
1008        Repository(
1009            RepositoryUses {
1010                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
1011                dependent: RepositoryUsesInner {
1012                    owner: "octo-org",
1013                    repo: "this-repo",
1014                    slug: "octo-org/this-repo",
1015                    subpath: Some(
1016                        ".github/workflows/workflow-1.yml",
1017                    ),
1018                    git_ref: "abcd",
1019                },
1020            },
1021        )
1022        "#
1023        );
1024
1025        // Invalid: remote reusable workflow without ref
1026        insta::assert_debug_snapshot!(
1027            serde_yaml::from_str::<Dummy>(
1028                "octo-org/this-repo/.github/workflows/workflow-1.yml"
1029            ).map(|d| d.0).unwrap_err(),
1030            @r#"Error("malformed `uses` ref: missing `@<ref>` in octo-org/this-repo/.github/workflows/workflow-1.yml")"#
1031        );
1032
1033        // Invalid: local reusable workflow with ref
1034        insta::assert_debug_snapshot!(
1035            serde_yaml::from_str::<Dummy>(
1036                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
1037            ).map(|d| d.0).unwrap_err(),
1038            @r#"Error("local reusable workflow reference can't specify `@<ref>`")"#
1039        );
1040
1041        // Invalid: no ref at all
1042        insta::assert_debug_snapshot!(
1043            serde_yaml::from_str::<Dummy>(
1044                ".github/workflows/workflow-1.yml"
1045            ).map(|d| d.0).unwrap_err(),
1046            @r#"Error("malformed `uses` ref: missing `@<ref>` in .github/workflows/workflow-1.yml")"#
1047        );
1048
1049        // Invalid: missing user/repo
1050        insta::assert_debug_snapshot!(
1051            serde_yaml::from_str::<Dummy>(
1052                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
1053            ).map(|d| d.0).unwrap_err(),
1054            @r#"Error("malformed `uses` ref: owner/repo slug is too short: workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89")"#
1055        );
1056    }
1057}