blaze_common/
executor.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt::Display,
4    path::{Path, PathBuf},
5    str::FromStr,
6};
7
8use anyhow::bail;
9use base64::Engine;
10use hash_value::{to_value, Value};
11use serde::{de::Error as DeError, ser::Error as SerError, Deserialize, Serialize};
12use strum_macros::{Display, EnumIter};
13use url::Url;
14
15use crate::{cache::FileChangesMatcher, error::Error, unit_enum_deserialize, unit_enum_from_str};
16
17#[derive(Hash, PartialEq, Eq, Serialize, EnumIter, Display, Debug, Clone, Copy)]
18pub enum ExecutorKind {
19    Rust,
20    Node,
21}
22
23unit_enum_from_str!(ExecutorKind);
24unit_enum_deserialize!(ExecutorKind);
25
26#[derive(Serialize, Debug, Hash, PartialEq, Eq, Clone)]
27#[serde(untagged)]
28pub enum ExecutorReference {
29    Standard {
30        url: Url,
31    },
32    Custom {
33        url: Url,
34        #[serde(flatten)]
35        location: Location,
36    },
37}
38
39impl Display for ExecutorReference {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::Standard { url } => url.fmt(f),
43            Self::Custom { url, location } => {
44                let fmt_git_location = |url: &Url, options: &GitOptions| {
45                    let mut out = url.to_string();
46                    if let Some(path) = options.path() {
47                        out.push_str(&format!("[/{}]", path.display()));
48                    }
49                    if let Some(checkout) = options.checkout() {
50                        out.push_str(&format!("@{}", checkout));
51                    }
52                    out
53                };
54
55                match location {
56                    Location::Git {
57                        options: git_options,
58                    }
59                    | Location::GitOverHttp { git_options, .. }
60                    | Location::GitOverSsh { git_options, .. } => {
61                        write!(f, "{}", fmt_git_location(url, git_options))
62                    }
63                    _ => url.fmt(f),
64                }
65            }
66        }
67    }
68}
69
70const URL_KEY: &str = "url";
71const FORMAT_KEY: &str = "format";
72const AUTHENTICATION_KEY: &str = "authentication";
73
74impl<'de> Deserialize<'de> for ExecutorReference {
75    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
76    where
77        D: serde::Deserializer<'de>,
78    {
79        let root = Value::deserialize(deserializer)?;
80        let url = root
81            .at(URL_KEY)
82            .and_then(Value::as_str)
83            .or_else(|| root.as_str())
84            .map(|s| Url::parse(s).map_err(D::Error::custom))
85            .transpose()?
86            .ok_or_else(|| D::Error::missing_field(URL_KEY))?;
87        let is_single_url = matches!(root, Value::String(_));
88
89        match url.scheme() {
90            "std" => Ok(Self::Standard { url }),
91            custom_executor_scheme => Ok(Self::Custom {
92                location: match custom_executor_scheme {
93                    "file" => Location::LocalFileSystem {
94                        options: if is_single_url {
95                            FileSystemOptions::default()
96                        } else {
97                            FileSystemOptions::deserialize(&root).map_err(D::Error::custom)?
98                        },
99                    },
100                    "http" | "https" => {
101                        let format = root
102                            .at(FORMAT_KEY)
103                            .map(|f| HttpFormatIdentifier::deserialize(f).map_err(D::Error::custom))
104                            .transpose()?
105                            .ok_or_else(|| D::Error::missing_field(FORMAT_KEY))?;
106
107                        let transport =
108                            HttpTransport::deserialize(&root).map_err(D::Error::custom)?;
109
110                        match format {
111                            HttpFormatIdentifier::Git => Location::GitOverHttp {
112                                transport,
113                                git_options: GitOptions::deserialize(&root)
114                                    .map_err(D::Error::custom)?,
115                                authentication: root
116                                    .at(AUTHENTICATION_KEY)
117                                    .map(|auth| {
118                                        GitPlainAuthentication::deserialize(auth)
119                                            .map_err(D::Error::custom)
120                                    })
121                                    .transpose()?,
122                            },
123                            HttpFormatIdentifier::Tarball => Location::TarballOverHttp {
124                                transport,
125                                tarball_options: TarballOptions::deserialize(&root)
126                                    .map_err(D::Error::custom)?,
127                                authentication: root
128                                    .at(AUTHENTICATION_KEY)
129                                    .map(|auth| {
130                                        HttpAuthentication::deserialize(auth)
131                                            .map_err(D::Error::custom)
132                                    })
133                                    .transpose()?,
134                            },
135                        }
136                    }
137                    "ssh" => Location::GitOverSsh {
138                        transport: SshTransport::deserialize(&root).map_err(D::Error::custom)?,
139                        git_options: GitOptions::deserialize(&root).map_err(D::Error::custom)?,
140                        authentication: root
141                            .at(AUTHENTICATION_KEY)
142                            .map(|auth| {
143                                SshAuthentication::deserialize(auth).map_err(D::Error::custom)
144                            })
145                            .transpose()?,
146                    },
147                    "git" => Location::Git {
148                        options: GitOptions::deserialize(&root).map_err(D::Error::custom)?,
149                    },
150                    "npm" => Location::Npm {
151                        options: NpmOptions::deserialize(&root).map_err(D::Error::custom)?,
152                    },
153                    "cargo" => Location::Cargo {
154                        options: CargoOptions::deserialize(&root).map_err(D::Error::custom)?,
155                    },
156                    invalid_scheme => {
157                        return Err(serde::de::Error::custom(&format!(
158                            "invalid url scheme \"{invalid_scheme}\""
159                        )))
160                    }
161                },
162                url,
163            }),
164        }
165    }
166}
167
168#[derive(Hash, Debug, PartialEq, Eq, Clone)]
169pub enum Location {
170    LocalFileSystem {
171        options: FileSystemOptions,
172    },
173
174    TarballOverHttp {
175        transport: HttpTransport,
176        tarball_options: TarballOptions,
177        authentication: Option<HttpAuthentication>,
178    },
179
180    GitOverHttp {
181        transport: HttpTransport,
182        git_options: GitOptions,
183        authentication: Option<GitPlainAuthentication>,
184    },
185
186    GitOverSsh {
187        transport: SshTransport,
188        git_options: GitOptions,
189        authentication: Option<SshAuthentication>,
190    },
191    Git {
192        options: GitOptions,
193    },
194    Cargo {
195        options: CargoOptions,
196    },
197    Npm {
198        options: NpmOptions,
199    },
200}
201
202impl Serialize for Location {
203    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
204    where
205        S: serde::Serializer,
206    {
207        match self {
208            Self::LocalFileSystem { options } => options.serialize(serializer),
209            Self::GitOverHttp {
210                transport,
211                git_options,
212                authentication,
213            } => {
214                let mut value = to_value(transport).map_err(S::Error::custom)?;
215                value.overwrite(to_value(git_options).map_err(S::Error::custom)?);
216                value.overwrite(Value::object([(
217                    FORMAT_KEY,
218                    Value::string(HttpFormatIdentifier::Git.to_string()),
219                )]));
220                if let Some(authentication) = authentication {
221                    value.overwrite(Value::object([(
222                        AUTHENTICATION_KEY,
223                        to_value(authentication).map_err(S::Error::custom)?,
224                    )]));
225                }
226                value.serialize(serializer)
227            }
228            Self::GitOverSsh {
229                transport,
230                git_options,
231                authentication,
232            } => {
233                let mut value = to_value(transport).map_err(S::Error::custom)?;
234                value.overwrite(to_value(git_options).map_err(S::Error::custom)?);
235                if let Some(authentication) = authentication {
236                    value.overwrite(to_value(authentication).map_err(S::Error::custom)?);
237                }
238                value.serialize(serializer)
239            }
240            Self::Npm { options } => options.serialize(serializer),
241            Self::Cargo { options } => options.serialize(serializer),
242            _ => todo!(),
243        }
244    }
245}
246
247#[derive(Default, EnumIter, Display, Serialize, Hash, PartialEq, Eq, Debug, Clone, Copy)]
248pub enum RebuildStrategy {
249    Always,
250    #[default]
251    OnChanges,
252}
253
254unit_enum_from_str!(RebuildStrategy);
255unit_enum_deserialize!(RebuildStrategy);
256
257#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone, Default)]
258pub struct FileSystemOptions {
259    #[serde(skip_serializing_if = "Option::is_none")]
260    kind: Option<ExecutorKind>,
261    #[serde(default)]
262    rebuild: RebuildStrategy,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    watch: Option<BTreeSet<FileChangesMatcher>>,
265}
266
267impl FileSystemOptions {
268    pub fn kind(&self) -> Option<ExecutorKind> {
269        self.kind
270    }
271
272    pub fn rebuild(&self) -> RebuildStrategy {
273        self.rebuild
274    }
275
276    pub fn watch(&self) -> Option<&BTreeSet<FileChangesMatcher>> {
277        self.watch.as_ref()
278    }
279}
280
281#[derive(Serialize, Deserialize, Hash, Debug, PartialEq, Eq, Clone)]
282pub struct HttpTransport {
283    #[serde(default)]
284    insecure: bool,
285    #[serde(default)]
286    headers: BTreeMap<String, String>,
287}
288
289impl HttpTransport {
290    pub fn insecure(&self) -> bool {
291        self.insecure
292    }
293
294    pub fn headers(&self) -> &BTreeMap<String, String> {
295        &self.headers
296    }
297}
298
299const HTTP_AUTH_MODE_KEY: &str = "mode";
300
301#[derive(Serialize, EnumIter, Display, Hash, PartialEq, Eq, Clone)]
302pub enum HttpAuthenticationMode {
303    Basic,
304    Bearer,
305}
306
307unit_enum_from_str!(HttpAuthenticationMode);
308unit_enum_deserialize!(HttpAuthenticationMode);
309
310#[derive(Serialize, Deserialize, Hash, Debug, PartialEq, Eq, Clone)]
311pub struct HttpBasicAuthentication {
312    username: String,
313    password: String,
314}
315
316impl HttpBasicAuthentication {
317    pub fn username(&self) -> &str {
318        &self.username
319    }
320
321    pub fn password(&self) -> &str {
322        &self.password
323    }
324}
325
326#[derive(Serialize, Deserialize, Hash, Debug, PartialEq, Eq, Clone)]
327pub struct HttpDigestAuthentication {
328    username: String,
329    password: String,
330}
331
332impl HttpDigestAuthentication {
333    pub fn username(&self) -> &str {
334        &self.username
335    }
336
337    pub fn password(&self) -> &str {
338        &self.password
339    }
340}
341
342#[derive(Serialize, Deserialize, Hash, Debug, PartialEq, Eq, Clone)]
343pub struct HttpBearerAuthentication {
344    token: String,
345}
346
347impl HttpBearerAuthentication {
348    pub fn token(&self) -> &str {
349        &self.token
350    }
351}
352
353#[derive(Hash, Debug, PartialEq, Eq, Clone)]
354pub enum HttpAuthentication {
355    Basic(HttpBasicAuthentication),
356    Bearer(HttpBearerAuthentication),
357}
358
359impl<'de> Deserialize<'de> for HttpAuthentication {
360    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
361    where
362        D: serde::Deserializer<'de>,
363    {
364        let root = Value::deserialize(deserializer)?;
365        let mode = root
366            .at(HTTP_AUTH_MODE_KEY)
367            .map(|m| HttpAuthenticationMode::deserialize(m).map_err(D::Error::custom))
368            .transpose()?
369            .ok_or_else(|| D::Error::missing_field(HTTP_AUTH_MODE_KEY))?;
370        Ok(match mode {
371            HttpAuthenticationMode::Basic => HttpAuthentication::Basic(
372                HttpBasicAuthentication::deserialize(root).map_err(D::Error::custom)?,
373            ),
374            HttpAuthenticationMode::Bearer => HttpAuthentication::Bearer(
375                HttpBearerAuthentication::deserialize(root).map_err(D::Error::custom)?,
376            ),
377        })
378    }
379}
380
381impl Serialize for HttpAuthentication {
382    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
383    where
384        S: serde::Serializer,
385    {
386        let mut value = Value::object([(
387            HTTP_AUTH_MODE_KEY,
388            to_value(match self {
389                HttpAuthentication::Basic(_) => HttpAuthenticationMode::Basic,
390                HttpAuthentication::Bearer(_) => HttpAuthenticationMode::Bearer,
391            })
392            .map_err(S::Error::custom)?,
393        )]);
394
395        value.overwrite(
396            match self {
397                HttpAuthentication::Basic(basic) => to_value(basic),
398                HttpAuthentication::Bearer(bearer) => to_value(bearer),
399            }
400            .map_err(S::Error::custom)?,
401        );
402
403        value.serialize(serializer)
404    }
405}
406
407#[derive(Serialize, EnumIter, Display)]
408pub enum HttpFormatIdentifier {
409    Git,
410    Tarball,
411}
412
413#[derive(Serialize, Hash, Debug, PartialEq, Eq, Clone)]
414#[serde(untagged)]
415pub enum HttpResource {
416    Git {
417        #[serde(flatten)]
418        options: GitOptions,
419    },
420    Tarball {
421        #[serde(flatten)]
422        options: TarballOptions,
423    },
424}
425
426#[derive(Serialize, Deserialize, Hash, Debug, PartialEq, Eq, Clone)]
427#[serde(untagged)]
428pub enum GitCheckout {
429    Branch { branch: String },
430    Tag { tag: String },
431    Revision { rev: String },
432}
433
434impl Display for GitCheckout {
435    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436        f.write_str(match self {
437            Self::Branch { branch } => branch.as_str(),
438            Self::Revision { rev } => rev.as_str(),
439            Self::Tag { tag } => tag.as_str(),
440        })
441    }
442}
443
444unit_enum_from_str!(HttpFormatIdentifier);
445unit_enum_deserialize!(HttpFormatIdentifier);
446
447#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone)]
448pub struct GitOptions {
449    #[serde(skip_serializing_if = "Option::is_none")]
450    kind: Option<ExecutorKind>,
451    #[serde(default)]
452    pull: bool,
453    #[serde(skip_serializing_if = "Option::is_none", flatten)]
454    checkout: Option<GitCheckout>,
455    #[serde(skip_serializing_if = "Option::is_none")]
456    path: Option<PathBuf>,
457}
458
459impl GitOptions {
460    pub fn kind(&self) -> Option<ExecutorKind> {
461        self.kind
462    }
463
464    pub fn pull(&self) -> bool {
465        self.pull
466    }
467
468    pub fn checkout(&self) -> Option<&GitCheckout> {
469        self.checkout.as_ref()
470    }
471
472    pub fn path(&self) -> Option<&Path> {
473        self.path.as_deref()
474    }
475}
476
477#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone)]
478pub struct GitPlainAuthentication {
479    username: String,
480    password: String,
481}
482
483impl GitPlainAuthentication {
484    pub fn username(&self) -> &str {
485        &self.username
486    }
487
488    pub fn password(&self) -> &str {
489        &self.password
490    }
491}
492
493#[derive(Serialize, EnumIter, Display, Hash, Debug, PartialEq, Eq, Copy, Clone)]
494pub enum Compression {
495    Deflate,
496    Zlib,
497    Gzip,
498}
499
500unit_enum_from_str!(Compression);
501unit_enum_deserialize!(Compression);
502
503#[derive(Serialize, Deserialize, Hash, Debug, PartialEq, Eq, Clone)]
504pub struct TarballOptions {
505    #[serde(skip_serializing_if = "Option::is_none")]
506    kind: Option<ExecutorKind>,
507    #[serde(skip_serializing_if = "Option::is_none")]
508    compression: Option<Compression>,
509}
510
511impl TarballOptions {
512    pub fn kind(&self) -> Option<ExecutorKind> {
513        self.kind
514    }
515
516    pub fn compression(&self) -> Option<Compression> {
517        self.compression
518    }
519}
520
521#[derive(Serialize, EnumIter, Display, Hash, Debug, PartialEq, Eq, Copy, Clone)]
522pub enum SshFingerprintAlgorithm {
523    Md5,
524    Sha1,
525    Sha256,
526}
527
528unit_enum_from_str!(SshFingerprintAlgorithm);
529unit_enum_deserialize!(SshFingerprintAlgorithm);
530
531#[derive(Hash, Debug, PartialEq, Eq, Clone)]
532pub enum SshFingerprint {
533    Md5([u8; 16]),
534    Sha1([u8; 20]),
535    Sha256([u8; 32]),
536}
537
538macro_rules! parse_fingerprint {
539    ($value:expr, $len:literal) => {{
540        let decoded = base64::prelude::BASE64_STANDARD_NO_PAD
541            .decode($value)
542            .map_err(|err| {
543                anyhow::anyhow!(
544                    "failed to decode ssh fingerprint content {} ({})",
545                    $value,
546                    err
547                )
548            })?;
549        let length = decoded.len();
550        if length != $len {
551            bail!(
552                "fingerprint has invalid length (expected={}, actual={})",
553                $len,
554                length
555            )
556        }
557        let mut bytes = [0_u8; $len];
558        bytes.copy_from_slice(&decoded);
559        Ok::<_, $crate::error::Error>(bytes)
560    }};
561}
562
563const MD5: &str = "MD5";
564const SHA1: &str = "SHA1";
565const SHA256: &str = "SHA256";
566
567impl FromStr for SshFingerprint {
568    type Err = Error;
569
570    fn from_str(s: &str) -> crate::error::Result<Self> {
571        Ok(match s.split_once(':'){
572            Some((algorithm, value)) => {
573                match algorithm {
574                    MD5 => Self::Md5(parse_fingerprint!(value, 16)?),
575                    SHA1 => Self::Sha1(parse_fingerprint!(value, 20)?),
576                    SHA256 => Self::Sha256(parse_fingerprint!(value, 32)?),
577                    _ => bail!("unknown ssh fingerprint algorithm \"{algorithm}\". valid algorithms are MD5, SHA1 and SHA256")
578                }
579            },
580            _ => bail!("bad ssh fingerprint. format must be <ALGORITHM>:<base64 hash>.")
581        })
582    }
583}
584
585impl Display for SshFingerprint {
586    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
587        let (algorithm_id, slice) = match self {
588            Self::Md5(md5) => (MD5, md5.as_slice()),
589            Self::Sha1(sha1) => (SHA1, sha1.as_slice()),
590            Self::Sha256(sha256) => (SHA256, sha256.as_slice()),
591        };
592        let mut base64 = String::new();
593        base64::prelude::BASE64_STANDARD_NO_PAD.encode_string(slice, &mut base64);
594        write!(f, "{algorithm_id}:{base64}")
595    }
596}
597
598impl Serialize for SshFingerprint {
599    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
600    where
601        S: serde::Serializer,
602    {
603        serializer.serialize_str(&self.to_string())
604    }
605}
606
607impl<'de> Deserialize<'de> for SshFingerprint {
608    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
609    where
610        D: serde::Deserializer<'de>,
611    {
612        let s = String::deserialize(deserializer)?;
613        SshFingerprint::from_str(&s).map_err(D::Error::custom)
614    }
615}
616
617#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone)]
618#[serde(rename_all = "camelCase")]
619pub struct SshTransport {
620    #[serde(skip_serializing_if = "Option::is_none")]
621    fingerprints: Option<Vec<SshFingerprint>>,
622    #[serde(default)]
623    insecure: bool,
624}
625
626impl SshTransport {
627    pub fn fingerprints(&self) -> Option<&[SshFingerprint]> {
628        self.fingerprints.as_deref()
629    }
630
631    pub fn insecure(&self) -> bool {
632        self.insecure
633    }
634}
635
636#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone)]
637#[serde(untagged)]
638pub enum SshResource {
639    Git {
640        #[serde(flatten)]
641        options: GitOptions,
642    },
643}
644
645#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone)]
646#[serde(untagged)]
647pub enum SshAuthentication {
648    Password {
649        #[serde(skip_serializing_if = "Option::is_none")]
650        username: Option<String>,
651        password: String,
652    },
653    PrivateKeyFile {
654        #[serde(skip_serializing_if = "Option::is_none")]
655        username: Option<String>,
656        key: PathBuf,
657        #[serde(skip_serializing_if = "Option::is_none")]
658        passphrase: Option<String>,
659    },
660}
661
662#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone)]
663pub struct CargoOptions {
664    #[serde(skip_serializing_if = "Option::is_none")]
665    version: Option<String>,
666    #[serde(default)]
667    insecure: bool,
668    #[serde(skip_serializing_if = "Option::is_none")]
669    token: Option<String>,
670}
671
672impl CargoOptions {
673    pub fn version(&self) -> Option<&str> {
674        self.version.as_deref()
675    }
676
677    pub fn insecure(&self) -> bool {
678        self.insecure
679    }
680
681    pub fn token(&self) -> Option<&str> {
682        self.token.as_deref()
683    }
684}
685
686#[derive(Deserialize, Serialize, Hash, Debug, PartialEq, Eq, Clone)]
687pub struct NpmOptions {
688    #[serde(skip_serializing_if = "Option::is_none")]
689    version: Option<String>,
690    #[serde(default)]
691    insecure: bool,
692    #[serde(skip_serializing_if = "Option::is_none")]
693    token: Option<String>,
694}
695
696impl NpmOptions {
697    pub fn version(&self) -> Option<&str> {
698        self.version.as_deref()
699    }
700
701    pub fn insecure(&self) -> bool {
702        self.insecure
703    }
704
705    pub fn token(&self) -> Option<&str> {
706        self.token.as_deref()
707    }
708}