Skip to main content

isoloom_spec/syntax/
files.rs

1//! A machine's `files`: one way to place a file, whatever its source. A project file or inline
2//! text is configuration (it may land in generated files); an input or a secret is a run-time
3//! value, delivered at each start and never written into a generated file, an image layer or an
4//! environment variable.
5
6use std::fmt::{self, Display, Formatter};
7use std::str::FromStr;
8
9use schemars::{JsonSchema, json_schema};
10use serde::{Deserialize, Deserializer, Serialize};
11
12use super::names::{InputName, Text};
13use crate::Error;
14
15/// Where a file's contents come from.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum FileSource {
18    /// A file of the project, copied as it is.
19    Source(String),
20    /// The text written in the spec.
21    Content(String),
22    /// An input's value, given at each start.
23    Input(InputName),
24    /// A secret's value, read from its store at each start.
25    Secret(String),
26}
27
28impl FileSource {
29    /// Whether the value is only known at run time (an input or a secret).
30    #[must_use]
31    pub fn is_value(&self) -> bool {
32        matches!(self, FileSource::Input(_) | FileSource::Secret(_))
33    }
34
35    /// The field that gives it (`source`, `content`, `input`, `secret`).
36    #[must_use]
37    pub fn key(&self) -> &'static str {
38        match self {
39            FileSource::Source(_) => "source",
40            FileSource::Content(_) => "content",
41            FileSource::Input(_) => "input",
42            FileSource::Secret(_) => "secret",
43        }
44    }
45}
46
47/// A file's permissions, octal, at most `0777`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct Mode(u32);
50
51impl Mode {
52    /// The mode a project file or inline text gets when none is given: everyone reads it.
53    pub const CONFIG: Mode = Mode(0o644);
54    /// The mode an input's or a secret's file gets when none is given: root reads it.
55    pub const VALUE: Mode = Mode(0o400);
56
57    /// The mode as a number (`0o444`).
58    #[must_use]
59    pub fn bits(self) -> u32 {
60        self.0
61    }
62}
63
64impl FromStr for Mode {
65    type Err = Error;
66
67    /// The mode `digits` (`"0444"`, or YAML's number `444`) names.
68    ///
69    /// # Errors
70    ///
71    /// [`Error::Mode`] when they aren't octal digits up to `0777`.
72    fn from_str(digits: &str) -> Result<Mode, Error> {
73        u32::from_str_radix(digits, 8)
74            .ok()
75            .filter(|m| *m <= 0o777)
76            .map(Mode)
77            .ok_or_else(|| Error::Mode(digits.to_owned()))
78    }
79}
80
81impl Display for Mode {
82    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
83        write!(f, "{:04o}", self.0)
84    }
85}
86
87impl Serialize for Mode {
88    /// The mode as a spec writes it, `"0440"`, so what is written reads back the same.
89    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
90        s.collect_str(self)
91    }
92}
93
94impl<'de> Deserialize<'de> for Mode {
95    /// Octal digits as text (`"0444"`), or the number YAML reads them as (`444`).
96    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Mode, D::Error> {
97        d.deserialize_any(Text("octal digits, like \"0444\""))?
98            .parse()
99            .map_err(serde::de::Error::custom)
100    }
101}
102
103impl JsonSchema for Mode {
104    fn schema_name() -> std::borrow::Cow<'static, str> {
105        "Mode".into()
106    }
107
108    fn inline_schema() -> bool {
109        true
110    }
111
112    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
113        json_schema!({ "type": ["string", "integer"], "pattern": "^0?[0-7]{1,3}$" })
114    }
115}
116
117/// A file placed on a machine.
118#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
119#[serde(try_from = "FileInput")]
120pub struct File {
121    /// Its absolute path in the machine (a drive path, `C:\…`, on Windows).
122    pub path: String,
123    /// Where its contents come from.
124    pub source: FileSource,
125    /// Its owner's user id (default 0). Not on Windows.
126    pub uid: Option<u32>,
127    /// Its group id (default 0). Not on Windows.
128    pub gid: Option<u32>,
129    /// Its mode, when the spec gives one (see [`File::mode_or_default`]).
130    pub mode: Option<Mode>,
131}
132
133impl File {
134    /// Its mode: the spec's, else `0644` for configuration and `0400` for a value.
135    #[must_use]
136    pub fn mode_or_default(&self) -> Mode {
137        self.mode.unwrap_or(if self.source.is_value() { Mode::VALUE } else { Mode::CONFIG })
138    }
139}
140
141/// A file as written.
142#[derive(Deserialize, JsonSchema)]
143#[serde(deny_unknown_fields)]
144struct FileInput {
145    /// Where the file goes: an absolute path (`/etc/app/app.ini`), or a drive path (`C:\app\flag.txt`)
146    /// on Windows. Its folders are created when missing.
147    path: String,
148    /// A file of the project to copy (`config/app.ini`). Exactly one of `source`, `content`,
149    /// `input` and `secret`.
150    #[serde(default)]
151    source: Option<String>,
152    /// The file's text, written in the spec.
153    #[serde(default)]
154    content: Option<String>,
155    /// An input's name: the file holds its value, given at each start, before the machine's
156    /// process starts. Never in a generated file, an image or an environment variable.
157    #[serde(default)]
158    input: Option<InputName>,
159    /// A secret's name: the file holds its value, read from its store at each start.
160    #[serde(default)]
161    secret: Option<String>,
162    /// The user id that owns the file (default 0, root). Not on Windows.
163    #[serde(default)]
164    uid: Option<u32>,
165    /// The group id that owns the file (default 0, root). Not on Windows.
166    #[serde(default)]
167    gid: Option<u32>,
168    /// Its permissions in octal (`"0444"`): default `0644` for `source` and `content`, `0400`
169    /// for `input` and `secret` (only root reads it; widen it for an app's own user). Not on
170    /// Windows, where a value file is readable by SYSTEM and Administrators.
171    #[serde(default)]
172    mode: Option<Mode>,
173}
174
175impl TryFrom<FileInput> for File {
176    type Error = Error;
177
178    fn try_from(f: FileInput) -> Result<File, Error> {
179        let given: Vec<FileSource> = [
180            f.source.map(FileSource::Source),
181            f.content.map(FileSource::Content),
182            f.input.map(FileSource::Input),
183            f.secret.map(FileSource::Secret),
184        ]
185        .into_iter()
186        .flatten()
187        .collect();
188        let source = match <[FileSource; 1]>::try_from(given) {
189            Ok([one]) => one,
190            Err(given) if given.is_empty() => return Err(Error::NoFileSource),
191            Err(given) => return Err(Error::FileSources(given.iter().map(FileSource::key).collect())),
192        };
193        Ok(File {
194            path: f.path,
195            source,
196            uid: f.uid,
197            gid: f.gid,
198            mode: f.mode,
199        })
200    }
201}
202
203impl JsonSchema for File {
204    fn schema_name() -> std::borrow::Cow<'static, str> {
205        "File".into()
206    }
207
208    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
209        FileInput::json_schema(g)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn a_file_has_one_source_and_a_mode_by_kind() {
219        let value: File = serde_yaml_ng::from_str("{ path: /ctf/flag, input: FLAG }").unwrap();
220        assert!(value.source.is_value());
221        assert_eq!(value.mode_or_default().to_string(), "0400");
222        let widened: File = serde_yaml_ng::from_str("{ path: /a, content: x, mode: 0444 }").unwrap();
223        assert_eq!(widened.mode_or_default(), Mode(0o444));
224        let config: File = serde_yaml_ng::from_str("{ path: /a, source: a.ini }").unwrap();
225        assert_eq!(config.mode_or_default(), Mode::CONFIG);
226    }
227
228    #[test]
229    fn a_mode_is_octal_text_or_the_number_yaml_reads() {
230        assert_eq!(serde_yaml_ng::from_str::<Mode>("\"0444\"").map(Mode::bits).ok(), Some(0o444));
231        assert_eq!(serde_yaml_ng::from_str::<Mode>("640").map(Mode::bits).ok(), Some(0o640));
232        assert_eq!(serde_json::to_string(&Mode::VALUE).ok().as_deref(), Some("\"0400\""));
233        let wrong = serde_yaml_ng::from_str::<Mode>("[4]").unwrap_err().to_string();
234        assert!(wrong.contains("octal digits, like \"0444\""), "{wrong}");
235    }
236
237    #[test]
238    fn a_file_with_two_sources_or_none_is_refused() {
239        let two = serde_yaml_ng::from_str::<File>("{ path: /a, source: a, input: FLAG }").unwrap_err().to_string();
240        assert!(two.contains("`source` and `input`"), "{two}");
241        let none = serde_yaml_ng::from_str::<File>("{ path: /a }").unwrap_err().to_string();
242        assert!(none.contains("needs one of"), "{none}");
243        assert!(serde_yaml_ng::from_str::<File>("{ path: /a, content: x, mode: 0999 }").is_err());
244    }
245}