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
91/// A "scalar or vector" type, for places in GitHub Actions where a
92/// key can have either a scalar value or an array of values.
93///
94/// This only appears internally, as an intermediate type for `scalar_or_vector`.
95#[derive(Deserialize, Debug, PartialEq)]
96#[serde(untagged)]
97enum SoV<T> {
98    One(T),
99    Many(Vec<T>),
100}
101
102impl<T> From<SoV<T>> for Vec<T> {
103    fn from(val: SoV<T>) -> Vec<T> {
104        match val {
105            SoV::One(v) => vec![v],
106            SoV::Many(vs) => vs,
107        }
108    }
109}
110
111pub(crate) fn scalar_or_vector<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
112where
113    D: Deserializer<'de>,
114    T: Deserialize<'de>,
115{
116    SoV::deserialize(de).map(Into::into)
117}
118
119/// A bool or string. This is useful for cases where GitHub Actions contextually
120/// reinterprets a YAML boolean as a string, e.g. `run: true` really means
121/// `run: 'true'`.
122#[derive(Deserialize, Debug, PartialEq)]
123#[serde(untagged)]
124enum BoS {
125    Bool(bool),
126    String(String),
127}
128
129impl From<BoS> for String {
130    fn from(value: BoS) -> Self {
131        match value {
132            BoS::Bool(b) => b.to_string(),
133            BoS::String(s) => s,
134        }
135    }
136}
137
138/// An `if:` condition in a job or action definition.
139///
140/// These are either booleans or bare (i.e. non-curly) expressions.
141#[derive(Deserialize, Serialize, Debug, PartialEq)]
142#[serde(untagged)]
143pub enum If {
144    Bool(bool),
145    // NOTE: condition expressions can be either "bare" or "curly", so we can't
146    // use `BoE` or anything else that assumes curly-only here.
147    Expr(String),
148}
149
150pub(crate) fn bool_is_string<'de, D>(de: D) -> Result<String, D::Error>
151where
152    D: Deserializer<'de>,
153{
154    BoS::deserialize(de).map(Into::into)
155}
156
157fn null_to_default<'de, D, T>(de: D) -> Result<T, D::Error>
158where
159    D: Deserializer<'de>,
160    T: Default + Deserialize<'de>,
161{
162    let key = Option::<T>::deserialize(de)?;
163    Ok(key.unwrap_or_default())
164}
165
166// TODO: Bother with enum variants here?
167#[derive(Debug, PartialEq)]
168pub struct UsesError(String);
169
170impl fmt::Display for UsesError {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "malformed `uses` ref: {}", self.0)
173    }
174}
175
176#[derive(Debug, PartialEq)]
177pub enum Uses {
178    /// A local `uses:` clause, e.g. `uses: ./foo/bar`.
179    Local(LocalUses),
180
181    /// A repository `uses:` clause, e.g. `uses: foo/bar`.
182    Repository(RepositoryUses),
183
184    /// A Docker image `uses: clause`, e.g. `uses: docker://ubuntu`.
185    Docker(DockerUses),
186}
187
188impl FromStr for Uses {
189    type Err = UsesError;
190
191    fn from_str(uses: &str) -> Result<Self, Self::Err> {
192        if uses.starts_with("./") {
193            LocalUses::from_str(uses).map(Self::Local)
194        } else if let Some(image) = uses.strip_prefix("docker://") {
195            DockerUses::from_str(image).map(Self::Docker)
196        } else {
197            RepositoryUses::from_str(uses).map(Self::Repository)
198        }
199    }
200}
201
202/// A `uses: ./some/path` clause.
203#[derive(Debug, PartialEq)]
204pub struct LocalUses {
205    pub path: String,
206}
207
208impl FromStr for LocalUses {
209    type Err = UsesError;
210
211    fn from_str(uses: &str) -> Result<Self, Self::Err> {
212        Ok(LocalUses { path: uses.into() })
213    }
214}
215
216/// A `uses: some/repo` clause.
217#[derive(Debug, PartialEq)]
218pub struct RepositoryUses {
219    /// The repo user or org.
220    pub owner: String,
221    /// The repo name.
222    pub repo: String,
223    /// The subpath to the action or reusable workflow, if present.
224    pub subpath: Option<String>,
225    /// The `@<ref>` that the `uses:` is pinned to, if present.
226    pub git_ref: Option<String>,
227}
228
229impl FromStr for RepositoryUses {
230    type Err = UsesError;
231
232    fn from_str(uses: &str) -> Result<Self, Self::Err> {
233        // NOTE: FromStr is slightly sub-optimal, since it takes a borrowed
234        // &str and results in bunch of allocs for a fully owned type.
235        //
236        // In theory we could do `From<String>` instead, but
237        // `&mut str::split_mut` and similar don't exist yet.
238
239        // NOTE: Both git refs and paths can contain `@`, but in practice
240        // GHA refuses to run a `uses:` clause with more than one `@` in it.
241        let (path, git_ref) = match uses.rsplit_once('@') {
242            Some((path, git_ref)) => (path, Some(git_ref)),
243            None => (uses, None),
244        };
245
246        let components = path.splitn(3, '/').collect::<Vec<_>>();
247        if components.len() < 2 {
248            return Err(UsesError(format!("owner/repo slug is too short: {uses}")));
249        }
250
251        Ok(RepositoryUses {
252            owner: components[0].into(),
253            repo: components[1].into(),
254            subpath: components.get(2).map(ToString::to_string),
255            git_ref: git_ref.map(Into::into),
256        })
257    }
258}
259
260/// A `uses: docker://some-image` clause.
261#[derive(Debug, PartialEq)]
262pub struct DockerUses {
263    /// The registry this image is on, if present.
264    pub registry: Option<String>,
265    /// The name of the Docker image.
266    pub image: String,
267    /// An optional tag for the image.
268    pub tag: Option<String>,
269    /// An optional integrity hash for the image.
270    pub hash: Option<String>,
271}
272
273impl DockerUses {
274    fn is_registry(registry: &str) -> bool {
275        // https://stackoverflow.com/a/42116190
276        registry == "localhost" || registry.contains('.') || registry.contains(':')
277    }
278}
279
280impl FromStr for DockerUses {
281    type Err = UsesError;
282
283    fn from_str(uses: &str) -> Result<Self, Self::Err> {
284        let (registry, image) = match uses.split_once('/') {
285            Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
286            _ => (None, uses),
287        };
288
289        // NOTE(ww): hashes aren't mentioned anywhere in Docker's own docs,
290        // but appear to be an OCI thing. GitHub doesn't support them
291        // yet either, but we expect them to soon (with "immutable actions").
292        if let Some(at_pos) = image.find('@') {
293            let (image, hash) = image.split_at(at_pos);
294
295            let hash = if hash.is_empty() {
296                None
297            } else {
298                Some(&hash[1..])
299            };
300
301            Ok(DockerUses {
302                registry: registry.map(Into::into),
303                image: image.into(),
304                tag: None,
305                hash: hash.map(Into::into),
306            })
307        } else {
308            let (image, tag) = match image.split_once(':') {
309                Some((image, "")) => (image, None),
310                Some((image, tag)) => (image, Some(tag)),
311                _ => (image, None),
312            };
313
314            Ok(DockerUses {
315                registry: registry.map(Into::into),
316                image: image.into(),
317                tag: tag.map(Into::into),
318                hash: None,
319            })
320        }
321    }
322}
323
324/// Deserialize an ordinary step `uses:`.
325pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
326where
327    D: Deserializer<'de>,
328{
329    let uses = <&str>::deserialize(de)?;
330    Uses::from_str(uses).map_err(de::Error::custom)
331}
332
333/// Deserialize a reusable workflow step `uses:`
334pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
335where
336    D: Deserializer<'de>,
337{
338    let uses = step_uses(de)?;
339
340    match uses {
341        Uses::Repository(ref repo) => {
342            // Remote reusable workflows must be pinned.
343            if repo.git_ref.is_none() {
344                Err(de::Error::custom(
345                    "repo action must have `@<ref>` in reusable workflow",
346                ))
347            } else {
348                Ok(uses)
349            }
350        }
351        Uses::Local(ref local) => {
352            // Local reusable workflows cannot be pinned.
353            // We do this with a string scan because `@` *can* occur as
354            // a path component in local actions uses, just not local reusable
355            // workflow uses.
356            if local.path.contains('@') {
357                Err(de::Error::custom(
358                    "local reusable workflow reference can't specify `@<ref>`",
359                ))
360            } else {
361                Ok(uses)
362            }
363        }
364        // `docker://` is never valid in reusable workflow uses.
365        Uses::Docker(_) => Err(de::Error::custom(
366            "docker action invalid in reusable workflow `uses`",
367        )),
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use indexmap::IndexMap;
374    use serde::Deserialize;
375
376    use crate::common::{BasePermission, Env, EnvValue, Permission};
377
378    use super::{
379        DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError, reusable_step_uses,
380    };
381
382    #[test]
383    fn test_permissions() {
384        assert_eq!(
385            serde_yaml::from_str::<Permissions>("read-all").unwrap(),
386            Permissions::Base(BasePermission::ReadAll)
387        );
388
389        let perm = "security-events: write";
390        assert_eq!(
391            serde_yaml::from_str::<Permissions>(perm).unwrap(),
392            Permissions::Explicit(IndexMap::from([(
393                "security-events".into(),
394                Permission::Write
395            )]))
396        );
397    }
398
399    #[test]
400    fn test_env_empty_value() {
401        let env = "foo:";
402        assert_eq!(
403            serde_yaml::from_str::<Env>(env).unwrap()["foo"],
404            EnvValue::String("".into())
405        );
406    }
407
408    #[test]
409    fn test_uses_parses() {
410        let vectors = [
411            (
412                // Valid: fully pinned.
413                "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
414                Ok(Uses::Repository(RepositoryUses {
415                    owner: "actions".to_owned(),
416                    repo: "checkout".to_owned(),
417                    subpath: None,
418                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
419                })),
420            ),
421            (
422                // Valid: fully pinned, subpath
423                "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
424                Ok(Uses::Repository(RepositoryUses {
425                    owner: "actions".to_owned(),
426                    repo: "aws".to_owned(),
427                    subpath: Some("ec2".to_owned()),
428                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
429                })),
430            ),
431            (
432                // Valid: fully pinned, complex subpath
433                "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
434                Ok(Uses::Repository(RepositoryUses {
435                    owner: "example".to_owned(),
436                    repo: "foo".to_owned(),
437                    subpath: Some("bar/baz/quux".to_owned()),
438                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
439                })),
440            ),
441            (
442                // Valid: pinned with branch/tag
443                "actions/checkout@v4",
444                Ok(Uses::Repository(RepositoryUses {
445                    owner: "actions".to_owned(),
446                    repo: "checkout".to_owned(),
447                    subpath: None,
448                    git_ref: Some("v4".to_owned()),
449                })),
450            ),
451            (
452                "actions/checkout@abcd",
453                Ok(Uses::Repository(RepositoryUses {
454                    owner: "actions".to_owned(),
455                    repo: "checkout".to_owned(),
456                    subpath: None,
457                    git_ref: Some("abcd".to_owned()),
458                })),
459            ),
460            (
461                // Valid: unpinned
462                "actions/checkout",
463                Ok(Uses::Repository(RepositoryUses {
464                    owner: "actions".to_owned(),
465                    repo: "checkout".to_owned(),
466                    subpath: None,
467                    git_ref: None,
468                })),
469            ),
470            (
471                // Valid: Docker ref, implicit registry
472                "docker://alpine:3.8",
473                Ok(Uses::Docker(DockerUses {
474                    registry: None,
475                    image: "alpine".to_owned(),
476                    tag: Some("3.8".to_owned()),
477                    hash: None,
478                })),
479            ),
480            (
481                // Valid: Docker ref, localhost
482                "docker://localhost/alpine:3.8",
483                Ok(Uses::Docker(DockerUses {
484                    registry: Some("localhost".to_owned()),
485                    image: "alpine".to_owned(),
486                    tag: Some("3.8".to_owned()),
487                    hash: None,
488                })),
489            ),
490            (
491                // Valid: Docker ref, localhost w/ port
492                "docker://localhost:1337/alpine:3.8",
493                Ok(Uses::Docker(DockerUses {
494                    registry: Some("localhost:1337".to_owned()),
495                    image: "alpine".to_owned(),
496                    tag: Some("3.8".to_owned()),
497                    hash: None,
498                })),
499            ),
500            (
501                // Valid: Docker ref, custom registry
502                "docker://ghcr.io/foo/alpine:3.8",
503                Ok(Uses::Docker(DockerUses {
504                    registry: Some("ghcr.io".to_owned()),
505                    image: "foo/alpine".to_owned(),
506                    tag: Some("3.8".to_owned()),
507                    hash: None,
508                })),
509            ),
510            (
511                // Valid: Docker ref, missing tag
512                "docker://ghcr.io/foo/alpine",
513                Ok(Uses::Docker(DockerUses {
514                    registry: Some("ghcr.io".to_owned()),
515                    image: "foo/alpine".to_owned(),
516                    tag: None,
517                    hash: None,
518                })),
519            ),
520            (
521                // Invalid, but allowed: Docker ref, empty tag
522                "docker://ghcr.io/foo/alpine:",
523                Ok(Uses::Docker(DockerUses {
524                    registry: Some("ghcr.io".to_owned()),
525                    image: "foo/alpine".to_owned(),
526                    tag: None,
527                    hash: None,
528                })),
529            ),
530            (
531                // Valid: Docker ref, bare
532                "docker://alpine",
533                Ok(Uses::Docker(DockerUses {
534                    registry: None,
535                    image: "alpine".to_owned(),
536                    tag: None,
537                    hash: None,
538                })),
539            ),
540            (
541                // Valid: Docker ref, hash
542                "docker://alpine@hash",
543                Ok(Uses::Docker(DockerUses {
544                    registry: None,
545                    image: "alpine".to_owned(),
546                    tag: None,
547                    hash: Some("hash".to_owned()),
548                })),
549            ),
550            (
551                // Valid: Local action "ref", actually part of the path
552                "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
553                Ok(Uses::Local(LocalUses {
554                    path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
555                })),
556            ),
557            (
558                // Valid: Local action ref, unpinned
559                "./.github/actions/hello-world-action",
560                Ok(Uses::Local(LocalUses {
561                    path: "./.github/actions/hello-world-action".to_owned(),
562                })),
563            ),
564            // Invalid: missing user/repo
565            (
566                "checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
567                Err(UsesError(
568                    "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()
569                )),
570            ),
571        ];
572
573        for (input, expected) in vectors {
574            assert_eq!(input.parse(), expected);
575        }
576    }
577
578    #[test]
579    fn test_uses_deser_reusable() {
580        let vectors = [
581            // Valid, as expected.
582            (
583                "octo-org/this-repo/.github/workflows/workflow-1.yml@\
584                 172239021f7ba04fe7327647b213799853a9eb89",
585                Some(Uses::Repository(RepositoryUses {
586                    owner: "octo-org".to_owned(),
587                    repo: "this-repo".to_owned(),
588                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
589                    git_ref: Some("172239021f7ba04fe7327647b213799853a9eb89".to_owned()),
590                })),
591            ),
592            (
593                "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
594                Some(Uses::Repository(RepositoryUses {
595                    owner: "octo-org".to_owned(),
596                    repo: "this-repo".to_owned(),
597                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
598                    git_ref: Some("notahash".to_owned()),
599                })),
600            ),
601            (
602                "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
603                Some(Uses::Repository(RepositoryUses {
604                    owner: "octo-org".to_owned(),
605                    repo: "this-repo".to_owned(),
606                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
607                    git_ref: Some("abcd".to_owned()),
608                })),
609            ),
610            // Invalid: remote reusable workflow without ref
611            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
612            // Invalid: local reusable workflow with ref
613            (
614                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
615                None,
616            ),
617            // Invalid: no ref at all
618            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
619            (".github/workflows/workflow-1.yml", None),
620            // Invalid: missing user/repo
621            (
622                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
623                None,
624            ),
625        ];
626
627        // Dummy type for testing deser of `Uses`.
628        #[derive(Deserialize)]
629        #[serde(transparent)]
630        struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
631
632        for (input, expected) in vectors {
633            assert_eq!(
634                serde_yaml::from_str::<Dummy>(input).map(|d| d.0).ok(),
635                expected
636            );
637        }
638    }
639}