Skip to main content

isoloom_spec/model/
secrets.rs

1//! Secrets: values read from a store on the runner when the environment starts, and handed to
2//! the machines as files. A secret's value is never in the spec, the generated files or the
3//! resolved snapshot: those only name where it comes from and where it goes.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::machine::ScalarSchema;
9use crate::syntax::Mode;
10
11/// Where the runner (`isoloom run`) reads a secret's value: one store, written as a map with
12/// one key (`{ env: DB_PASSWORD }`). Another store is a new variant here and a reader of its
13/// own in the runtime.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15#[serde(untagged)]
16pub enum SecretSource {
17    /// An environment variable of the runner (or the profile's `inputs`).
18    Env {
19        /// The name of the runner's environment variable holding the value, e.g. `DB_PASSWORD`.
20        env: String,
21    },
22    /// A file on the runner. Keep it out of version control.
23    File {
24        /// Its path: absolute, `~/...`, or relative to the project folder.
25        file: String,
26    },
27    /// A HashiCorp Vault key/value secret, read with the `vault` command and its usual
28    /// settings (`VAULT_ADDR`, `VAULT_TOKEN`).
29    Vault {
30        /// The secret's path in Vault and the field holding the value, as `<path>#<field>`,
31        /// e.g. `secret/app#db_password`.
32        vault: String,
33    },
34    /// A value in a SOPS-encrypted file, decrypted with the `sops` command.
35    Sops {
36        /// `<file>#<key>`, dotted keys for nesting, e.g. `secrets.enc.yaml#db.password`.
37        sops: String,
38    },
39    /// An input's value, given at each start: the secret lowering makes of a file whose source
40    /// is an input (`files: [{ path, input }]`). Never written in a spec.
41    #[serde(skip_deserializing)]
42    #[schemars(skip)]
43    Input {
44        /// The input's name (`CTF_FLAG_MAIN`): the runner's variable holding the value.
45        input: String,
46    },
47}
48
49impl SecretSource {
50    /// The store's name, as the spec writes it (`env`, `file`, `vault`, `sops`).
51    #[must_use]
52    pub fn store(&self) -> &'static str {
53        match self {
54            SecretSource::Env { .. } => "env",
55            SecretSource::File { .. } => "file",
56            SecretSource::Vault { .. } => "vault",
57            SecretSource::Sops { .. } => "sops",
58            SecretSource::Input { .. } => "input",
59        }
60    }
61
62    /// What the store is asked for: a variable, a path, a `<path>#<field>` reference.
63    #[must_use]
64    pub fn reference(&self) -> &str {
65        match self {
66            SecretSource::Env { env: r }
67            | SecretSource::File { file: r }
68            | SecretSource::Vault { vault: r }
69            | SecretSource::Sops { sops: r }
70            | SecretSource::Input { input: r } => r,
71        }
72    }
73}
74
75/// Where a machine's secret goes: by name alone (`/run/secrets/<name>`, read-only, with the
76/// default mode of the machine's kind), or with its path, owner and mode.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
78#[serde(untagged)]
79pub enum MachineSecret {
80    /// The secret's name: the file `/run/secrets/<name>`.
81    Name(String),
82    /// The secret placed as written.
83    Mount(SecretMount),
84}
85
86/// A secret placed in a machine: which one, at what path, owned by whom, with what mode.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88#[serde(deny_unknown_fields)]
89pub struct SecretMount {
90    /// The secret, from the top-level `secrets`.
91    pub source: String,
92    /// The file's path in the machine: absolute, or a name under `/run/secrets/`. Default:
93    /// `/run/secrets/<source>`. On Windows, a drive path (`C:\app\db-password`) too; a path from
94    /// `/` is on drive C: (`/run/secrets/db` is `C:\run\secrets\db`).
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub target: Option<String>,
97    /// The file's owner, a user id (default 0, root). Not on Windows, where the file is
98    /// readable by SYSTEM and Administrators only.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub uid: Option<u32>,
101    /// The file's group, a group id (default 0, root).
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub gid: Option<u32>,
104    /// The file's mode, in octal (`0440`, or `"0440"`). Default `0444` in a container (as
105    /// Compose and Kubernetes do), `0400` on a Linux VM, whose other users shouldn't read it.
106    /// Not on Windows.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    #[schemars(with = "Option<ScalarSchema>")]
109    pub mode: Option<Mode>,
110}
111
112/// Whether `p` is an absolute Windows path on a drive (`C:\app\db`, `C:/app/db`).
113#[must_use]
114pub fn is_drive_path(p: &str) -> bool {
115    after_drive(p).is_some()
116}
117
118/// The path after its drive (`\app\db` of `C:\app\db`), when `p` is an absolute Windows path
119/// on a drive.
120#[must_use]
121pub fn after_drive(p: &str) -> Option<&str> {
122    let rest = p.strip_prefix(|c: char| c.is_ascii_alphabetic())?.strip_prefix(':')?;
123    (rest.len() > 1 && rest.starts_with(['\\', '/'])).then_some(rest)
124}
125
126/// Where secrets go when a machine names no path.
127pub const SECRETS_DIR: &str = "/run/secrets";
128
129impl MachineSecret {
130    /// The secret it places.
131    #[must_use]
132    pub fn source(&self) -> &str {
133        match self {
134            MachineSecret::Name(n) => n,
135            MachineSecret::Mount(m) => &m.source,
136        }
137    }
138
139    /// The file's absolute path in the machine.
140    #[must_use]
141    pub fn path(&self) -> String {
142        match self {
143            MachineSecret::Mount(SecretMount { target: Some(t), .. }) if t.starts_with('/') || is_drive_path(t) => t.clone(),
144            MachineSecret::Mount(SecretMount { target: Some(t), .. }) => format!("{SECRETS_DIR}/{t}"),
145            _ => format!("{SECRETS_DIR}/{}", self.source()),
146        }
147    }
148
149    /// Its long form (every field but the source unset for a name alone).
150    #[must_use]
151    pub fn mount(&self) -> SecretMount {
152        match self {
153            MachineSecret::Name(n) => SecretMount {
154                source: n.clone(),
155                target: None,
156                uid: None,
157                gid: None,
158                mode: None,
159            },
160            MachineSecret::Mount(m) => m.clone(),
161        }
162    }
163}
164
165/// The environment variable a secret's value is handed over in, to the tool that delivers it
166/// (`ISOLOOM_SECRET_DB_PASSWORD` for `db-password`). Secret names are kebab-case, so two
167/// secrets never share one.
168#[must_use]
169pub fn secret_variable(name: &str) -> String {
170    format!("ISOLOOM_SECRET_{}", name.to_ascii_uppercase().replace('-', "_"))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn a_source_names_its_store_and_what_the_store_is_asked_for() -> Result<(), serde_yaml_ng::Error> {
179        let mut read = Vec::new();
180        for yaml in [
181            "{ env: DB_PASSWORD }",
182            "{ file: ~/db.txt }",
183            "{ vault: \"secret/app#db\" }",
184            "{ sops: \"s.enc.yaml#db.pw\" }",
185        ] {
186            let source: SecretSource = serde_yaml_ng::from_str(yaml)?;
187            read.push((source.store(), source.reference().to_owned()));
188        }
189        let input = SecretSource::Input { input: "FLAG".into() };
190        read.push((input.store(), input.reference().to_owned()));
191        let expected = [
192            ("env", "DB_PASSWORD"),
193            ("file", "~/db.txt"),
194            ("vault", "secret/app#db"),
195            ("sops", "s.enc.yaml#db.pw"),
196            ("input", "FLAG"),
197        ];
198        assert_eq!(read, expected.map(|(s, r)| (s, r.to_owned())));
199        Ok(())
200    }
201
202    #[test]
203    fn a_drive_path_is_a_letter_a_colon_and_a_rooted_path() {
204        let read: Vec<bool> = ["C:\\app\\db", "d:/app", "C:\\", "C:app\\db", "1:\\app", "/run/db"]
205            .iter()
206            .map(|p| is_drive_path(p))
207            .collect();
208        assert_eq!(read, [true, true, false, false, false, false]);
209    }
210
211    #[test]
212    fn a_secret_goes_to_its_target_or_under_run_secrets() -> Result<(), serde_yaml_ng::Error> {
213        let mut paths = Vec::new();
214        for yaml in [
215            "db",
216            "{ source: db, target: /etc/db.pw }",
217            "{ source: db, target: \"C:\\\\app\\\\db\" }",
218            "{ source: db, target: db.pw }",
219            "{ source: db }",
220        ] {
221            paths.push(serde_yaml_ng::from_str::<MachineSecret>(yaml)?.path());
222        }
223        assert_eq!(paths, ["/run/secrets/db", "/etc/db.pw", "C:\\app\\db", "/run/secrets/db.pw", "/run/secrets/db"]);
224        Ok(())
225    }
226
227    #[test]
228    fn a_secret_is_handed_over_in_a_variable_of_its_name() {
229        assert_eq!(secret_variable("db-password"), "ISOLOOM_SECRET_DB_PASSWORD");
230    }
231}