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, if present.
242    pub git_ref: Option<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, Some(git_ref)),
259            None => (uses, None),
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.map(Into::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/// Deserialize an ordinary step `uses:`.
341pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
342where
343    D: Deserializer<'de>,
344{
345    let uses = <&str>::deserialize(de)?;
346    Uses::from_str(uses).map_err(de::Error::custom)
347}
348
349/// Deserialize a reusable workflow step `uses:`
350pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
351where
352    D: Deserializer<'de>,
353{
354    let uses = step_uses(de)?;
355
356    match uses {
357        Uses::Repository(ref repo) => {
358            // Remote reusable workflows must be pinned.
359            if repo.git_ref.is_none() {
360                Err(de::Error::custom(
361                    "repo action must have `@<ref>` in reusable workflow",
362                ))
363            } else {
364                Ok(uses)
365            }
366        }
367        Uses::Local(ref local) => {
368            // Local reusable workflows cannot be pinned.
369            // We do this with a string scan because `@` *can* occur as
370            // a path component in local actions uses, just not local reusable
371            // workflow uses.
372            if local.path.contains('@') {
373                Err(de::Error::custom(
374                    "local reusable workflow reference can't specify `@<ref>`",
375                ))
376            } else {
377                Ok(uses)
378            }
379        }
380        // `docker://` is never valid in reusable workflow uses.
381        Uses::Docker(_) => Err(de::Error::custom(
382            "docker action invalid in reusable workflow `uses`",
383        )),
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use indexmap::IndexMap;
390    use serde::Deserialize;
391
392    use crate::common::{BasePermission, Env, EnvValue, Permission};
393
394    use super::{
395        DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError, reusable_step_uses,
396    };
397
398    #[test]
399    fn test_permissions() {
400        assert_eq!(
401            serde_yaml::from_str::<Permissions>("read-all").unwrap(),
402            Permissions::Base(BasePermission::ReadAll)
403        );
404
405        let perm = "security-events: write";
406        assert_eq!(
407            serde_yaml::from_str::<Permissions>(perm).unwrap(),
408            Permissions::Explicit(IndexMap::from([(
409                "security-events".into(),
410                Permission::Write
411            )]))
412        );
413    }
414
415    #[test]
416    fn test_env_empty_value() {
417        let env = "foo:";
418        assert_eq!(
419            serde_yaml::from_str::<Env>(env).unwrap()["foo"],
420            EnvValue::String("".into())
421        );
422    }
423
424    #[test]
425    fn test_env_value_csharp_trueish() {
426        let vectors = [
427            (EnvValue::Boolean(true), true),
428            (EnvValue::Boolean(false), false),
429            (EnvValue::String("true".to_string()), true),
430            (EnvValue::String("TRUE".to_string()), true),
431            (EnvValue::String("TrUe".to_string()), true),
432            (EnvValue::String(" true ".to_string()), true),
433            (EnvValue::String("   \n\r\t True\n\n".to_string()), true),
434            (EnvValue::String("false".to_string()), false),
435            (EnvValue::String("1".to_string()), false),
436            (EnvValue::String("yes".to_string()), false),
437            (EnvValue::String("on".to_string()), false),
438            (EnvValue::String("random".to_string()), false),
439            (EnvValue::Number(1.0), false),
440            (EnvValue::Number(0.0), false),
441            (EnvValue::Number(666.0), false),
442        ];
443
444        for (val, expected) in vectors {
445            assert_eq!(val.csharp_trueish(), expected, "failed for {:?}", val);
446        }
447    }
448
449    #[test]
450    fn test_uses_parses() {
451        let vectors = [
452            (
453                // Valid: fully pinned.
454                "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
455                Ok(Uses::Repository(RepositoryUses {
456                    owner: "actions".to_owned(),
457                    repo: "checkout".to_owned(),
458                    subpath: None,
459                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
460                })),
461            ),
462            (
463                // Valid: fully pinned, subpath
464                "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
465                Ok(Uses::Repository(RepositoryUses {
466                    owner: "actions".to_owned(),
467                    repo: "aws".to_owned(),
468                    subpath: Some("ec2".to_owned()),
469                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
470                })),
471            ),
472            (
473                // Valid: fully pinned, complex subpath
474                "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
475                Ok(Uses::Repository(RepositoryUses {
476                    owner: "example".to_owned(),
477                    repo: "foo".to_owned(),
478                    subpath: Some("bar/baz/quux".to_owned()),
479                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
480                })),
481            ),
482            (
483                // Valid: pinned with branch/tag
484                "actions/checkout@v4",
485                Ok(Uses::Repository(RepositoryUses {
486                    owner: "actions".to_owned(),
487                    repo: "checkout".to_owned(),
488                    subpath: None,
489                    git_ref: Some("v4".to_owned()),
490                })),
491            ),
492            (
493                "actions/checkout@abcd",
494                Ok(Uses::Repository(RepositoryUses {
495                    owner: "actions".to_owned(),
496                    repo: "checkout".to_owned(),
497                    subpath: None,
498                    git_ref: Some("abcd".to_owned()),
499                })),
500            ),
501            (
502                // Valid: unpinned
503                "actions/checkout",
504                Ok(Uses::Repository(RepositoryUses {
505                    owner: "actions".to_owned(),
506                    repo: "checkout".to_owned(),
507                    subpath: None,
508                    git_ref: None,
509                })),
510            ),
511            (
512                // Valid: Docker ref, implicit registry
513                "docker://alpine:3.8",
514                Ok(Uses::Docker(DockerUses {
515                    registry: None,
516                    image: "alpine".to_owned(),
517                    tag: Some("3.8".to_owned()),
518                    hash: None,
519                })),
520            ),
521            (
522                // Valid: Docker ref, localhost
523                "docker://localhost/alpine:3.8",
524                Ok(Uses::Docker(DockerUses {
525                    registry: Some("localhost".to_owned()),
526                    image: "alpine".to_owned(),
527                    tag: Some("3.8".to_owned()),
528                    hash: None,
529                })),
530            ),
531            (
532                // Valid: Docker ref, localhost w/ port
533                "docker://localhost:1337/alpine:3.8",
534                Ok(Uses::Docker(DockerUses {
535                    registry: Some("localhost:1337".to_owned()),
536                    image: "alpine".to_owned(),
537                    tag: Some("3.8".to_owned()),
538                    hash: None,
539                })),
540            ),
541            (
542                // Valid: Docker ref, custom registry
543                "docker://ghcr.io/foo/alpine:3.8",
544                Ok(Uses::Docker(DockerUses {
545                    registry: Some("ghcr.io".to_owned()),
546                    image: "foo/alpine".to_owned(),
547                    tag: Some("3.8".to_owned()),
548                    hash: None,
549                })),
550            ),
551            (
552                // Valid: Docker ref, missing tag
553                "docker://ghcr.io/foo/alpine",
554                Ok(Uses::Docker(DockerUses {
555                    registry: Some("ghcr.io".to_owned()),
556                    image: "foo/alpine".to_owned(),
557                    tag: None,
558                    hash: None,
559                })),
560            ),
561            (
562                // Invalid, but allowed: Docker ref, empty tag
563                "docker://ghcr.io/foo/alpine:",
564                Ok(Uses::Docker(DockerUses {
565                    registry: Some("ghcr.io".to_owned()),
566                    image: "foo/alpine".to_owned(),
567                    tag: None,
568                    hash: None,
569                })),
570            ),
571            (
572                // Valid: Docker ref, bare
573                "docker://alpine",
574                Ok(Uses::Docker(DockerUses {
575                    registry: None,
576                    image: "alpine".to_owned(),
577                    tag: None,
578                    hash: None,
579                })),
580            ),
581            (
582                // Valid: Docker ref, hash
583                "docker://alpine@hash",
584                Ok(Uses::Docker(DockerUses {
585                    registry: None,
586                    image: "alpine".to_owned(),
587                    tag: None,
588                    hash: Some("hash".to_owned()),
589                })),
590            ),
591            (
592                // Valid: Local action "ref", actually part of the path
593                "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
594                Ok(Uses::Local(LocalUses {
595                    path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
596                })),
597            ),
598            (
599                // Valid: Local action ref, unpinned
600                "./.github/actions/hello-world-action",
601                Ok(Uses::Local(LocalUses {
602                    path: "./.github/actions/hello-world-action".to_owned(),
603                })),
604            ),
605            // Invalid: missing user/repo
606            (
607                "checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
608                Err(UsesError(
609                    "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()
610                )),
611            ),
612        ];
613
614        for (input, expected) in vectors {
615            assert_eq!(input.parse(), expected);
616        }
617    }
618
619    #[test]
620    fn test_uses_deser_reusable() {
621        let vectors = [
622            // Valid, as expected.
623            (
624                "octo-org/this-repo/.github/workflows/workflow-1.yml@\
625                 172239021f7ba04fe7327647b213799853a9eb89",
626                Some(Uses::Repository(RepositoryUses {
627                    owner: "octo-org".to_owned(),
628                    repo: "this-repo".to_owned(),
629                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
630                    git_ref: Some("172239021f7ba04fe7327647b213799853a9eb89".to_owned()),
631                })),
632            ),
633            (
634                "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
635                Some(Uses::Repository(RepositoryUses {
636                    owner: "octo-org".to_owned(),
637                    repo: "this-repo".to_owned(),
638                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
639                    git_ref: Some("notahash".to_owned()),
640                })),
641            ),
642            (
643                "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
644                Some(Uses::Repository(RepositoryUses {
645                    owner: "octo-org".to_owned(),
646                    repo: "this-repo".to_owned(),
647                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
648                    git_ref: Some("abcd".to_owned()),
649                })),
650            ),
651            // Invalid: remote reusable workflow without ref
652            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
653            // Invalid: local reusable workflow with ref
654            (
655                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
656                None,
657            ),
658            // Invalid: no ref at all
659            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
660            (".github/workflows/workflow-1.yml", None),
661            // Invalid: missing user/repo
662            (
663                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
664                None,
665            ),
666        ];
667
668        // Dummy type for testing deser of `Uses`.
669        #[derive(Deserialize)]
670        #[serde(transparent)]
671        struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
672
673        for (input, expected) in vectors {
674            assert_eq!(
675                serde_yaml::from_str::<Dummy>(input).map(|d| d.0).ok(),
676                expected
677            );
678        }
679    }
680}