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::{de, Deserialize, Deserializer, Serialize};
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, Default, Debug, 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, Default, Debug, 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(Serialize, Deserialize, 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(Debug, Deserialize, 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(Debug, Deserialize, 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(Debug, Deserialize, PartialEq, Serialize)]
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    pub git_ref: Option<String>,
207}
208
209impl FromStr for LocalUses {
210    type Err = UsesError;
211
212    fn from_str(uses: &str) -> Result<Self, Self::Err> {
213        let (path, git_ref) = match uses.rsplit_once('@') {
214            Some((path, git_ref)) => (path, Some(git_ref)),
215            None => (uses, None),
216        };
217
218        if path.is_empty() {
219            return Err(UsesError(format!(
220                "local uses has no path component: {uses}"
221            )));
222        }
223
224        // TODO: Overly conservative? `uses: ./foo/bar@` might be valid if
225        // `./foo/bar@/action.yml` exists.
226        if git_ref.is_some_and(|git_ref| git_ref.is_empty()) {
227            return Err(UsesError(format!(
228                "local uses is missing git ref after '@': {uses}"
229            )));
230        }
231
232        Ok(LocalUses {
233            path: path.into(),
234            git_ref: git_ref.map(Into::into),
235        })
236    }
237}
238
239/// A `uses: some/repo` clause.
240#[derive(Debug, PartialEq)]
241pub struct RepositoryUses {
242    /// The repo user or org.
243    pub owner: String,
244    /// The repo name.
245    pub repo: String,
246    /// The subpath to the action or reusable workflow, if present.
247    pub subpath: Option<String>,
248    /// The `@<ref>` that the `uses:` is pinned to, if present.
249    pub git_ref: Option<String>,
250}
251
252impl FromStr for RepositoryUses {
253    type Err = UsesError;
254
255    fn from_str(uses: &str) -> Result<Self, Self::Err> {
256        // NOTE: FromStr is slightly sub-optimal, since it takes a borrowed
257        // &str and results in bunch of allocs for a fully owned type.
258        //
259        // In theory we could do `From<String>` instead, but
260        // `&mut str::split_mut` and similar don't exist yet.
261
262        // NOTE: Technically both git refs and action paths can contain `@`,
263        // so this isn't guaranteed to be correct. In practice, however,
264        // splitting on the last `@` is mostly reliable.
265        let (path, git_ref) = match uses.rsplit_once('@') {
266            Some((path, git_ref)) => (path, Some(git_ref)),
267            None => (uses, None),
268        };
269
270        let components = path.splitn(3, '/').collect::<Vec<_>>();
271        if components.len() < 2 {
272            return Err(UsesError(format!("owner/repo slug is too short: {uses}")));
273        }
274
275        Ok(RepositoryUses {
276            owner: components[0].into(),
277            repo: components[1].into(),
278            subpath: components.get(2).map(ToString::to_string),
279            git_ref: git_ref.map(Into::into),
280        })
281    }
282}
283
284/// A `uses: docker://some-image` clause.
285#[derive(Debug, PartialEq)]
286pub struct DockerUses {
287    /// The registry this image is on, if present.
288    pub registry: Option<String>,
289    /// The name of the Docker image.
290    pub image: String,
291    /// An optional tag for the image.
292    pub tag: Option<String>,
293    /// An optional integrity hash for the image.
294    pub hash: Option<String>,
295}
296
297impl DockerUses {
298    fn is_registry(registry: &str) -> bool {
299        // https://stackoverflow.com/a/42116190
300        registry == "localhost" || registry.contains('.') || registry.contains(':')
301    }
302}
303
304impl FromStr for DockerUses {
305    type Err = UsesError;
306
307    fn from_str(uses: &str) -> Result<Self, Self::Err> {
308        let (registry, image) = match uses.split_once('/') {
309            Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
310            _ => (None, uses),
311        };
312
313        // NOTE(ww): hashes aren't mentioned anywhere in Docker's own docs,
314        // but appear to be an OCI thing. GitHub doesn't support them
315        // yet either, but we expect them to soon (with "immutable actions").
316        if let Some(at_pos) = image.find('@') {
317            let (image, hash) = image.split_at(at_pos);
318
319            let hash = if hash.is_empty() {
320                None
321            } else {
322                Some(&hash[1..])
323            };
324
325            Ok(DockerUses {
326                registry: registry.map(Into::into),
327                image: image.into(),
328                tag: None,
329                hash: hash.map(Into::into),
330            })
331        } else {
332            let (image, tag) = match image.split_once(':') {
333                Some((image, "")) => (image, None),
334                Some((image, tag)) => (image, Some(tag)),
335                _ => (image, None),
336            };
337
338            Ok(DockerUses {
339                registry: registry.map(Into::into),
340                image: image.into(),
341                tag: tag.map(Into::into),
342                hash: None,
343            })
344        }
345    }
346}
347
348/// Deserialize an ordinary step `uses:`.
349pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
350where
351    D: Deserializer<'de>,
352{
353    let uses = <&str>::deserialize(de)?;
354    Uses::from_str(uses).map_err(de::Error::custom)
355}
356
357/// Deserialize a reusable workflow step `uses:`
358pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
359where
360    D: Deserializer<'de>,
361{
362    let uses = step_uses(de)?;
363
364    match uses {
365        Uses::Repository(repo) if repo.git_ref.is_none() => Err(de::Error::custom(
366            "repo action must have `@<ref> in reusable workflow",
367        )),
368        // NOTE: local reusable workflows do not have to be pinned.
369        Uses::Local(_) => Ok(uses),
370        Uses::Repository(_) => Ok(uses),
371        // `docker://` is never valid in reusable workflow uses.
372        Uses::Docker(_) => Err(de::Error::custom(
373            "docker action invalid in reusable workflow `uses`",
374        )),
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use indexmap::IndexMap;
381    use serde::Deserialize;
382
383    use crate::common::{BasePermission, Env, EnvValue, Permission};
384
385    use super::{
386        reusable_step_uses, DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError,
387    };
388
389    #[test]
390    fn test_permissions() {
391        assert_eq!(
392            serde_yaml::from_str::<Permissions>("read-all").unwrap(),
393            Permissions::Base(BasePermission::ReadAll)
394        );
395
396        let perm = "security-events: write";
397        assert_eq!(
398            serde_yaml::from_str::<Permissions>(perm).unwrap(),
399            Permissions::Explicit(IndexMap::from([(
400                "security-events".into(),
401                Permission::Write
402            )]))
403        );
404    }
405
406    #[test]
407    fn test_env_empty_value() {
408        let env = "foo:";
409        assert_eq!(
410            serde_yaml::from_str::<Env>(env).unwrap()["foo"],
411            EnvValue::String("".into())
412        );
413    }
414
415    #[test]
416    fn test_uses_parses() {
417        let vectors = [
418            (
419                // Valid: fully pinned.
420                "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
421                Ok(Uses::Repository(RepositoryUses {
422                    owner: "actions".to_owned(),
423                    repo: "checkout".to_owned(),
424                    subpath: None,
425                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
426                })),
427            ),
428            (
429                // Valid: fully pinned, subpath
430                "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
431                Ok(Uses::Repository(RepositoryUses {
432                    owner: "actions".to_owned(),
433                    repo: "aws".to_owned(),
434                    subpath: Some("ec2".to_owned()),
435                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
436                })),
437            ),
438            (
439                // Valid: fully pinned, complex subpath
440                "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
441                Ok(Uses::Repository(RepositoryUses {
442                    owner: "example".to_owned(),
443                    repo: "foo".to_owned(),
444                    subpath: Some("bar/baz/quux".to_owned()),
445                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
446                })),
447            ),
448            (
449                // Valid: pinned with branch/tag
450                "actions/checkout@v4",
451                Ok(Uses::Repository(RepositoryUses {
452                    owner: "actions".to_owned(),
453                    repo: "checkout".to_owned(),
454                    subpath: None,
455                    git_ref: Some("v4".to_owned()),
456                })),
457            ),
458            (
459                "actions/checkout@abcd",
460                Ok(Uses::Repository(RepositoryUses {
461                    owner: "actions".to_owned(),
462                    repo: "checkout".to_owned(),
463                    subpath: None,
464                    git_ref: Some("abcd".to_owned()),
465                })),
466            ),
467            (
468                // Valid: unpinned
469                "actions/checkout",
470                Ok(Uses::Repository(RepositoryUses {
471                    owner: "actions".to_owned(),
472                    repo: "checkout".to_owned(),
473                    subpath: None,
474                    git_ref: None,
475                })),
476            ),
477            (
478                // Valid: Docker ref, implicit registry
479                "docker://alpine:3.8",
480                Ok(Uses::Docker(DockerUses {
481                    registry: None,
482                    image: "alpine".to_owned(),
483                    tag: Some("3.8".to_owned()),
484                    hash: None,
485                })),
486            ),
487            (
488                // Valid: Docker ref, localhost
489                "docker://localhost/alpine:3.8",
490                Ok(Uses::Docker(DockerUses {
491                    registry: Some("localhost".to_owned()),
492                    image: "alpine".to_owned(),
493                    tag: Some("3.8".to_owned()),
494                    hash: None,
495                })),
496            ),
497            (
498                // Valid: Docker ref, localhost w/ port
499                "docker://localhost:1337/alpine:3.8",
500                Ok(Uses::Docker(DockerUses {
501                    registry: Some("localhost:1337".to_owned()),
502                    image: "alpine".to_owned(),
503                    tag: Some("3.8".to_owned()),
504                    hash: None,
505                })),
506            ),
507            (
508                // Valid: Docker ref, custom registry
509                "docker://ghcr.io/foo/alpine:3.8",
510                Ok(Uses::Docker(DockerUses {
511                    registry: Some("ghcr.io".to_owned()),
512                    image: "foo/alpine".to_owned(),
513                    tag: Some("3.8".to_owned()),
514                    hash: None,
515                })),
516            ),
517            (
518                // Valid: Docker ref, missing tag
519                "docker://ghcr.io/foo/alpine",
520                Ok(Uses::Docker(DockerUses {
521                    registry: Some("ghcr.io".to_owned()),
522                    image: "foo/alpine".to_owned(),
523                    tag: None,
524                    hash: None,
525                })),
526            ),
527            (
528                // Invalid, but allowed: Docker ref, empty tag
529                "docker://ghcr.io/foo/alpine:",
530                Ok(Uses::Docker(DockerUses {
531                    registry: Some("ghcr.io".to_owned()),
532                    image: "foo/alpine".to_owned(),
533                    tag: None,
534                    hash: None,
535                })),
536            ),
537            (
538                // Valid: Docker ref, bare
539                "docker://alpine",
540                Ok(Uses::Docker(DockerUses {
541                    registry: None,
542                    image: "alpine".to_owned(),
543                    tag: None,
544                    hash: None,
545                })),
546            ),
547            (
548                // Valid: Docker ref, hash
549                "docker://alpine@hash",
550                Ok(Uses::Docker(DockerUses {
551                    registry: None,
552                    image: "alpine".to_owned(),
553                    tag: None,
554                    hash: Some("hash".to_owned()),
555                })),
556            ),
557            (
558                // Valid: Local action ref
559                "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
560                Ok(Uses::Local(LocalUses {
561                    path: "./.github/actions/hello-world-action".to_owned(),
562                    git_ref: Some("172239021f7ba04fe7327647b213799853a9eb89".to_owned()),
563                })),
564            ),
565            (
566                // Valid: Local action ref, unpinned
567                "./.github/actions/hello-world-action",
568                Ok(Uses::Local(LocalUses {
569                    path: "./.github/actions/hello-world-action".to_owned(),
570                    git_ref: None,
571                })),
572            ),
573            // Invalid: missing user/repo
574            (
575                "checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
576                Err(UsesError(
577                    "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()
578                )),
579            ),
580        ];
581
582        for (input, expected) in vectors {
583            assert_eq!(input.parse(), expected);
584        }
585    }
586
587    #[test]
588    fn test_uses_deser_reusable() {
589        let vectors = [
590            // Valid, as expected.
591            (
592                "octo-org/this-repo/.github/workflows/workflow-1.yml@\
593                 172239021f7ba04fe7327647b213799853a9eb89",
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("172239021f7ba04fe7327647b213799853a9eb89".to_owned()),
599                })),
600            ),
601            (
602                "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
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("notahash".to_owned()),
608                })),
609            ),
610            (
611                "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
612                Some(Uses::Repository(RepositoryUses {
613                    owner: "octo-org".to_owned(),
614                    repo: "this-repo".to_owned(),
615                    subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
616                    git_ref: Some("abcd".to_owned()),
617                })),
618            ),
619            // Valid: local reusable workflow
620            (
621                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
622                Some(Uses::Local(LocalUses {
623                    path: "./.github/workflows/workflow-1.yml".to_owned(),
624                    git_ref: Some("172239021f7ba04fe7327647b213799853a9eb89".to_owned()),
625                })),
626            ),
627            // Invalid: no ref at all
628            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
629            (".github/workflows/workflow-1.yml", None),
630            // Invalid: missing user/repo
631            (
632                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
633                None,
634            ),
635        ];
636
637        // Dummy type for testing deser of `Uses`.
638        #[derive(Deserialize)]
639        #[serde(transparent)]
640        struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
641
642        for (input, expected) in vectors {
643            assert_eq!(
644                serde_yaml::from_str::<Dummy>(input).map(|d| d.0).ok(),
645                expected
646            );
647        }
648    }
649}