1use 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#[derive(Deserialize, Debug, PartialEq)]
15#[serde(rename_all = "kebab-case", untagged)]
16pub enum Permissions {
17    Base(BasePermission),
19    Explicit(IndexMap<String, Permission>),
24}
25
26impl Default for Permissions {
27    fn default() -> Self {
28        Self::Base(BasePermission::Default)
29    }
30}
31
32#[derive(Deserialize, Debug, Default, PartialEq)]
35#[serde(rename_all = "kebab-case")]
36pub enum BasePermission {
37    #[default]
39    Default,
40    ReadAll,
42    WriteAll,
44}
45
46#[derive(Deserialize, Debug, Default, PartialEq)]
48#[serde(rename_all = "kebab-case")]
49pub enum Permission {
50    Read,
52
53    Write,
55
56    #[default]
58    None,
59}
60
61pub type Env = IndexMap<String, EnvValue>;
63
64#[derive(Deserialize, Serialize, Debug, PartialEq)]
72#[serde(untagged)]
73pub enum EnvValue {
74    #[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#[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#[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#[derive(Deserialize, Serialize, Debug, PartialEq)]
142#[serde(untagged)]
143pub enum If {
144    Bool(bool),
145    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#[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    Local(LocalUses),
180
181    Repository(RepositoryUses),
183
184    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#[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        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#[derive(Debug, PartialEq)]
241pub struct RepositoryUses {
242    pub owner: String,
244    pub repo: String,
246    pub subpath: Option<String>,
248    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        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#[derive(Debug, PartialEq)]
286pub struct DockerUses {
287    pub registry: Option<String>,
289    pub image: String,
291    pub tag: Option<String>,
293    pub hash: Option<String>,
295}
296
297impl DockerUses {
298    fn is_registry(registry: &str) -> bool {
299        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        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
348pub(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
357pub(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        Uses::Local(_) => Ok(uses),
370        Uses::Repository(_) => Ok(uses),
371        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        DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError, reusable_step_uses,
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                "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                "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                "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                "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                "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                "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                "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                "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                "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                "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                "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                "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                "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                "./.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                "./.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            (
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            (
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            (
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            ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
629            (".github/workflows/workflow-1.yml", None),
630            (
632                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
633                None,
634            ),
635        ];
636
637        #[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}