1use std::fmt::{self, Display};
4
5use indexmap::IndexMap;
6use self_cell::self_cell;
7use serde::{Deserialize, Deserializer, Serialize, de};
8
9pub mod expr;
10
11#[derive(Deserialize, Debug, PartialEq)]
13#[serde(rename_all = "kebab-case", untagged)]
14pub enum Permissions {
15 Base(BasePermission),
17 Explicit(IndexMap<String, Permission>),
22}
23
24impl Default for Permissions {
25 fn default() -> Self {
26 Self::Base(BasePermission::Default)
27 }
28}
29
30#[derive(Deserialize, Debug, Default, PartialEq)]
33#[serde(rename_all = "kebab-case")]
34pub enum BasePermission {
35 #[default]
37 Default,
38 ReadAll,
40 WriteAll,
42}
43
44#[derive(Deserialize, Debug, Default, PartialEq)]
46#[serde(rename_all = "kebab-case")]
47pub enum Permission {
48 Read,
50
51 Write,
53
54 #[default]
56 None,
57}
58
59pub type Env = IndexMap<String, EnvValue>;
61
62#[derive(Deserialize, Serialize, Debug, PartialEq)]
70#[serde(untagged)]
71pub enum EnvValue {
72 #[serde(deserialize_with = "null_to_default")]
74 String(String),
75 Number(f64),
76 Boolean(bool),
77}
78
79impl Display for EnvValue {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Self::String(s) => write!(f, "{s}"),
83 Self::Number(n) => write!(f, "{n}"),
84 Self::Boolean(b) => write!(f, "{b}"),
85 }
86 }
87}
88
89impl EnvValue {
90 pub fn csharp_trueish(&self) -> bool {
97 match self {
98 EnvValue::Boolean(true) => true,
99 EnvValue::String(maybe) => maybe.trim().eq_ignore_ascii_case("true"),
100 _ => false,
101 }
102 }
103}
104
105#[derive(Deserialize, Debug, PartialEq)]
110#[serde(untagged)]
111enum SoV<T> {
112 One(T),
113 Many(Vec<T>),
114}
115
116impl<T> From<SoV<T>> for Vec<T> {
117 fn from(val: SoV<T>) -> Vec<T> {
118 match val {
119 SoV::One(v) => vec![v],
120 SoV::Many(vs) => vs,
121 }
122 }
123}
124
125pub(crate) fn scalar_or_vector<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
126where
127 D: Deserializer<'de>,
128 T: Deserialize<'de>,
129{
130 SoV::deserialize(de).map(Into::into)
131}
132
133#[derive(Deserialize, Debug, PartialEq)]
137#[serde(untagged)]
138enum BoS {
139 Bool(bool),
140 String(String),
141}
142
143impl From<BoS> for String {
144 fn from(value: BoS) -> Self {
145 match value {
146 BoS::Bool(b) => b.to_string(),
147 BoS::String(s) => s,
148 }
149 }
150}
151
152#[derive(Deserialize, Serialize, Debug, PartialEq)]
156#[serde(untagged)]
157pub enum If {
158 Bool(bool),
159 Expr(String),
162}
163
164pub(crate) fn bool_is_string<'de, D>(de: D) -> Result<String, D::Error>
165where
166 D: Deserializer<'de>,
167{
168 BoS::deserialize(de).map(Into::into)
169}
170
171fn null_to_default<'de, D, T>(de: D) -> Result<T, D::Error>
172where
173 D: Deserializer<'de>,
174 T: Default + Deserialize<'de>,
175{
176 let key = Option::<T>::deserialize(de)?;
177 Ok(key.unwrap_or_default())
178}
179
180#[derive(Debug, PartialEq)]
182pub struct UsesError(String);
183
184impl fmt::Display for UsesError {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 write!(f, "malformed `uses` ref: {}", self.0)
187 }
188}
189
190#[derive(Debug, PartialEq)]
191pub enum Uses {
192 Local(LocalUses),
194
195 Repository(RepositoryUses),
197
198 Docker(DockerUses),
200}
201
202impl Uses {
203 pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
205 let uses = uses.into();
206
207 if uses.starts_with("./") {
208 Ok(Self::Local(LocalUses::new(uses)))
209 } else if let Some(image) = uses.strip_prefix("docker://") {
210 DockerUses::parse(image).map(Self::Docker)
211 } else {
212 RepositoryUses::parse(uses).map(Self::Repository)
213 }
214 }
215
216 pub fn raw(&self) -> &str {
218 match self {
219 Uses::Local(local) => &local.path,
220 Uses::Repository(repo) => repo.raw(),
221 Uses::Docker(docker) => docker.raw(),
222 }
223 }
224}
225
226#[derive(Debug, PartialEq)]
228#[non_exhaustive]
229pub struct LocalUses {
230 pub path: String,
231}
232
233impl LocalUses {
234 fn new(path: String) -> Self {
235 LocalUses { path }
236 }
237}
238
239#[derive(Debug, PartialEq)]
240struct RepositoryUsesInner<'a> {
241 owner: &'a str,
243 repo: &'a str,
245 slug: &'a str,
247 subpath: Option<&'a str>,
249 git_ref: &'a str,
251}
252
253impl<'a> RepositoryUsesInner<'a> {
254 fn from_str(uses: &'a str) -> Result<Self, UsesError> {
255 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 mut components = path.splitn(3, '/');
263
264 if let Some(owner) = components.next()
265 && let Some(repo) = components.next()
266 {
267 let subpath = components.next();
268
269 let slug = if subpath.is_none() {
270 path
271 } else {
272 &path[..owner.len() + 1 + repo.len()]
273 };
274
275 Ok(RepositoryUsesInner {
276 owner,
277 repo,
278 slug,
279 subpath,
280 git_ref,
281 })
282 } else {
283 Err(UsesError(format!("owner/repo slug is too short: {uses}")))
284 }
285 }
286}
287
288self_cell!(
289 pub struct RepositoryUses {
291 owner: String,
292
293 #[covariant]
294 dependent: RepositoryUsesInner,
295 }
296
297 impl {Debug, PartialEq}
298);
299
300impl RepositoryUses {
301 pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
303 RepositoryUses::try_new(uses.into(), |s| {
304 let inner = RepositoryUsesInner::from_str(s)?;
305 Ok(inner)
306 })
307 }
308
309 pub fn raw(&self) -> &str {
311 self.borrow_owner()
312 }
313
314 pub fn owner(&self) -> &str {
316 self.borrow_dependent().owner
317 }
318
319 pub fn repo(&self) -> &str {
321 self.borrow_dependent().repo
322 }
323
324 pub fn slug(&self) -> &str {
326 self.borrow_dependent().slug
327 }
328
329 pub fn subpath(&self) -> Option<&str> {
331 self.borrow_dependent().subpath
332 }
333
334 pub fn git_ref(&self) -> &str {
336 self.borrow_dependent().git_ref
337 }
338}
339
340#[derive(Debug, PartialEq)]
341#[non_exhaustive]
342pub struct DockerUsesInner<'a> {
343 registry: Option<&'a str>,
345 image: &'a str,
347 tag: Option<&'a str>,
349 hash: Option<&'a str>,
351}
352
353impl<'a> DockerUsesInner<'a> {
354 fn is_registry(registry: &str) -> bool {
355 registry == "localhost" || registry.contains('.') || registry.contains(':')
357 }
358
359 fn from_str(uses: &'a str) -> Result<Self, UsesError> {
360 let (registry, image) = match uses.split_once('/') {
361 Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
362 _ => (None, uses),
363 };
364
365 if let Some(at_pos) = image.find('@') {
369 let (image, hash) = image.split_at(at_pos);
370
371 let hash = if hash.is_empty() {
372 None
373 } else {
374 Some(&hash[1..])
375 };
376
377 Ok(DockerUsesInner {
378 registry,
379 image,
380 tag: None,
381 hash,
382 })
383 } else {
384 let (image, tag) = match image.split_once(':') {
385 Some((image, "")) => (image, None),
386 Some((image, tag)) => (image, Some(tag)),
387 _ => (image, None),
388 };
389
390 Ok(DockerUsesInner {
391 registry,
392 image,
393 tag,
394 hash: None,
395 })
396 }
397 }
398}
399
400self_cell!(
401 pub struct DockerUses {
403 owner: String,
404
405 #[covariant]
406 dependent: DockerUsesInner,
407 }
408
409 impl {Debug, PartialEq}
410);
411
412impl DockerUses {
413 pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
415 DockerUses::try_new(uses.into(), |s| {
416 let inner = DockerUsesInner::from_str(s)?;
417 Ok(inner)
418 })
419 }
420
421 pub fn raw(&self) -> &str {
423 self.borrow_owner()
424 }
425
426 pub fn registry(&self) -> Option<&str> {
428 self.borrow_dependent().registry
429 }
430
431 pub fn image(&self) -> &str {
433 self.borrow_dependent().image
434 }
435
436 pub fn tag(&self) -> Option<&str> {
438 self.borrow_dependent().tag
439 }
440
441 pub fn hash(&self) -> Option<&str> {
443 self.borrow_dependent().hash
444 }
445}
446
447pub(crate) fn custom_error<'de, D>(msg: impl Display) -> D::Error
453where
454 D: Deserializer<'de>,
455{
456 let msg = msg.to_string();
457 tracing::error!(msg);
458 de::Error::custom(msg)
459}
460
461pub(crate) fn docker_uses<'de, D>(de: D) -> Result<DockerUses, D::Error>
463where
464 D: Deserializer<'de>,
465{
466 let uses = <String>::deserialize(de)?;
467 DockerUses::parse(uses).map_err(custom_error::<D>)
468}
469
470pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
472where
473 D: Deserializer<'de>,
474{
475 let uses = <String>::deserialize(de)?;
476 Uses::parse(uses).map_err(custom_error::<D>)
477}
478
479pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
481where
482 D: Deserializer<'de>,
483{
484 let uses = step_uses(de)?;
485
486 match uses {
487 Uses::Repository(_) => Ok(uses),
488 Uses::Local(ref local) => {
489 if local.path.contains('@') {
494 Err(custom_error::<D>(
495 "local reusable workflow reference can't specify `@<ref>`",
496 ))
497 } else {
498 Ok(uses)
499 }
500 }
501 Uses::Docker(_) => Err(custom_error::<D>(
503 "docker action invalid in reusable workflow `uses`",
504 )),
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use indexmap::IndexMap;
511 use serde::Deserialize;
512
513 use crate::common::{BasePermission, Env, EnvValue, Permission};
514
515 use super::{Permissions, Uses, reusable_step_uses};
516
517 #[test]
518 fn test_permissions() {
519 assert_eq!(
520 serde_yaml::from_str::<Permissions>("read-all").unwrap(),
521 Permissions::Base(BasePermission::ReadAll)
522 );
523
524 let perm = "security-events: write";
525 assert_eq!(
526 serde_yaml::from_str::<Permissions>(perm).unwrap(),
527 Permissions::Explicit(IndexMap::from([(
528 "security-events".into(),
529 Permission::Write
530 )]))
531 );
532 }
533
534 #[test]
535 fn test_env_empty_value() {
536 let env = "foo:";
537 assert_eq!(
538 serde_yaml::from_str::<Env>(env).unwrap()["foo"],
539 EnvValue::String("".into())
540 );
541 }
542
543 #[test]
544 fn test_env_value_csharp_trueish() {
545 let vectors = [
546 (EnvValue::Boolean(true), true),
547 (EnvValue::Boolean(false), false),
548 (EnvValue::String("true".to_string()), true),
549 (EnvValue::String("TRUE".to_string()), true),
550 (EnvValue::String("TrUe".to_string()), true),
551 (EnvValue::String(" true ".to_string()), true),
552 (EnvValue::String(" \n\r\t True\n\n".to_string()), true),
553 (EnvValue::String("false".to_string()), false),
554 (EnvValue::String("1".to_string()), false),
555 (EnvValue::String("yes".to_string()), false),
556 (EnvValue::String("on".to_string()), false),
557 (EnvValue::String("random".to_string()), false),
558 (EnvValue::Number(1.0), false),
559 (EnvValue::Number(0.0), false),
560 (EnvValue::Number(666.0), false),
561 ];
562
563 for (val, expected) in vectors {
564 assert_eq!(val.csharp_trueish(), expected, "failed for {val:?}");
565 }
566 }
567
568 #[test]
569 fn test_uses_parses() {
570 insta::assert_debug_snapshot!(
572 Uses::parse("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
573 @r#"
574 Repository(
575 RepositoryUses {
576 owner: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
577 dependent: RepositoryUsesInner {
578 owner: "actions",
579 repo: "checkout",
580 slug: "actions/checkout",
581 subpath: None,
582 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
583 },
584 },
585 )
586 "#,
587 );
588
589 insta::assert_debug_snapshot!(
591 Uses::parse("actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
592 @r#"
593 Repository(
594 RepositoryUses {
595 owner: "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
596 dependent: RepositoryUsesInner {
597 owner: "actions",
598 repo: "aws",
599 slug: "actions/aws",
600 subpath: Some(
601 "ec2",
602 ),
603 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
604 },
605 },
606 )
607 "#
608 );
609
610 insta::assert_debug_snapshot!(
612 Uses::parse("example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
613 @r#"
614 Repository(
615 RepositoryUses {
616 owner: "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
617 dependent: RepositoryUsesInner {
618 owner: "example",
619 repo: "foo",
620 slug: "example/foo",
621 subpath: Some(
622 "bar/baz/quux",
623 ),
624 git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
625 },
626 },
627 )
628 "#
629 );
630
631 insta::assert_debug_snapshot!(
633 Uses::parse("actions/checkout@v4").unwrap(),
634 @r#"
635 Repository(
636 RepositoryUses {
637 owner: "actions/checkout@v4",
638 dependent: RepositoryUsesInner {
639 owner: "actions",
640 repo: "checkout",
641 slug: "actions/checkout",
642 subpath: None,
643 git_ref: "v4",
644 },
645 },
646 )
647 "#
648 );
649
650 insta::assert_debug_snapshot!(
651 Uses::parse("actions/checkout@abcd").unwrap(),
652 @r#"
653 Repository(
654 RepositoryUses {
655 owner: "actions/checkout@abcd",
656 dependent: RepositoryUsesInner {
657 owner: "actions",
658 repo: "checkout",
659 slug: "actions/checkout",
660 subpath: None,
661 git_ref: "abcd",
662 },
663 },
664 )
665 "#
666 );
667
668 insta::assert_debug_snapshot!(
670 Uses::parse("actions/checkout").unwrap_err(),
671 @r#"
672 UsesError(
673 "missing `@<ref>` in actions/checkout",
674 )
675 "#
676 );
677
678 insta::assert_debug_snapshot!(
680 Uses::parse("docker://alpine:3.8").unwrap(),
681 @r#"
682 Docker(
683 DockerUses {
684 owner: "alpine:3.8",
685 dependent: DockerUsesInner {
686 registry: None,
687 image: "alpine",
688 tag: Some(
689 "3.8",
690 ),
691 hash: None,
692 },
693 },
694 )
695 "#
696 );
697
698 insta::assert_debug_snapshot!(
700 Uses::parse("docker://localhost/alpine:3.8").unwrap(),
701 @r#"
702 Docker(
703 DockerUses {
704 owner: "localhost/alpine:3.8",
705 dependent: DockerUsesInner {
706 registry: Some(
707 "localhost",
708 ),
709 image: "alpine",
710 tag: Some(
711 "3.8",
712 ),
713 hash: None,
714 },
715 },
716 )
717 "#
718 );
719
720 insta::assert_debug_snapshot!(
722 Uses::parse("docker://localhost:1337/alpine:3.8").unwrap(),
723 @r#"
724 Docker(
725 DockerUses {
726 owner: "localhost:1337/alpine:3.8",
727 dependent: DockerUsesInner {
728 registry: Some(
729 "localhost:1337",
730 ),
731 image: "alpine",
732 tag: Some(
733 "3.8",
734 ),
735 hash: None,
736 },
737 },
738 )
739 "#
740 );
741
742 insta::assert_debug_snapshot!(
744 Uses::parse("docker://ghcr.io/foo/alpine:3.8").unwrap(),
745 @r#"
746 Docker(
747 DockerUses {
748 owner: "ghcr.io/foo/alpine:3.8",
749 dependent: DockerUsesInner {
750 registry: Some(
751 "ghcr.io",
752 ),
753 image: "foo/alpine",
754 tag: Some(
755 "3.8",
756 ),
757 hash: None,
758 },
759 },
760 )
761 "#
762 );
763
764 insta::assert_debug_snapshot!(
766 Uses::parse("docker://ghcr.io/foo/alpine").unwrap(),
767 @r#"
768 Docker(
769 DockerUses {
770 owner: "ghcr.io/foo/alpine",
771 dependent: DockerUsesInner {
772 registry: Some(
773 "ghcr.io",
774 ),
775 image: "foo/alpine",
776 tag: None,
777 hash: None,
778 },
779 },
780 )
781 "#
782 );
783
784 insta::assert_debug_snapshot!(
786 Uses::parse("docker://ghcr.io/foo/alpine:").unwrap(),
787 @r#"
788 Docker(
789 DockerUses {
790 owner: "ghcr.io/foo/alpine:",
791 dependent: DockerUsesInner {
792 registry: Some(
793 "ghcr.io",
794 ),
795 image: "foo/alpine",
796 tag: None,
797 hash: None,
798 },
799 },
800 )
801 "#
802 );
803
804 insta::assert_debug_snapshot!(
806 Uses::parse("docker://alpine").unwrap(),
807 @r#"
808 Docker(
809 DockerUses {
810 owner: "alpine",
811 dependent: DockerUsesInner {
812 registry: None,
813 image: "alpine",
814 tag: None,
815 hash: None,
816 },
817 },
818 )
819 "#
820 );
821
822 insta::assert_debug_snapshot!(
824 Uses::parse("docker://alpine@hash").unwrap(),
825 @r#"
826 Docker(
827 DockerUses {
828 owner: "alpine@hash",
829 dependent: DockerUsesInner {
830 registry: None,
831 image: "alpine",
832 tag: None,
833 hash: Some(
834 "hash",
835 ),
836 },
837 },
838 )
839 "#
840 );
841
842 insta::assert_debug_snapshot!(
844 Uses::parse("./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89").unwrap(),
845 @r#"
846 Local(
847 LocalUses {
848 path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
849 },
850 )
851 "#
852 );
853
854 insta::assert_debug_snapshot!(
856 Uses::parse("./.github/actions/hello-world-action").unwrap(),
857 @r#"
858 Local(
859 LocalUses {
860 path: "./.github/actions/hello-world-action",
861 },
862 )
863 "#
864 );
865
866 insta::assert_debug_snapshot!(
868 Uses::parse("checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap_err(),
869 @r#"
870 UsesError(
871 "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
872 )
873 "#
874 );
875 }
876
877 #[test]
878 fn test_uses_deser_reusable() {
879 #[derive(Deserialize)]
881 #[serde(transparent)]
882 struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
883
884 insta::assert_debug_snapshot!(
885 serde_yaml::from_str::<Dummy>(
886 "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
887 )
888 .map(|d| d.0)
889 .unwrap(),
890 @r#"
891 Repository(
892 RepositoryUses {
893 owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
894 dependent: RepositoryUsesInner {
895 owner: "octo-org",
896 repo: "this-repo",
897 slug: "octo-org/this-repo",
898 subpath: Some(
899 ".github/workflows/workflow-1.yml",
900 ),
901 git_ref: "172239021f7ba04fe7327647b213799853a9eb89",
902 },
903 },
904 )
905 "#
906 );
907
908 insta::assert_debug_snapshot!(
909 serde_yaml::from_str::<Dummy>(
910 "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash"
911 ).map(|d| d.0).unwrap(),
912 @r#"
913 Repository(
914 RepositoryUses {
915 owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
916 dependent: RepositoryUsesInner {
917 owner: "octo-org",
918 repo: "this-repo",
919 slug: "octo-org/this-repo",
920 subpath: Some(
921 ".github/workflows/workflow-1.yml",
922 ),
923 git_ref: "notahash",
924 },
925 },
926 )
927 "#
928 );
929
930 insta::assert_debug_snapshot!(
931 serde_yaml::from_str::<Dummy>(
932 "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd"
933 ).map(|d| d.0).unwrap(),
934 @r#"
935 Repository(
936 RepositoryUses {
937 owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
938 dependent: RepositoryUsesInner {
939 owner: "octo-org",
940 repo: "this-repo",
941 slug: "octo-org/this-repo",
942 subpath: Some(
943 ".github/workflows/workflow-1.yml",
944 ),
945 git_ref: "abcd",
946 },
947 },
948 )
949 "#
950 );
951
952 insta::assert_debug_snapshot!(
954 serde_yaml::from_str::<Dummy>(
955 "octo-org/this-repo/.github/workflows/workflow-1.yml"
956 ).map(|d| d.0).unwrap_err(),
957 @r#"Error("malformed `uses` ref: missing `@<ref>` in octo-org/this-repo/.github/workflows/workflow-1.yml")"#
958 );
959
960 insta::assert_debug_snapshot!(
962 serde_yaml::from_str::<Dummy>(
963 "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
964 ).map(|d| d.0).unwrap_err(),
965 @r#"Error("local reusable workflow reference can't specify `@<ref>`")"#
966 );
967
968 insta::assert_debug_snapshot!(
970 serde_yaml::from_str::<Dummy>(
971 ".github/workflows/workflow-1.yml"
972 ).map(|d| d.0).unwrap_err(),
973 @r#"Error("malformed `uses` ref: missing `@<ref>` in .github/workflows/workflow-1.yml")"#
974 );
975
976 insta::assert_debug_snapshot!(
978 serde_yaml::from_str::<Dummy>(
979 "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
980 ).map(|d| d.0).unwrap_err(),
981 @r#"Error("malformed `uses` ref: owner/repo slug is too short: workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89")"#
982 );
983 }
984}