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