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: 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, git_ref),
259 None => return Err(UsesError(format!("missing `@<ref>` in {uses}"))),
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.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(_) => Ok(uses),
372 Uses::Local(ref local) => {
373 if local.path.contains('@') {
378 Err(custom_error::<D>(
379 "local reusable workflow reference can't specify `@<ref>`",
380 ))
381 } else {
382 Ok(uses)
383 }
384 }
385 Uses::Docker(_) => Err(custom_error::<D>(
387 "docker action invalid in reusable workflow `uses`",
388 )),
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use indexmap::IndexMap;
395 use serde::Deserialize;
396
397 use crate::common::{BasePermission, Env, EnvValue, Permission};
398
399 use super::{
400 DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError, reusable_step_uses,
401 };
402
403 #[test]
404 fn test_permissions() {
405 assert_eq!(
406 serde_yaml::from_str::<Permissions>("read-all").unwrap(),
407 Permissions::Base(BasePermission::ReadAll)
408 );
409
410 let perm = "security-events: write";
411 assert_eq!(
412 serde_yaml::from_str::<Permissions>(perm).unwrap(),
413 Permissions::Explicit(IndexMap::from([(
414 "security-events".into(),
415 Permission::Write
416 )]))
417 );
418 }
419
420 #[test]
421 fn test_env_empty_value() {
422 let env = "foo:";
423 assert_eq!(
424 serde_yaml::from_str::<Env>(env).unwrap()["foo"],
425 EnvValue::String("".into())
426 );
427 }
428
429 #[test]
430 fn test_env_value_csharp_trueish() {
431 let vectors = [
432 (EnvValue::Boolean(true), true),
433 (EnvValue::Boolean(false), false),
434 (EnvValue::String("true".to_string()), true),
435 (EnvValue::String("TRUE".to_string()), true),
436 (EnvValue::String("TrUe".to_string()), true),
437 (EnvValue::String(" true ".to_string()), true),
438 (EnvValue::String(" \n\r\t True\n\n".to_string()), true),
439 (EnvValue::String("false".to_string()), false),
440 (EnvValue::String("1".to_string()), false),
441 (EnvValue::String("yes".to_string()), false),
442 (EnvValue::String("on".to_string()), false),
443 (EnvValue::String("random".to_string()), false),
444 (EnvValue::Number(1.0), false),
445 (EnvValue::Number(0.0), false),
446 (EnvValue::Number(666.0), false),
447 ];
448
449 for (val, expected) in vectors {
450 assert_eq!(val.csharp_trueish(), expected, "failed for {val:?}");
451 }
452 }
453
454 #[test]
455 fn test_uses_parses() {
456 let vectors = [
457 (
458 "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
460 Ok(Uses::Repository(RepositoryUses {
461 owner: "actions".to_owned(),
462 repo: "checkout".to_owned(),
463 subpath: None,
464 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned(),
465 })),
466 ),
467 (
468 "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
470 Ok(Uses::Repository(RepositoryUses {
471 owner: "actions".to_owned(),
472 repo: "aws".to_owned(),
473 subpath: Some("ec2".to_owned()),
474 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned(),
475 })),
476 ),
477 (
478 "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
480 Ok(Uses::Repository(RepositoryUses {
481 owner: "example".to_owned(),
482 repo: "foo".to_owned(),
483 subpath: Some("bar/baz/quux".to_owned()),
484 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned(),
485 })),
486 ),
487 (
488 "actions/checkout@v4",
490 Ok(Uses::Repository(RepositoryUses {
491 owner: "actions".to_owned(),
492 repo: "checkout".to_owned(),
493 subpath: None,
494 git_ref: "v4".to_owned(),
495 })),
496 ),
497 (
498 "actions/checkout@abcd",
499 Ok(Uses::Repository(RepositoryUses {
500 owner: "actions".to_owned(),
501 repo: "checkout".to_owned(),
502 subpath: None,
503 git_ref: "abcd".to_owned(),
504 })),
505 ),
506 (
507 "actions/checkout",
509 Err(UsesError(
510 "missing `@<ref>` in actions/checkout".to_owned(),
511 )),
512 ),
513 (
514 "docker://alpine:3.8",
516 Ok(Uses::Docker(DockerUses {
517 registry: None,
518 image: "alpine".to_owned(),
519 tag: Some("3.8".to_owned()),
520 hash: None,
521 })),
522 ),
523 (
524 "docker://localhost/alpine:3.8",
526 Ok(Uses::Docker(DockerUses {
527 registry: Some("localhost".to_owned()),
528 image: "alpine".to_owned(),
529 tag: Some("3.8".to_owned()),
530 hash: None,
531 })),
532 ),
533 (
534 "docker://localhost:1337/alpine:3.8",
536 Ok(Uses::Docker(DockerUses {
537 registry: Some("localhost:1337".to_owned()),
538 image: "alpine".to_owned(),
539 tag: Some("3.8".to_owned()),
540 hash: None,
541 })),
542 ),
543 (
544 "docker://ghcr.io/foo/alpine:3.8",
546 Ok(Uses::Docker(DockerUses {
547 registry: Some("ghcr.io".to_owned()),
548 image: "foo/alpine".to_owned(),
549 tag: Some("3.8".to_owned()),
550 hash: None,
551 })),
552 ),
553 (
554 "docker://ghcr.io/foo/alpine",
556 Ok(Uses::Docker(DockerUses {
557 registry: Some("ghcr.io".to_owned()),
558 image: "foo/alpine".to_owned(),
559 tag: None,
560 hash: None,
561 })),
562 ),
563 (
564 "docker://ghcr.io/foo/alpine:",
566 Ok(Uses::Docker(DockerUses {
567 registry: Some("ghcr.io".to_owned()),
568 image: "foo/alpine".to_owned(),
569 tag: None,
570 hash: None,
571 })),
572 ),
573 (
574 "docker://alpine",
576 Ok(Uses::Docker(DockerUses {
577 registry: None,
578 image: "alpine".to_owned(),
579 tag: None,
580 hash: None,
581 })),
582 ),
583 (
584 "docker://alpine@hash",
586 Ok(Uses::Docker(DockerUses {
587 registry: None,
588 image: "alpine".to_owned(),
589 tag: None,
590 hash: Some("hash".to_owned()),
591 })),
592 ),
593 (
594 "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
596 Ok(Uses::Local(LocalUses {
597 path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
598 })),
599 ),
600 (
601 "./.github/actions/hello-world-action",
603 Ok(Uses::Local(LocalUses {
604 path: "./.github/actions/hello-world-action".to_owned(),
605 })),
606 ),
607 (
609 "checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
610 Err(UsesError(
611 "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()
612 )),
613 ),
614 ];
615
616 for (input, expected) in vectors {
617 assert_eq!(input.parse(), expected);
618 }
619 }
620
621 #[test]
622 fn test_uses_deser_reusable() {
623 let vectors = [
624 (
626 "octo-org/this-repo/.github/workflows/workflow-1.yml@\
627 172239021f7ba04fe7327647b213799853a9eb89",
628 Some(Uses::Repository(RepositoryUses {
629 owner: "octo-org".to_owned(),
630 repo: "this-repo".to_owned(),
631 subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
632 git_ref: "172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
633 })),
634 ),
635 (
636 "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
637 Some(Uses::Repository(RepositoryUses {
638 owner: "octo-org".to_owned(),
639 repo: "this-repo".to_owned(),
640 subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
641 git_ref: "notahash".to_owned(),
642 })),
643 ),
644 (
645 "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
646 Some(Uses::Repository(RepositoryUses {
647 owner: "octo-org".to_owned(),
648 repo: "this-repo".to_owned(),
649 subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
650 git_ref: "abcd".to_owned(),
651 })),
652 ),
653 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
655 (
657 "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
658 None,
659 ),
660 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
662 (".github/workflows/workflow-1.yml", None),
663 (
665 "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
666 None,
667 ),
668 ];
669
670 #[derive(Deserialize)]
672 #[serde(transparent)]
673 struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
674
675 for (input, expected) in vectors {
676 assert_eq!(
677 serde_yaml::from_str::<Dummy>(input).map(|d| d.0).ok(),
678 expected
679 );
680 }
681 }
682}