1use 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#[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, Default, Debug, PartialEq)]
35#[serde(rename_all = "kebab-case")]
36pub enum BasePermission {
37 #[default]
39 Default,
40 ReadAll,
42 WriteAll,
44}
45
46#[derive(Deserialize, Default, Debug, 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(Serialize, Deserialize, 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(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#[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#[derive(Debug, Deserialize, PartialEq, Serialize)]
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 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 "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}