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 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
354pub(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
363pub(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 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 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 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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "./.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 "./.github/actions/hello-world-action",
615 Ok(Uses::Local(LocalUses {
616 path: "./.github/actions/hello-world-action".to_owned(),
617 })),
618 ),
619 (
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 (
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 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
667 (
669 "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
670 None,
671 ),
672 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
674 (".github/workflows/workflow-1.yml", None),
675 (
677 "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
678 None,
679 ),
680 ];
681
682 #[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}