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