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}
207
208impl FromStr for LocalUses {
209 type Err = UsesError;
210
211 fn from_str(uses: &str) -> Result<Self, Self::Err> {
212 Ok(LocalUses { path: uses.into() })
213 }
214}
215
216#[derive(Debug, PartialEq)]
218pub struct RepositoryUses {
219 pub owner: String,
221 pub repo: String,
223 pub subpath: Option<String>,
225 pub git_ref: Option<String>,
227}
228
229impl FromStr for RepositoryUses {
230 type Err = UsesError;
231
232 fn from_str(uses: &str) -> Result<Self, Self::Err> {
233 let (path, git_ref) = match uses.rsplit_once('@') {
242 Some((path, git_ref)) => (path, Some(git_ref)),
243 None => (uses, None),
244 };
245
246 let components = path.splitn(3, '/').collect::<Vec<_>>();
247 if components.len() < 2 {
248 return Err(UsesError(format!("owner/repo slug is too short: {uses}")));
249 }
250
251 Ok(RepositoryUses {
252 owner: components[0].into(),
253 repo: components[1].into(),
254 subpath: components.get(2).map(ToString::to_string),
255 git_ref: git_ref.map(Into::into),
256 })
257 }
258}
259
260#[derive(Debug, PartialEq)]
262pub struct DockerUses {
263 pub registry: Option<String>,
265 pub image: String,
267 pub tag: Option<String>,
269 pub hash: Option<String>,
271}
272
273impl DockerUses {
274 fn is_registry(registry: &str) -> bool {
275 registry == "localhost" || registry.contains('.') || registry.contains(':')
277 }
278}
279
280impl FromStr for DockerUses {
281 type Err = UsesError;
282
283 fn from_str(uses: &str) -> Result<Self, Self::Err> {
284 let (registry, image) = match uses.split_once('/') {
285 Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
286 _ => (None, uses),
287 };
288
289 if let Some(at_pos) = image.find('@') {
293 let (image, hash) = image.split_at(at_pos);
294
295 let hash = if hash.is_empty() {
296 None
297 } else {
298 Some(&hash[1..])
299 };
300
301 Ok(DockerUses {
302 registry: registry.map(Into::into),
303 image: image.into(),
304 tag: None,
305 hash: hash.map(Into::into),
306 })
307 } else {
308 let (image, tag) = match image.split_once(':') {
309 Some((image, "")) => (image, None),
310 Some((image, tag)) => (image, Some(tag)),
311 _ => (image, None),
312 };
313
314 Ok(DockerUses {
315 registry: registry.map(Into::into),
316 image: image.into(),
317 tag: tag.map(Into::into),
318 hash: None,
319 })
320 }
321 }
322}
323
324pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
326where
327 D: Deserializer<'de>,
328{
329 let uses = <&str>::deserialize(de)?;
330 Uses::from_str(uses).map_err(de::Error::custom)
331}
332
333pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
335where
336 D: Deserializer<'de>,
337{
338 let uses = step_uses(de)?;
339
340 match uses {
341 Uses::Repository(ref repo) => {
342 if repo.git_ref.is_none() {
344 Err(de::Error::custom(
345 "repo action must have `@<ref>` in reusable workflow",
346 ))
347 } else {
348 Ok(uses)
349 }
350 }
351 Uses::Local(ref local) => {
352 if local.path.contains('@') {
357 Err(de::Error::custom(
358 "local reusable workflow reference can't specify `@<ref>`",
359 ))
360 } else {
361 Ok(uses)
362 }
363 }
364 Uses::Docker(_) => Err(de::Error::custom(
366 "docker action invalid in reusable workflow `uses`",
367 )),
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use indexmap::IndexMap;
374 use serde::Deserialize;
375
376 use crate::common::{BasePermission, Env, EnvValue, Permission};
377
378 use super::{
379 DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError, reusable_step_uses,
380 };
381
382 #[test]
383 fn test_permissions() {
384 assert_eq!(
385 serde_yaml::from_str::<Permissions>("read-all").unwrap(),
386 Permissions::Base(BasePermission::ReadAll)
387 );
388
389 let perm = "security-events: write";
390 assert_eq!(
391 serde_yaml::from_str::<Permissions>(perm).unwrap(),
392 Permissions::Explicit(IndexMap::from([(
393 "security-events".into(),
394 Permission::Write
395 )]))
396 );
397 }
398
399 #[test]
400 fn test_env_empty_value() {
401 let env = "foo:";
402 assert_eq!(
403 serde_yaml::from_str::<Env>(env).unwrap()["foo"],
404 EnvValue::String("".into())
405 );
406 }
407
408 #[test]
409 fn test_uses_parses() {
410 let vectors = [
411 (
412 "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
414 Ok(Uses::Repository(RepositoryUses {
415 owner: "actions".to_owned(),
416 repo: "checkout".to_owned(),
417 subpath: None,
418 git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
419 })),
420 ),
421 (
422 "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
424 Ok(Uses::Repository(RepositoryUses {
425 owner: "actions".to_owned(),
426 repo: "aws".to_owned(),
427 subpath: Some("ec2".to_owned()),
428 git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
429 })),
430 ),
431 (
432 "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
434 Ok(Uses::Repository(RepositoryUses {
435 owner: "example".to_owned(),
436 repo: "foo".to_owned(),
437 subpath: Some("bar/baz/quux".to_owned()),
438 git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
439 })),
440 ),
441 (
442 "actions/checkout@v4",
444 Ok(Uses::Repository(RepositoryUses {
445 owner: "actions".to_owned(),
446 repo: "checkout".to_owned(),
447 subpath: None,
448 git_ref: Some("v4".to_owned()),
449 })),
450 ),
451 (
452 "actions/checkout@abcd",
453 Ok(Uses::Repository(RepositoryUses {
454 owner: "actions".to_owned(),
455 repo: "checkout".to_owned(),
456 subpath: None,
457 git_ref: Some("abcd".to_owned()),
458 })),
459 ),
460 (
461 "actions/checkout",
463 Ok(Uses::Repository(RepositoryUses {
464 owner: "actions".to_owned(),
465 repo: "checkout".to_owned(),
466 subpath: None,
467 git_ref: None,
468 })),
469 ),
470 (
471 "docker://alpine:3.8",
473 Ok(Uses::Docker(DockerUses {
474 registry: None,
475 image: "alpine".to_owned(),
476 tag: Some("3.8".to_owned()),
477 hash: None,
478 })),
479 ),
480 (
481 "docker://localhost/alpine:3.8",
483 Ok(Uses::Docker(DockerUses {
484 registry: Some("localhost".to_owned()),
485 image: "alpine".to_owned(),
486 tag: Some("3.8".to_owned()),
487 hash: None,
488 })),
489 ),
490 (
491 "docker://localhost:1337/alpine:3.8",
493 Ok(Uses::Docker(DockerUses {
494 registry: Some("localhost:1337".to_owned()),
495 image: "alpine".to_owned(),
496 tag: Some("3.8".to_owned()),
497 hash: None,
498 })),
499 ),
500 (
501 "docker://ghcr.io/foo/alpine:3.8",
503 Ok(Uses::Docker(DockerUses {
504 registry: Some("ghcr.io".to_owned()),
505 image: "foo/alpine".to_owned(),
506 tag: Some("3.8".to_owned()),
507 hash: None,
508 })),
509 ),
510 (
511 "docker://ghcr.io/foo/alpine",
513 Ok(Uses::Docker(DockerUses {
514 registry: Some("ghcr.io".to_owned()),
515 image: "foo/alpine".to_owned(),
516 tag: None,
517 hash: None,
518 })),
519 ),
520 (
521 "docker://ghcr.io/foo/alpine:",
523 Ok(Uses::Docker(DockerUses {
524 registry: Some("ghcr.io".to_owned()),
525 image: "foo/alpine".to_owned(),
526 tag: None,
527 hash: None,
528 })),
529 ),
530 (
531 "docker://alpine",
533 Ok(Uses::Docker(DockerUses {
534 registry: None,
535 image: "alpine".to_owned(),
536 tag: None,
537 hash: None,
538 })),
539 ),
540 (
541 "docker://alpine@hash",
543 Ok(Uses::Docker(DockerUses {
544 registry: None,
545 image: "alpine".to_owned(),
546 tag: None,
547 hash: Some("hash".to_owned()),
548 })),
549 ),
550 (
551 "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
553 Ok(Uses::Local(LocalUses {
554 path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
555 })),
556 ),
557 (
558 "./.github/actions/hello-world-action",
560 Ok(Uses::Local(LocalUses {
561 path: "./.github/actions/hello-world-action".to_owned(),
562 })),
563 ),
564 (
566 "checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
567 Err(UsesError(
568 "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()
569 )),
570 ),
571 ];
572
573 for (input, expected) in vectors {
574 assert_eq!(input.parse(), expected);
575 }
576 }
577
578 #[test]
579 fn test_uses_deser_reusable() {
580 let vectors = [
581 (
583 "octo-org/this-repo/.github/workflows/workflow-1.yml@\
584 172239021f7ba04fe7327647b213799853a9eb89",
585 Some(Uses::Repository(RepositoryUses {
586 owner: "octo-org".to_owned(),
587 repo: "this-repo".to_owned(),
588 subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
589 git_ref: Some("172239021f7ba04fe7327647b213799853a9eb89".to_owned()),
590 })),
591 ),
592 (
593 "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
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("notahash".to_owned()),
599 })),
600 ),
601 (
602 "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
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("abcd".to_owned()),
608 })),
609 ),
610 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
612 (
614 "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
615 None,
616 ),
617 ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
619 (".github/workflows/workflow-1.yml", None),
620 (
622 "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
623 None,
624 ),
625 ];
626
627 #[derive(Deserialize)]
629 #[serde(transparent)]
630 struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
631
632 for (input, expected) in vectors {
633 assert_eq!(
634 serde_yaml::from_str::<Dummy>(input).map(|d| d.0).ok(),
635 expected
636 );
637 }
638 }
639}