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
91impl EnvValue {
92 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#[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#[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#[derive(Deserialize, Serialize, Debug, PartialEq)]
158#[serde(untagged)]
159pub enum If {
160 Bool(bool),
161 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#[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 Local(LocalUses),
196
197 Repository(RepositoryUses),
199
200 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#[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#[derive(Debug, PartialEq)]
234pub struct RepositoryUses {
235 pub owner: String,
237 pub repo: String,
239 pub subpath: Option<String>,
241 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 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#[derive(Debug, PartialEq)]
278pub struct DockerUses {
279 pub registry: Option<String>,
281 pub image: String,
283 pub tag: Option<String>,
285 pub hash: Option<String>,
287}
288
289impl DockerUses {
290 fn is_registry(registry: &str) -> bool {
291 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 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
340pub(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
349pub(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 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 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 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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "./.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 "./.github/actions/hello-world-action",
601 Ok(Uses::Local(LocalUses {
602 path: "./.github/actions/hello-world-action".to_owned(),
603 })),
604 ),
605 (
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 (
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 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
653 (
655 "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
656 None,
657 ),
658 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
660 (".github/workflows/workflow-1.yml", None),
661 (
663 "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
664 None,
665 ),
666 ];
667
668 #[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}