dofigen_lib/
from_str.rs

1use crate::deserialize::*;
2use crate::dofigen_struct::*;
3use regex::Regex;
4use serde::de::{Error as DeError, value::Error};
5use std::str::FromStr;
6use struct_patch::Patch;
7use url::Url;
8
9const GIT_HTTP_REPO_REGEX: &str = "https?://(?:.+@)?[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\\.git(?:#[a-zA-Z0-9_/.-]*(?::[a-zA-Z0-9_/-]+)?)?";
10const GIT_SSH_REPO_REGEX: &str = "[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+:[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:#[a-zA-Z0-9_/.-]+)?(?::[a-zA-Z0-9_/-]+)?";
11const URL_REGEX: &str = "https?://(?:.+@)?[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+(/[a-zA-Z0-9_.-]+)*";
12
13macro_rules! impl_parsable_patch {
14    ($struct:ty, $patch:ty, $param:ident, $expression:expr_2021) => {
15        impl Patch<ParsableStruct<$patch>> for $struct {
16            fn apply(&mut self, patch: ParsableStruct<$patch>) {
17                self.apply(patch.0);
18            }
19
20            fn into_patch(self) -> ParsableStruct<$patch> {
21                ParsableStruct(self.into_patch())
22            }
23
24            fn into_patch_by_diff(self, previous_struct: Self) -> ParsableStruct<$patch> {
25                ParsableStruct(self.into_patch_by_diff(previous_struct))
26            }
27
28            fn new_empty_patch() -> ParsableStruct<$patch> {
29                ParsableStruct(Self::new_empty_patch())
30            }
31        }
32
33        impl From<ParsableStruct<$patch>> for $struct {
34            fn from(value: ParsableStruct<$patch>) -> Self {
35                value.0.into()
36            }
37        }
38
39        impl FromStr for $patch {
40            type Err = Error;
41
42            fn from_str($param: &str) -> std::result::Result<Self, Self::Err> {
43                $expression
44            }
45        }
46    };
47}
48
49impl_parsable_patch!(ImageName, ImageNamePatch, s, {
50    let regex = Regex::new(r"^(?:(?<host>[^:\/.]+(?:\.[^:\/.]+)+)(?::(?<port>\d{1,5}))?\/)?(?<path>[a-zA-Z0-9-]{1,63}(?:\/[a-zA-Z0-9-]{1,63})*)(?:(?<version_char>[:@])(?<version_value>[a-zA-Z0-9_.:-]{1,128}))?$").unwrap();
51    let Some(captures) = regex.captures(s) else {
52        return Err(Error::custom("Not matching image name pattern"));
53    };
54    Ok(ImageNamePatch {
55        host: Some(captures.name("host").map(|m| m.as_str().into())),
56        port: Some(captures.name("port").map(|m| m.as_str().parse().unwrap())),
57        path: Some(captures["path"].into()),
58        version: Some(
59            match (
60                captures.name("version_char").map(|m| m.as_str()),
61                captures.name("version_value"),
62            ) {
63                (Some(":"), Some(value)) => Some(ImageVersion::Tag(value.as_str().into())),
64                (Some("@"), Some(value)) => Some(ImageVersion::Digest(value.as_str().into())),
65                (None, None) => None,
66                _ => return Err(Error::custom("Invalid version format")),
67            },
68        ),
69        platform: Some(None),
70    })
71});
72
73impl_parsable_patch!(CopyResource, CopyResourcePatch, s, {
74    let parts_regex = format!(
75        r"^(?:(?<git>(?:{git_http}|{git_ssh}))|(?<url>{url})|\S+)(?: (?:{git_http}|{git_ssh}|{url}|\S+))*(?: \S+)?$",
76        git_http = GIT_HTTP_REPO_REGEX,
77        git_ssh = GIT_SSH_REPO_REGEX,
78        url = URL_REGEX
79    );
80    let regex = Regex::new(parts_regex.as_str()).unwrap();
81    let Some(captures) = regex.captures(s) else {
82        return Err(Error::custom("Not matching copy resources pattern"));
83    };
84    if captures.name("git").is_some() {
85        return Ok(CopyResourcePatch::AddGitRepo(s.parse().unwrap()));
86    }
87    if captures.name("url").is_some() {
88        return Ok(CopyResourcePatch::Add(s.parse().unwrap()));
89    }
90    Ok(CopyResourcePatch::Copy(s.parse().unwrap()))
91});
92
93impl_parsable_patch!(Copy, CopyPatch, s, {
94    let mut parts: Vec<String> = s.split(" ").map(|s| s.into()).collect();
95    let target = if parts.len() > 1 { parts.pop() } else { None };
96    Ok(Self {
97        paths: Some(parts.into_patch()),
98        options: Some(CopyOptionsPatch {
99            target: Some(target),
100            chmod: Some(None),
101            chown: Some(None),
102            link: Some(None),
103        }),
104        from: Some(FromContextPatch::default()),
105        exclude: Some(VecPatch::default()),
106        parents: Some(None),
107    })
108});
109
110impl_parsable_patch!(AddGitRepo, AddGitRepoPatch, s, {
111    let (repo, target) = match &s.split(" ").collect::<Vec<&str>>().as_slice() {
112        &[repo, target] => (repo.to_string(), Some(target.to_string())),
113        &[repo] => (repo.to_string(), None),
114        _ => return Err(Error::custom("Invalid add git repo format")),
115    };
116    Ok(Self {
117        repo: Some(repo),
118        options: Some(CopyOptionsPatch {
119            target: Some(target),
120            chmod: Some(None),
121            chown: Some(None),
122            link: Some(None),
123        }),
124        keep_git_dir: Some(None),
125        checksum: Some(None),
126        exclude: Some(VecPatch::default()),
127    })
128});
129
130impl_parsable_patch!(Add, AddPatch, s, {
131    let mut parts: Vec<_> = s.split(" ").collect();
132    let target = if parts.len() > 1 {
133        parts.pop().map(str::to_string)
134    } else {
135        None
136    };
137    let parts: Vec<_> = parts
138        .iter()
139        .map(|s| {
140            Url::parse(s)
141                .map(Resource::Url)
142                .ok()
143                .unwrap_or(Resource::File(s.into()))
144        })
145        .collect();
146    Ok(Self {
147        files: Some(parts.into_patch()),
148        options: Some(CopyOptionsPatch {
149            target: Some(target),
150            chmod: Some(None),
151            chown: Some(None),
152            link: Some(None),
153        }),
154        checksum: Some(None),
155        unpack: Some(None),
156    })
157});
158
159impl_parsable_patch!(User, UserPatch, s, {
160    let regex = Regex::new(r"^(?<user>[a-zA-Z0-9_-]+)(?::(?<group>[a-zA-Z0-9_-]+))?$").unwrap();
161    let Some(captures) = regex.captures(s) else {
162        return Err(Error::custom("Not matching chown pattern"));
163    };
164    Ok(Self {
165        user: Some(captures["user"].into()),
166        group: Some(captures.name("group").map(|m| m.as_str().into())),
167    })
168});
169
170impl_parsable_patch!(Port, PortPatch, s, {
171    let regex = Regex::new(r"^(?<port>\d+)(?:/(?<protocol>(tcp|udp)))?$").unwrap();
172    let Some(captures) = regex.captures(s) else {
173        return Err(Error::custom("Not matching chown pattern"));
174    };
175    Ok(Self {
176        port: Some(captures["port"].parse().map_err(Error::custom)?),
177        protocol: Some(captures.name("protocol").map(|m| match m.as_str() {
178            "tcp" => PortProtocol::Tcp,
179            "udp" => PortProtocol::Udp,
180            _ => unreachable!(),
181        })),
182    })
183});
184
185impl_parsable_patch!(Bind, BindPatch, s, {
186    let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
187    let Some(captures) = regex.captures(s) else {
188        return Err(Error::custom("Not matching bind pattern"));
189    };
190
191    let target = Some(captures["target"].to_string());
192    Ok(Self {
193        source: Some(
194            captures
195                .name("source")
196                .map(|m| m.as_str().into())
197                .or(target.clone()),
198        ),
199        target,
200        from: captures.name("from").map(|m| {
201            let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
202            let from = m.as_str();
203            match from_type {
204                "image" => {
205                    FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
206                }
207                "builder" => FromContextPatch::FromBuilder(from.into()),
208                "context" => FromContextPatch::FromContext(Some(from.into())),
209                _ => unreachable!(),
210            }
211        }),
212
213        readwrite: Some(None),
214    })
215});
216
217impl_parsable_patch!(Cache, CachePatch, s, {
218    let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
219    let Some(captures) = regex.captures(s) else {
220        return Err(Error::custom("Not matching bind pattern"));
221    };
222
223    let target = Some(captures["target"].to_string());
224    Ok(Self {
225        source: Some(captures.name("source").map(|m| m.as_str().into())),
226        target,
227        from: captures.name("from").map(|m| {
228            let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
229            let from = m.as_str();
230            match from_type {
231                "image" => {
232                    FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
233                }
234                "builder" => FromContextPatch::FromBuilder(from.into()),
235                "context" => FromContextPatch::FromContext(Some(from.into())),
236                _ => unreachable!(),
237            }
238        }),
239        chmod: Some(None),
240        chown: Some(None),
241        id: Some(None),
242        readonly: Some(None),
243        sharing: Some(None),
244    })
245});
246
247impl_parsable_patch!(TmpFs, TmpFsPatch, s, {
248    Ok(Self {
249        target: Some(s.into()),
250        size: Some(None),
251    })
252});
253
254#[cfg(test)]
255mod test_from_str {
256    use super::*;
257    use pretty_assertions_sorted::assert_eq_sorted;
258
259    mod image_name {
260        use pretty_assertions_sorted::assert_eq_sorted;
261
262        use super::*;
263
264        #[test]
265        fn simple() {
266            let input = "example/image";
267            let result = ImageNamePatch::from_str(input).unwrap();
268            assert_eq_sorted!(result.host, Some(None));
269            assert_eq_sorted!(result.path, Some("example/image".into()));
270            assert_eq_sorted!(result.port, Some(None));
271            assert_eq_sorted!(result.version, Some(None));
272        }
273
274        #[test]
275        fn with_host() {
276            let input = "docker.io/example/image";
277            let result = ImageNamePatch::from_str(input).unwrap();
278            assert_eq_sorted!(result.host, Some(Some("docker.io".into())));
279            assert_eq_sorted!(result.path, Some("example/image".into()));
280            assert_eq_sorted!(result.port, Some(None));
281            assert_eq_sorted!(result.version, Some(None));
282        }
283
284        #[test]
285        fn with_tag() {
286            let input = "example/image:tag";
287            let result = ImageNamePatch::from_str(input).unwrap();
288            assert_eq_sorted!(result.host, Some(None));
289            assert_eq_sorted!(result.path, Some("example/image".into()));
290            assert_eq_sorted!(result.port, Some(None));
291            assert_eq_sorted!(result.version, Some(Some(ImageVersion::Tag("tag".into()))));
292        }
293
294        #[test]
295        fn with_digest() {
296            let input = "example/image@sha256:my-sha";
297            let result = ImageNamePatch::from_str(input).unwrap();
298            assert_eq_sorted!(result.host, Some(None));
299            assert_eq_sorted!(result.path, Some("example/image".into()));
300            assert_eq_sorted!(result.port, Some(None));
301            assert_eq_sorted!(
302                result.version,
303                Some(Some(ImageVersion::Digest("sha256:my-sha".into())))
304            );
305        }
306
307        #[test]
308        fn full() {
309            let input = "registry.my-host.io:5001/example/image:stable";
310            let result = ImageNamePatch::from_str(input).unwrap();
311            assert_eq_sorted!(result.host, Some(Some("registry.my-host.io".into())));
312            assert_eq_sorted!(result.path, Some("example/image".into()));
313            assert_eq_sorted!(result.port, Some(Some(5001)));
314            assert_eq_sorted!(
315                result.version,
316                Some(Some(ImageVersion::Tag("stable".into())))
317            );
318        }
319
320        #[test]
321        fn with_arg() {
322            let input = "example/image:${VERSION}";
323            let result = ImageNamePatch::from_str(input);
324            assert!(result.is_err());
325        }
326    }
327
328    mod copy {
329
330        use super::*;
331
332        #[test]
333        fn simple() {
334            let result = CopyPatch::from_str("src").unwrap();
335            assert_eq_sorted!(
336                result,
337                CopyPatch {
338                    paths: Some(vec!["src".to_string()].into_patch()),
339                    options: Some(CopyOptionsPatch {
340                        target: Some(None),
341                        chown: Some(None),
342                        chmod: Some(None),
343                        link: Some(None),
344                    }),
345                    from: Some(FromContextPatch::default()),
346                    exclude: Some(VecPatch::default()),
347                    parents: Some(None),
348                }
349            );
350        }
351
352        #[test]
353        fn with_target_option() {
354            let result = CopyPatch::from_str("src /app").unwrap();
355            assert_eq_sorted!(
356                result,
357                CopyPatch {
358                    paths: Some(vec!["src".to_string()].into_patch()),
359                    options: Some(CopyOptionsPatch {
360                        target: Some(Some("/app".into())),
361                        chown: Some(None),
362                        chmod: Some(None),
363                        link: Some(None),
364                    }),
365                    from: Some(FromContextPatch::default()),
366                    exclude: Some(VecPatch::default()),
367                    parents: Some(None),
368                }
369            );
370        }
371
372        #[test]
373        fn with_multiple_sources_and_target() {
374            let result = CopyPatch::from_str("src1 src2 /app").unwrap();
375            assert_eq_sorted!(
376                result,
377                CopyPatch {
378                    paths: Some(vec!["src1".to_string(), "src2".to_string()].into_patch()),
379                    options: Some(CopyOptionsPatch {
380                        target: Some(Some("/app".into())),
381                        chown: Some(None),
382                        chmod: Some(None),
383                        link: Some(None),
384                    }),
385                    from: Some(FromContextPatch::default()),
386                    exclude: Some(VecPatch::default()),
387                    parents: Some(None),
388                }
389            );
390        }
391    }
392
393    mod add_git_repo {
394
395        use super::*;
396
397        #[test]
398        fn ssh() {
399            let result = AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
400            assert_eq_sorted!(
401                result,
402                AddGitRepoPatch {
403                    repo: Some("git@github.com:lenra-io/dofigen.git".into()),
404                    options: Some(CopyOptionsPatch {
405                        target: Some(None),
406                        chown: Some(None),
407                        chmod: Some(None),
408                        link: Some(None),
409                    }),
410                    keep_git_dir: Some(None),
411                    checksum: Some(None),
412                    exclude: Some(VecPatch::default()),
413                }
414            );
415        }
416
417        #[test]
418        fn ssh_with_target() {
419            let result =
420                AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git /app").unwrap();
421            assert_eq_sorted!(
422                result,
423                AddGitRepoPatch {
424                    repo: Some("git@github.com:lenra-io/dofigen.git".into()),
425                    options: Some(CopyOptionsPatch {
426                        target: Some(Some("/app".into())),
427                        chown: Some(None),
428                        chmod: Some(None),
429                        link: Some(None),
430                    }),
431                    keep_git_dir: Some(None),
432                    checksum: Some(None),
433                    exclude: Some(VecPatch::default()),
434                }
435            );
436        }
437
438        #[test]
439        fn http() {
440            let result =
441                AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
442            assert_eq_sorted!(
443                result,
444                AddGitRepoPatch {
445                    repo: Some("https://github.com/lenra-io/dofigen.git".into()),
446                    options: Some(CopyOptionsPatch {
447                        target: Some(None),
448                        chown: Some(None),
449                        chmod: Some(None),
450                        link: Some(None),
451                    }),
452                    keep_git_dir: Some(None),
453                    checksum: Some(None),
454                    exclude: Some(VecPatch::default()),
455                }
456            );
457        }
458
459        #[test]
460        fn http_with_target() {
461            let result =
462                AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git /app").unwrap();
463            assert_eq_sorted!(
464                result,
465                AddGitRepoPatch {
466                    repo: Some("https://github.com/lenra-io/dofigen.git".into()),
467                    options: Some(CopyOptionsPatch {
468                        target: Some(Some("/app".into())),
469                        chown: Some(None),
470                        chmod: Some(None),
471                        link: Some(None),
472                    }),
473                    keep_git_dir: Some(None),
474                    checksum: Some(None),
475                    exclude: Some(VecPatch::default()),
476                }
477            );
478        }
479    }
480
481    mod add {
482        use struct_patch::Patch;
483
484        use crate::{CopyOptions, Resource};
485
486        use super::*;
487
488        #[test]
489        fn simple() {
490            let result =
491                AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
492                    .unwrap();
493            assert_eq_sorted!(
494                result,
495                Add {
496                    files: vec![Resource::Url(
497                        "https://github.com/lenra-io/dofigen/raw/main/README.md"
498                            .parse()
499                            .unwrap()
500                    )],
501                    options: CopyOptions::default(),
502                    ..Default::default()
503                }
504                .into_patch()
505            );
506        }
507
508        #[test]
509        fn with_target_option() {
510            let result =
511                AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md /app")
512                    .unwrap();
513            assert_eq_sorted!(
514                result,
515                Add {
516                    files: vec![Resource::Url(
517                        "https://github.com/lenra-io/dofigen/raw/main/README.md"
518                            .parse()
519                            .unwrap()
520                    )],
521                    options: CopyOptions {
522                        target: Some("/app".into()),
523                        ..Default::default()
524                    },
525                    ..Default::default()
526                }
527                .into_patch()
528            );
529        }
530
531        #[test]
532        fn with_multiple_sources_and_target() {
533            let result = AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md https://github.com/lenra-io/dofigen/raw/main/LICENSE /app").unwrap();
534            assert_eq_sorted!(
535                result,
536                Add {
537                    files: vec![
538                        Resource::Url(
539                            "https://github.com/lenra-io/dofigen/raw/main/README.md"
540                                .parse()
541                                .unwrap()
542                        ),
543                        Resource::Url(
544                            "https://github.com/lenra-io/dofigen/raw/main/LICENSE"
545                                .parse()
546                                .unwrap()
547                        )
548                    ],
549                    options: CopyOptions {
550                        target: Some("/app".into()),
551                        ..Default::default()
552                    },
553                    ..Default::default()
554                }
555                .into_patch()
556            );
557        }
558    }
559
560    mod copy_resources {
561        use super::*;
562
563        #[test]
564        fn copy() {
565            let result = CopyResourcePatch::from_str("src").unwrap();
566            assert_eq_sorted!(
567                result,
568                CopyResourcePatch::Copy(CopyPatch::from_str("src").unwrap())
569            );
570        }
571
572        #[test]
573        fn add_git_repo_ssh() {
574            let result =
575                CopyResourcePatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
576            assert_eq_sorted!(
577                result,
578                CopyResourcePatch::AddGitRepo(
579                    AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap()
580                )
581            );
582        }
583
584        #[test]
585        fn add_git_repo_http() {
586            let result =
587                CopyResourcePatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
588            assert_eq_sorted!(
589                result,
590                CopyResourcePatch::AddGitRepo(
591                    AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap()
592                )
593            );
594        }
595
596        #[test]
597        fn add() {
598            let result = CopyResourcePatch::from_str(
599                "https://github.com/lenra-io/dofigen/raw/main/README.md",
600            )
601            .unwrap();
602            assert_eq_sorted!(
603                result,
604                CopyResourcePatch::Add(
605                    AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
606                        .unwrap()
607                )
608            );
609        }
610    }
611
612    mod user {
613        use pretty_assertions_sorted::assert_eq_sorted;
614
615        use super::*;
616
617        #[test]
618        fn user() {
619            let result = UserPatch::from_str("user").unwrap();
620
621            assert_eq_sorted!(result.user, Some("user".into()));
622            assert_eq_sorted!(result.group, Some(None));
623        }
624
625        #[test]
626        fn with_group() {
627            let result = UserPatch::from_str("user:group").unwrap();
628
629            assert_eq_sorted!(result.user, Some("user".into()));
630            assert_eq_sorted!(result.group, Some(Some("group".into())));
631        }
632
633        #[test]
634        fn uid() {
635            let result = UserPatch::from_str("1000").unwrap();
636
637            assert_eq_sorted!(result.user, Some("1000".into()));
638            assert_eq_sorted!(result.group, Some(None));
639        }
640
641        #[test]
642        fn uid_with_gid() {
643            let result = UserPatch::from_str("1000:1000").unwrap();
644
645            assert_eq_sorted!(result.user, Some("1000".into()));
646            assert_eq_sorted!(result.group, Some(Some("1000".into())));
647        }
648
649        #[test]
650        fn invalid_username() {
651            let result = UserPatch::from_str("user*name");
652
653            assert!(result.is_err());
654        }
655
656        #[test]
657        fn invalid_extra() {
658            let result = UserPatch::from_str("user:group:extra");
659
660            assert!(result.is_err());
661        }
662    }
663
664    mod port {
665
666        use super::*;
667
668        #[test]
669        fn simple() {
670            let result = PortPatch::from_str("80").unwrap();
671
672            assert_eq_sorted!(result.port, Some(80));
673            assert_eq_sorted!(result.protocol, Some(None));
674        }
675
676        #[test]
677        fn with_tcp_protocol() {
678            let result = PortPatch::from_str("80/tcp").unwrap();
679
680            assert_eq_sorted!(result.port, Some(80));
681            assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Tcp)));
682        }
683
684        #[test]
685        fn with_udp_protocol() {
686            let result = PortPatch::from_str("80/udp").unwrap();
687
688            assert_eq_sorted!(result.port, Some(80));
689            assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Udp)));
690        }
691
692        #[test]
693        fn invalid() {
694            let result = PortPatch::from_str("80/invalid");
695
696            assert!(result.is_err());
697        }
698    }
699}