Skip to main content

isoloom_spec/syntax/step/
files.rs

1//! The steps that place files: `copy` (a project file or folder as it is), `template` (a project
2//! file with its `{{ path }}` placeholders filled in at generation) and `file` (a folder, a
3//! removal, a link or a pinned download).
4
5use schemars::JsonSchema;
6use serde::Deserialize;
7
8use super::super::files::Mode;
9use super::super::mistake::Mistake;
10use super::super::problem::Code;
11use super::words::{AbsPath, GroupName, HttpsUrl, ProjectPath, Sha256, UserName};
12
13/// A `copy` step: a project file or folder copied into the machine as it is.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct Copy {
17    /// A file or folder of the project (a folder ends with `/`).
18    pub from: ProjectPath,
19    /// Where it goes in the machine (absolute).
20    pub to: AbsPath,
21    /// The owner, a user name (default root). Not on Windows.
22    #[serde(default)]
23    pub owner: Option<UserName>,
24    /// The mode, octal (default: as in the project). Not on Windows.
25    #[serde(default)]
26    pub mode: Option<Mode>,
27}
28
29impl Copy {
30    /// Whether it copies a folder (its `from` ends with `/`).
31    #[must_use]
32    pub fn is_folder(&self) -> bool {
33        self.from.as_str().ends_with('/')
34    }
35}
36
37/// A `template` step: a project file whose `{{ path }}` placeholders Isoloom fills in when it
38/// generates, so every CM tool installs the same bytes.
39#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
40#[serde(try_from = "TemplateInput")]
41pub struct Template {
42    /// The project file.
43    pub from: ProjectPath,
44    /// Where the filled-in file goes in the machine.
45    pub to: AbsPath,
46    /// Its owner (default root).
47    pub owner: Option<UserName>,
48    /// Its group (default the owner's).
49    pub group: Option<GroupName>,
50    /// Its mode (default `0644`).
51    pub mode: Option<Mode>,
52}
53
54schema_as!(Template, TemplateInput);
55
56/// A `template` step as written.
57#[derive(Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59struct TemplateInput {
60    /// A file of the project holding `{{ path }}` placeholders: `{{ machine.name }}`,
61    /// `{{ machine.addresses.<network> }}`, `{{ machines.<m>.addresses.<network> }}` and
62    /// `{{ machines.<m>.ports.<name>.port }}`. Filled in when Isoloom generates; no logic.
63    from: ProjectPath,
64    /// Where the filled-in file goes in the machine (absolute).
65    to: AbsPath,
66    /// Its owner, a user name (default root). Not on Windows.
67    #[serde(default)]
68    owner: Option<UserName>,
69    /// Its group (default the owner's). Not on Windows.
70    #[serde(default)]
71    group: Option<GroupName>,
72    /// Its mode, octal (default `0644`). Not on Windows.
73    #[serde(default)]
74    mode: Option<Mode>,
75}
76
77impl TryFrom<TemplateInput> for Template {
78    type Error = Mistake;
79
80    fn try_from(t: TemplateInput) -> Result<Template, Mistake> {
81        if t.from.as_str().ends_with('/') {
82            return Err(Mistake::new(Code::InvalidValue, format!("`{}`: a template is one file", t.from))
83                .at("from")
84                .fix("a `copy` step copies a folder"));
85        }
86        Ok(Template {
87            from: t.from,
88            to: t.to,
89            owner: t.owner,
90            group: t.group,
91            mode: t.mode,
92        })
93    }
94}
95
96/// A `file` step: a path made a folder, removed, made a link, or downloaded.
97#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
98#[serde(try_from = "FileInput")]
99pub struct FileStep {
100    /// The path in the machine.
101    pub path: AbsPath,
102    /// What it becomes.
103    pub state: FileState,
104    /// Its owner (default root).
105    pub owner: Option<UserName>,
106    /// Its group (default the owner's).
107    pub group: Option<GroupName>,
108    /// Its mode.
109    pub mode: Option<Mode>,
110}
111
112schema_as!(FileStep, FileInput);
113
114/// What a `file` step makes of its path.
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116#[non_exhaustive]
117pub enum FileState {
118    /// A folder (with its parents).
119    Directory,
120    /// Nothing: the path is removed. The model's only removal.
121    Absent,
122    /// A symbolic link to this target.
123    Link(AbsPath),
124    /// A file downloaded from `url`, kept only when its SHA-256 is `sha256`.
125    Download {
126        /// Where it comes from.
127        url: HttpsUrl,
128        /// What it must hash to.
129        sha256: Sha256,
130    },
131}
132
133/// `state:` as written.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
135#[serde(rename_all = "lowercase")]
136enum StateWord {
137    /// A folder.
138    Directory,
139    /// Removed.
140    Absent,
141}
142
143/// A `file` step as written.
144#[derive(Deserialize, JsonSchema)]
145#[serde(deny_unknown_fields)]
146struct FileInput {
147    /// The path in the machine (absolute).
148    path: AbsPath,
149    /// `directory` (a folder, with its parents) or `absent` (removed: the model's only removal).
150    /// Not with `link` or `url`.
151    #[serde(default)]
152    state: Option<StateWord>,
153    /// A symbolic link to this target (absolute). Not on Windows.
154    #[serde(default)]
155    link: Option<AbsPath>,
156    /// A file to download (`https`), with its `sha256`: every fetch is pinned.
157    #[serde(default)]
158    url: Option<HttpsUrl>,
159    /// The SHA-256 of what `url` fetches (64 hex digits, as `sha256sum` prints it).
160    #[serde(default)]
161    sha256: Option<Sha256>,
162    /// Its owner, a user name (default root). Not on Windows.
163    #[serde(default)]
164    owner: Option<UserName>,
165    /// Its group (default the owner's). Not on Windows.
166    #[serde(default)]
167    group: Option<GroupName>,
168    /// Its mode, octal. Not on Windows, nor with `state: absent` or `link`.
169    #[serde(default)]
170    mode: Option<Mode>,
171}
172
173impl TryFrom<FileInput> for FileStep {
174    type Error = Mistake;
175
176    fn try_from(f: FileInput) -> Result<FileStep, Mistake> {
177        let state = state(f.state, f.link, f.url, f.sha256)?;
178        let owned = f.owner.is_some() || f.group.is_some() || f.mode.is_some();
179        match state {
180            FileState::Absent if owned => {
181                return Err(Mistake::new(
182                    Code::Conflict,
183                    "`state: absent` removes the path, so it has no owner, group or mode",
184                ));
185            }
186            FileState::Link(_) if f.mode.is_some() => {
187                return Err(Mistake::new(Code::Conflict, "a link has no mode of its own").at("mode"));
188            }
189            _ => {}
190        }
191        Ok(FileStep {
192            path: f.path,
193            state,
194            owner: f.owner,
195            group: f.group,
196            mode: f.mode,
197        })
198    }
199}
200
201/// What the step makes of its path: exactly one of `state`, `link` and `url` (with `sha256`).
202fn state(word: Option<StateWord>, link: Option<AbsPath>, url: Option<HttpsUrl>, sha256: Option<Sha256>) -> Result<FileState, Mistake> {
203    let given = usize::from(word.is_some()) + usize::from(link.is_some()) + usize::from(url.is_some());
204    if given > 1 {
205        return Err(Mistake::new(Code::Conflict, "a file step does one thing: one of `state`, `link` and `url`"));
206    }
207    match (word, link, url, sha256) {
208        (_, _, Some(_), None) => Err(
209            Mistake::new(Code::MissingField, "every fetch is pinned: a download needs the SHA-256 of what it fetches")
210                .at("url")
211                .fix("`sha256: <hex>` (`sha256sum <file>` prints it)"),
212        ),
213        (_, _, None, Some(_)) => Err(Mistake::new(Code::InvalidValue, "`sha256` checks a download: it goes with `url`").at("sha256")),
214        (_, _, Some(url), Some(sha256)) => Ok(FileState::Download { url, sha256 }),
215        (Some(StateWord::Directory), ..) => Ok(FileState::Directory),
216        (Some(StateWord::Absent), ..) => Ok(FileState::Absent),
217        (None, Some(target), ..) => Ok(FileState::Link(target)),
218        (None, None, None, None) => Err(Mistake::new(Code::MissingField, "a file step says what to make of its `path`")
219            .fix("`state: directory`, `state: absent`, `link: <target>`, or `url` with `sha256`")),
220    }
221}