github_actions_models/
common.rs

1//! Shared models and utilities.
2
3use std::{
4    fmt::{self, Display},
5    str::FromStr,
6};
7
8use indexmap::IndexMap;
9use serde::{Deserialize, Deserializer, Serialize, de};
10
11pub mod expr;
12
13/// `permissions` for a workflow, job, or step.
14#[derive(Deserialize, Debug, PartialEq)]
15#[serde(rename_all = "kebab-case", untagged)]
16pub enum Permissions {
17    /// Base, i.e. blanket permissions.
18    Base(BasePermission),
19    /// Fine-grained permissions.
20    ///
21    /// These are modeled with an open-ended mapping rather than a structure
22    /// to make iteration over all defined permissions easier.
23    Explicit(IndexMap<String, Permission>),
24}
25
26impl Default for Permissions {
27    fn default() -> Self {
28        Self::Base(BasePermission::Default)
29    }
30}
31
32/// "Base" permissions, where all individual permissions are configured
33/// with a blanket setting.
34#[derive(Deserialize, Debug, Default, PartialEq)]
35#[serde(rename_all = "kebab-case")]
36pub enum BasePermission {
37    /// Whatever default permissions come from the workflow's `GITHUB_TOKEN`.
38    #[default]
39    Default,
40    /// "Read" access to all resources.
41    ReadAll,
42    /// "Write" access to all resources (implies read).
43    WriteAll,
44}
45
46/// A singular permission setting.
47#[derive(Deserialize, Debug, Default, PartialEq)]
48#[serde(rename_all = "kebab-case")]
49pub enum Permission {
50    /// Read access.
51    Read,
52
53    /// Write access.
54    Write,
55
56    /// No access.
57    #[default]
58    None,
59}
60
61/// An environment mapping.
62pub type Env = IndexMap<String, EnvValue>;
63
64/// Environment variable values are always strings, but GitHub Actions
65/// allows users to configure them as various native YAML types before
66/// internal stringification.
67///
68/// This type also gets used for other places where GitHub Actions
69/// contextually reinterprets a YAML value as a string, e.g. trigger
70/// input values.
71#[derive(Deserialize, Serialize, Debug, PartialEq)]
72#[serde(untagged)]
73pub enum EnvValue {
74    // Missing values are empty strings.
75    #[serde(deserialize_with = "null_to_default")]
76    String(String),
77    Number(f64),
78    Boolean(bool),
79}
80
81impl Display for EnvValue {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Self::String(s) => write!(f, "{s}"),
85            Self::Number(n) => write!(f, "{n}"),
86            Self::Boolean(b) => write!(f, "{b}"),
87        }
88    }
89}
90
91impl EnvValue {
92    /// Returns whether this [`EnvValue`] is a "trueish" value
93    /// per C#'s `Boolean.TryParse`.
94    ///
95    /// This follows the semantics of C#'s `Boolean.TryParse`, where
96    /// the case-insensitive string "true" is considered true, but
97    /// "1", "yes", etc. are not.
98    pub fn csharp_trueish(&self) -> bool {
99        match self {
100            EnvValue::Boolean(true) => true,
101            EnvValue::String(maybe) => maybe.trim().eq_ignore_ascii_case("true"),
102            _ => false,
103        }
104    }
105}
106
107/// A "scalar or vector" type, for places in GitHub Actions where a
108/// key can have either a scalar value or an array of values.
109///
110/// This only appears internally, as an intermediate type for `scalar_or_vector`.
111#[derive(Deserialize, Debug, PartialEq)]
112#[serde(untagged)]
113enum SoV<T> {
114    One(T),
115    Many(Vec<T>),
116}
117
118impl<T> From<SoV<T>> for Vec<T> {
119    fn from(val: SoV<T>) -> Vec<T> {
120        match val {
121            SoV::One(v) => vec![v],
122            SoV::Many(vs) => vs,
123        }
124    }
125}
126
127pub(crate) fn scalar_or_vector<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
128where
129    D: Deserializer<'de>,
130    T: Deserialize<'de>,
131{
132    SoV::deserialize(de).map(Into::into)
133}
134
135/// A bool or string. This is useful for cases where GitHub Actions contextually
136/// reinterprets a YAML boolean as a string, e.g. `run: true` really means
137/// `run: 'true'`.
138#[derive(Deserialize, Debug, PartialEq)]
139#[serde(untagged)]
140enum BoS {
141    Bool(bool),
142    String(String),
143}
144
145impl From<BoS> for String {
146    fn from(value: BoS) -> Self {
147        match value {
148            BoS::Bool(b) => b.to_string(),
149            BoS::String(s) => s,
150        }
151    }
152}
153
154/// An `if:` condition in a job or action definition.
155///
156/// These are either booleans or bare (i.e. non-curly) expressions.
157#[derive(Deserialize, Serialize, Debug, PartialEq)]
158#[serde(untagged)]
159pub enum If {
160    Bool(bool),
161    // NOTE: condition expressions can be either "bare" or "curly", so we can't
162    // use `BoE` or anything else that assumes curly-only here.
163    Expr(String),
164}
165
166pub(crate) fn bool_is_string<'de, D>(de: D) -> Result<String, D::Error>
167where
168    D: Deserializer<'de>,
169{
170    BoS::deserialize(de).map(Into::into)
171}
172
173fn null_to_default<'de, D, T>(de: D) -> Result<T, D::Error>
174where
175    D: Deserializer<'de>,
176    T: Default + Deserialize<'de>,
177{
178    let key = Option::<T>::deserialize(de)?;
179    Ok(key.unwrap_or_default())
180}
181
182// TODO: Bother with enum variants here?
183#[derive(Debug, PartialEq)]
184pub struct UsesError(String);
185
186impl fmt::Display for UsesError {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        write!(f, "malformed `uses` ref: {}", self.0)
189    }
190}
191
192#[derive(Debug, PartialEq)]
193pub enum Uses {
194    /// A local `uses:` clause, e.g. `uses: ./foo/bar`.
195    Local(LocalUses),
196
197    /// A repository `uses:` clause, e.g. `uses: foo/bar`.
198    Repository(RepositoryUses),
199
200    /// A Docker image `uses: clause`, e.g. `uses: docker://ubuntu`.
201    Docker(DockerUses),
202}
203
204impl FromStr for Uses {
205    type Err = UsesError;
206
207    fn from_str(uses: &str) -> Result<Self, Self::Err> {
208        if uses.starts_with("./") {
209            LocalUses::from_str(uses).map(Self::Local)
210        } else if let Some(image) = uses.strip_prefix("docker://") {
211            DockerUses::from_str(image).map(Self::Docker)
212        } else {
213            RepositoryUses::from_str(uses).map(Self::Repository)
214        }
215    }
216}
217
218/// A `uses: ./some/path` clause.
219#[derive(Debug, PartialEq)]
220pub struct LocalUses {
221    pub path: String,
222}
223
224impl FromStr for LocalUses {
225    type Err = UsesError;
226
227    fn from_str(uses: &str) -> Result<Self, Self::Err> {
228        Ok(LocalUses { path: uses.into() })
229    }
230}
231
232/// A `uses: some/repo` clause.
233#[derive(Debug, PartialEq)]
234pub struct RepositoryUses {
235    /// The repo user or org.
236    pub owner: String,
237    /// The repo name.
238    pub repo: String,
239    /// The subpath to the action or reusable workflow, if present.
240    pub subpath: Option<String>,
241    /// The `@<ref>` that the `uses:` is pinned to.
242    pub git_ref: String,
243}
244
245impl FromStr for RepositoryUses {
246    type Err = UsesError;
247
248    fn from_str(uses: &str) -> Result<Self, Self::Err> {
249        // NOTE: FromStr is slightly sub-optimal, since it takes a borrowed
250        // &str and results in bunch of allocs for a fully owned type.
251        //
252        // In theory we could do `From<String>` instead, but
253        // `&mut str::split_mut` and similar don't exist yet.
254
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 components = path.splitn(3, '/').collect::<Vec<_>>();
263        if components.len() < 2 {
264            return Err(UsesError(format!("owner/repo slug is too short: {uses}")));
265        }
266
267        Ok(RepositoryUses {
268            owner: components[0].into(),
269            repo: components[1].into(),
270            subpath: components.get(2).map(ToString::to_string),
271            git_ref: git_ref.into(),
272        })
273    }
274}
275
276/// A `uses: docker://some-image` clause.
277#[derive(Debug, PartialEq)]
278pub struct DockerUses {
279    /// The registry this image is on, if present.
280    pub registry: Option<String>,
281    /// The name of the Docker image.
282    pub image: String,
283    /// An optional tag for the image.
284    pub tag: Option<String>,
285    /// An optional integrity hash for the image.
286    pub hash: Option<String>,
287}
288
289impl DockerUses {
290    fn is_registry(registry: &str) -> bool {
291        // https://stackoverflow.com/a/42116190
292        registry == "localhost" || registry.contains('.') || registry.contains(':')
293    }
294}
295
296impl FromStr for DockerUses {
297    type Err = UsesError;
298
299    fn from_str(uses: &str) -> Result<Self, Self::Err> {
300        let (registry, image) = match uses.split_once('/') {
301            Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
302            _ => (None, uses),
303        };
304
305        // NOTE(ww): hashes aren't mentioned anywhere in Docker's own docs,
306        // but appear to be an OCI thing. GitHub doesn't support them
307        // yet either, but we expect them to soon (with "immutable actions").
308        if let Some(at_pos) = image.find('@') {
309            let (image, hash) = image.split_at(at_pos);
310
311            let hash = if hash.is_empty() {
312                None
313            } else {
314                Some(&hash[1..])
315            };
316
317            Ok(DockerUses {
318                registry: registry.map(Into::into),
319                image: image.into(),
320                tag: None,
321                hash: hash.map(Into::into),
322            })
323        } else {
324            let (image, tag) = match image.split_once(':') {
325                Some((image, "")) => (image, None),
326                Some((image, tag)) => (image, Some(tag)),
327                _ => (image, None),
328            };
329
330            Ok(DockerUses {
331                registry: registry.map(Into::into),
332                image: image.into(),
333                tag: tag.map(Into::into),
334                hash: None,
335            })
336        }
337    }
338}
339
340/// Wraps a `de::Error::custom` call to log the same error as
341/// a `tracing::error!` event.
342///
343/// This is useful when doing custom deserialization within untagged
344/// enum variants, since serde loses track of the original error.
345pub(crate) fn custom_error<'de, D>(msg: impl Display) -> D::Error
346where
347    D: Deserializer<'de>,
348{
349    let msg = msg.to_string();
350    tracing::error!(msg);
351    de::Error::custom(msg)
352}
353
354/// Deserialize an ordinary step `uses:`.
355pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
356where
357    D: Deserializer<'de>,
358{
359    let uses = <&str>::deserialize(de)?;
360    Uses::from_str(uses).map_err(custom_error::<D>)
361}
362
363/// Deserialize a reusable workflow step `uses:`
364pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
365where
366    D: Deserializer<'de>,
367{
368    let uses = step_uses(de)?;
369
370    match uses {
371        Uses::Repository(_) => Ok(uses),
372        Uses::Local(ref local) => {
373            // Local reusable workflows cannot be pinned.
374            // We do this with a string scan because `@` *can* occur as
375            // a path component in local actions uses, just not local reusable
376            // workflow uses.
377            if local.path.contains('@') {
378                Err(custom_error::<D>(
379                    "local reusable workflow reference can't specify `@<ref>`",
380                ))
381            } else {
382                Ok(uses)
383            }
384        }
385        // `docker://` is never valid in reusable workflow uses.
386        Uses::Docker(_) => Err(custom_error::<D>(
387            "docker action invalid in reusable workflow `uses`",
388        )),
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use indexmap::IndexMap;
395    use serde::Deserialize;
396
397    use crate::common::{BasePermission, Env, EnvValue, Permission};
398
399    use super::{
400        DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError, reusable_step_uses,
401    };
402
403    #[test]
404    fn test_permissions() {
405        assert_eq!(
406            serde_yaml::from_str::<Permissions>("read-all").unwrap(),
407            Permissions::Base(BasePermission::ReadAll)
408        );
409
410        let perm = "security-events: write";
411        assert_eq!(
412            serde_yaml::from_str::<Permissions>(perm).unwrap(),
413            Permissions::Explicit(IndexMap::from([(
414                "security-events".into(),
415                Permission::Write
416            )]))
417        );
418    }
419
420    #[test]
421    fn test_env_empty_value() {
422        let env = "foo:";
423        assert_eq!(
424            serde_yaml::from_str::<Env>(env).unwrap()["foo"],
425            EnvValue::String("".into())
426        );
427    }
428
429    #[test]
430    fn test_env_value_csharp_trueish() {
431        let vectors = [
432            (EnvValue::Boolean(true), true),
433            (EnvValue::Boolean(false), false),
434            (EnvValue::String("true".to_string()), true),
435            (EnvValue::String("TRUE".to_string()), true),
436            (EnvValue::String("TrUe".to_string()), true),
437            (EnvValue::String(" true ".to_string()), true),
438            (EnvValue::String("   \n\r\t True\n\n".to_string()), true),
439            (EnvValue::String("false".to_string()), false),
440            (EnvValue::String("1".to_string()), false),
441            (EnvValue::String("yes".to_string()), false),
442            (EnvValue::String("on".to_string()), false),
443            (EnvValue::String("random".to_string()), false),
444            (EnvValue::Number(1.0), false),
445            (EnvValue::Number(0.0), false),
446            (EnvValue::Number(666.0), false),
447        ];
448
449        for (val, expected) in vectors {
450            assert_eq!(val.csharp_trueish(), expected, "failed for {val:?}");
451        }
452    }
453
454    #[test]
455    fn test_uses_parses() {
456        let vectors = [
457            (
458                // Valid: fully pinned.
459                "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
460                Ok(Uses::Repository(RepositoryUses {
461                    owner: "actions".to_owned(),
462                    repo: "checkout".to_owned(),
463                    subpath: None,
464                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned(),
465                })),
466            ),
467            (
468                // Valid: fully pinned, subpath
469                "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
470                Ok(Uses::Repository(RepositoryUses {
471                    owner: "actions".to_owned(),
472                    repo: "aws".to_owned(),
473                    subpath: Some("ec2".to_owned()),
474                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned(),
475                })),
476            ),
477            (
478                // Valid: fully pinned, complex subpath
479                "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
480                Ok(Uses::Repository(RepositoryUses {
481                    owner: "example".to_owned(),
482                    repo: "foo".to_owned(),
483                    subpath: Some("bar/baz/quux".to_owned()),
484                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned(),
485                })),
486            ),
487            (
488                // Valid: pinned with branch/tag
489                "actions/checkout@v4",
490                Ok(Uses::Repository(RepositoryUses {
491                    owner: "actions".to_owned(),
492                    repo: "checkout".to_owned(),
493                    subpath: None,
494                    git_ref: "v4".to_owned(),
495                })),
496            ),
497            (
498                "actions/checkout@abcd",
499                Ok(Uses::Repository(RepositoryUses {
500                    owner: "actions".to_owned(),
501                    repo: "checkout".to_owned(),
502                    subpath: None,
503                    git_ref: "abcd".to_owned(),
504                })),
505            ),
506            (
507                // Invalid: unpinned
508                "actions/checkout",
509                Err(UsesError(
510                    "missing `@<ref>` in actions/checkout".to_owned(),
511                )),
512            ),
513            (
514                // Valid: Docker ref, implicit registry
515                "docker://alpine:3.8",
516                Ok(Uses::Docker(DockerUses {
517                    registry: None,
518                    image: "alpine".to_owned(),
519                    tag: Some("3.8".to_owned()),
520                    hash: None,
521                })),
522            ),
523            (
524                // Valid: Docker ref, localhost
525                "docker://localhost/alpine:3.8",
526                Ok(Uses::Docker(DockerUses {
527                    registry: Some("localhost".to_owned()),
528                    image: "alpine".to_owned(),
529                    tag: Some("3.8".to_owned()),
530                    hash: None,
531                })),
532            ),
533            (
534                // Valid: Docker ref, localhost w/ port
535                "docker://localhost:1337/alpine:3.8",
536                Ok(Uses::Docker(DockerUses {
537                    registry: Some("localhost:1337".to_owned()),
538                    image: "alpine".to_owned(),
539                    tag: Some("3.8".to_owned()),
540                    hash: None,
541                })),
542            ),
543            (
544                // Valid: Docker ref, custom registry
545                "docker://ghcr.io/foo/alpine:3.8",
546                Ok(Uses::Docker(DockerUses {
547                    registry: Some("ghcr.io".to_owned()),
548                    image: "foo/alpine".to_owned(),
549                    tag: Some("3.8".to_owned()),
550                    hash: None,
551                })),
552            ),
553            (
554                // Valid: Docker ref, missing tag
555                "docker://ghcr.io/foo/alpine",
556                Ok(Uses::Docker(DockerUses {
557                    registry: Some("ghcr.io".to_owned()),
558                    image: "foo/alpine".to_owned(),
559                    tag: None,
560                    hash: None,
561                })),
562            ),
563            (
564                // Invalid, but allowed: Docker ref, empty tag
565                "docker://ghcr.io/foo/alpine:",
566                Ok(Uses::Docker(DockerUses {
567                    registry: Some("ghcr.io".to_owned()),
568                    image: "foo/alpine".to_owned(),
569                    tag: None,
570                    hash: None,
571                })),
572            ),
573            (
574                // Valid: Docker ref, bare
575                "docker://alpine",
576                Ok(Uses::Docker(DockerUses {
577                    registry: None,
578                    image: "alpine".to_owned(),
579                    tag: None,
580                    hash: None,
581                })),
582            ),
583            (
584                // Valid: Docker ref, hash
585                "docker://alpine@hash",
586                Ok(Uses::Docker(DockerUses {
587                    registry: None,
588                    image: "alpine".to_owned(),
589                    tag: None,
590                    hash: Some("hash".to_owned()),
591                })),
592            ),
593            (
594                // Valid: Local action "ref", actually part of the path
595                "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
596                Ok(Uses::Local(LocalUses {
597                    path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
598                })),
599            ),
600            (
601                // Valid: Local action ref, unpinned
602                "./.github/actions/hello-world-action",
603                Ok(Uses::Local(LocalUses {
604                    path: "./.github/actions/hello-world-action".to_owned(),
605                })),
606            ),
607            // Invalid: missing user/repo
608            (
609                "checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
610                Err(UsesError(
611                    "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()
612                )),
613            ),
614        ];
615
616        for (input, expected) in vectors {
617            assert_eq!(input.parse(), expected);
618        }
619    }
620
621    #[test]
622    fn test_uses_deser_reusable() {
623        let vectors = [
624            // Valid, as expected.
625            (
626                "octo-org/this-repo/.github/workflows/workflow-1.yml@\
627                 172239021f7ba04fe7327647b213799853a9eb89",
628                Some(Uses::Repository(RepositoryUses {
629                    owner: "octo-org".to_owned(),
630                    repo: "this-repo".to_owned(),
631                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
632                    git_ref: "172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
633                })),
634            ),
635            (
636                "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
637                Some(Uses::Repository(RepositoryUses {
638                    owner: "octo-org".to_owned(),
639                    repo: "this-repo".to_owned(),
640                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
641                    git_ref: "notahash".to_owned(),
642                })),
643            ),
644            (
645                "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
646                Some(Uses::Repository(RepositoryUses {
647                    owner: "octo-org".to_owned(),
648                    repo: "this-repo".to_owned(),
649                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
650                    git_ref: "abcd".to_owned(),
651                })),
652            ),
653            // Invalid: remote reusable workflow without ref
654            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
655            // Invalid: local reusable workflow with ref
656            (
657                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
658                None,
659            ),
660            // Invalid: no ref at all
661            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
662            (".github/workflows/workflow-1.yml", None),
663            // Invalid: missing user/repo
664            (
665                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
666                None,
667            ),
668        ];
669
670        // Dummy type for testing deser of `Uses`.
671        #[derive(Deserialize)]
672        #[serde(transparent)]
673        struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
674
675        for (input, expected) in vectors {
676            assert_eq!(
677                serde_yaml::from_str::<Dummy>(input).map(|d| d.0).ok(),
678                expected
679            );
680        }
681    }
682}