github_actions_models/
common.rs

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