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