Skip to main content

qcode/profile/
own.rs

1//! What a person added to a profile by hand, in the profile's shell: the commands they ran as the
2//! administrator and the files of the home they changed.
3//!
4//! The profile's image already holds both, committed from the shell's container. They are kept
5//! here as well, beside the definition, so that a rebuild — which starts from the recipe and
6//! fetches everything again — can put them back: the commands run again as `RUN` steps, the files
7//! come back from `own-home.tar`.
8//!
9//! ```text
10//! {store}/Profiles/<profile>/own.toml        the commands and the home's paths
11//! {store}/Profiles/<profile>/own-home.tar    the home's files, as the shell left them
12//! ```
13//!
14//! They live apart from `<profile>.toml` so that a profile made before this existed, or copied
15//! without its folder, still reads as it did; a folder that is not there means nothing was added.
16
17use std::fmt::Write as _;
18use std::io;
19use std::path::{Path, PathBuf};
20
21use qframe::diagnostics::Diagnostic;
22use qframe::document::{Document, Shape, ValueKind};
23use qframe::storage::atomic_write;
24
25use crate::base::paths::{HOME_DIR, OPEN_HOME, USER};
26use crate::profile::SafeName;
27
28/// The file the commands and the home's paths are kept in.
29const FILE: &str = "own.toml";
30
31/// The archive of the home's files.
32pub const HOME_ARCHIVE: &str = "own-home.tar";
33
34/// Where a build finds the archive, inside its context and inside the image while it unpacks.
35const STAGED: &str = "/tmp/qcode-own-home.tar";
36
37/// One command, in the file.
38const STEP: &str = "step";
39/// One path of the home, in the file.
40const HOME: &str = "home";
41/// The key both carry their text under.
42const RUN: &str = "run";
43const PATH: &str = "path";
44
45/// What a person added to one profile by hand.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct Own {
48    /// The commands they ran as the administrator, in the order they ran them, as they reviewed
49    /// them.
50    pub steps: Vec<String>,
51    /// The files and folders of the home they changed, relative to the home.
52    pub home: Vec<String>,
53}
54
55impl Own {
56    /// The folder a profile's additions are kept in, inside the store's `Profiles/`.
57    #[must_use]
58    pub fn folder(profiles: &Path, profile: &SafeName) -> PathBuf {
59        profiles.join(profile.as_str())
60    }
61
62    /// The archive of the home's files of a profile.
63    #[must_use]
64    pub fn archive(profiles: &Path, profile: &SafeName) -> PathBuf {
65        Self::folder(profiles, profile).join(HOME_ARCHIVE)
66    }
67
68    /// Whether nothing was added.
69    #[must_use]
70    pub fn is_empty(&self) -> bool {
71        self.steps.is_empty() && self.home.is_empty()
72    }
73
74    /// Reads a profile's additions. A folder or file that is not there is nothing added; a file
75    /// that cannot be read or understood is reported with its place and read as far as it goes,
76    /// never panicked over.
77    #[must_use]
78    pub fn load(profiles: &Path, profile: &SafeName) -> (Self, Vec<Diagnostic>) {
79        let path = Self::folder(profiles, profile).join(FILE);
80        match std::fs::read_to_string(&path) {
81            Ok(text) => Self::parse(&path.to_string_lossy(), &text),
82            Err(error) if error.kind() == io::ErrorKind::NotFound => (Self::default(), Vec::new()),
83            Err(error) => (Self::default(), vec![Diagnostic::error(None, format!("{}: {error}", path.display()))]),
84        }
85    }
86
87    /// Reads the text of an `own.toml`.
88    #[must_use]
89    pub fn parse(file: &str, text: &str) -> (Self, Vec<Diagnostic>) {
90        let document = Document::parse(file, text, &shape());
91        let root = document.root();
92        let steps = root.entries(STEP).iter().filter_map(|entry| entry.text(RUN)).map(str::to_owned).collect();
93        let home = root.entries(HOME).iter().filter_map(|entry| entry.text(PATH)).map(str::to_owned).collect();
94        (Self { steps, home }, document.diagnostics().to_vec())
95    }
96
97    /// The text of an `own.toml`.
98    #[must_use]
99    pub fn to_toml(&self) -> String {
100        let mut text = String::new();
101        for step in &self.steps {
102            let _ = write!(text, "[[{STEP}]]\n{RUN} = {}\n\n", quoted(step));
103        }
104        for path in &self.home {
105            let _ = write!(text, "[[{HOME}]]\n{PATH} = {}\n\n", quoted(path));
106        }
107        text
108    }
109
110    /// Writes a profile's additions, or removes the file when nothing is left of them.
111    ///
112    /// # Errors
113    ///
114    /// A diagnostic naming the folder or file that could not be written.
115    pub fn save(&self, profiles: &Path, profile: &SafeName) -> Result<(), Diagnostic> {
116        let folder = Self::folder(profiles, profile);
117        let path = folder.join(FILE);
118        let blocked = |error: &io::Error| Diagnostic::error(None, format!("{}: {error}", path.display()));
119        if self.is_empty() {
120            return match std::fs::remove_file(&path) {
121                Ok(()) => Ok(()),
122                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
123                Err(error) => Err(blocked(&error)),
124            };
125        }
126        std::fs::create_dir_all(&folder).map_err(|error| blocked(&error))?;
127        atomic_write(&path, self.to_toml().as_bytes()).map_err(|error| blocked(&error))
128    }
129
130    /// Adds what one session in the shell added: its commands after the ones already kept, its
131    /// paths beside them, each path once.
132    pub fn absorb(&mut self, steps: Vec<String>, home: Vec<String>) {
133        self.steps.extend(steps);
134        for path in home {
135            if !self.home.contains(&path) {
136                self.home.push(path);
137            }
138        }
139    }
140
141    /// What a build appends to a profile's recipe to put the additions back: the commands as
142    /// root, one `RUN` each so a failing one is named by the engine's own log, then the home's
143    /// files, and last the step that opens the home to whoever runs the container, as the recipe
144    /// ends. `with_archive` says whether the archive is in the build context.
145    #[must_use]
146    pub fn containerfile_tail(&self, with_archive: bool) -> String {
147        if self.steps.is_empty() && !with_archive {
148            return String::new();
149        }
150        let mut lines = vec!["USER root".to_owned()];
151        for step in &self.steps {
152            lines.push(format!("RUN {step}"));
153        }
154        if with_archive {
155            lines.push(format!("COPY {HOME_ARCHIVE} {STAGED}"));
156            lines.push(format!("RUN tar -xf {STAGED} -C {HOME_DIR} && rm -f {STAGED}"));
157        }
158        // What an administrator's command left in the package lists is not the person's.
159        lines.push("RUN rm -rf /var/lib/apt/lists/*".to_owned());
160        lines.push(format!("RUN {OPEN_HOME}"));
161        lines.push(format!("USER {USER}"));
162        lines.push(String::new());
163        lines.join("\n")
164    }
165}
166
167/// The shape of `own.toml`.
168fn shape() -> Shape {
169    Shape::new()
170        .entries(STEP, Shape::new().required(RUN, ValueKind::text()))
171        .entries(HOME, Shape::new().required(PATH, ValueKind::text()))
172}
173
174/// `text` as one TOML basic string.
175fn quoted(text: &str) -> String {
176    let mut spelled = String::from("\"");
177    for character in text.chars() {
178        match character {
179            '"' => spelled.push_str("\\\""),
180            '\\' => spelled.push_str("\\\\"),
181            '\n' => spelled.push_str("\\n"),
182            '\t' => spelled.push_str("\\t"),
183            '\r' => spelled.push_str("\\r"),
184            other if other.is_control() => {
185                let _ = write!(spelled, "\\u{:04X}", u32::from(other));
186            }
187            other => spelled.push(other),
188        }
189    }
190    spelled.push('"');
191    spelled
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn name() -> SafeName {
199        SafeName::parse("own-test").expect("safe")
200    }
201
202    #[test]
203    fn what_is_written_reads_back_as_itself_whatever_the_commands_hold() {
204        let own = Own {
205            steps: vec![
206                "apt-get install -y jq".to_owned(),
207                "printf '%s\\n' \"a \\\"quoted\\\" word\" > /etc/motd".to_owned(),
208                "echo tab\there".to_owned(),
209            ],
210            home: vec!["notes.txt".to_owned(), ".config/tool/settings.json".to_owned()],
211        };
212        let (read, diagnostics) = Own::parse("own.toml", &own.to_toml());
213        assert!(diagnostics.is_empty(), "{diagnostics:?}");
214        assert_eq!(read, own);
215    }
216
217    #[test]
218    fn a_broken_file_is_reported_at_its_place_and_not_panicked_over() {
219        let (read, diagnostics) =
220            Own::parse("own.toml", "[[step]]\nrun = \"apt-get install -y jq\"\n\n[[step]]\nrun = 3\n");
221        assert_eq!(read.steps, ["apt-get install -y jq"]);
222        let said = format!("{diagnostics:?}");
223        assert!(!diagnostics.is_empty() && said.contains("own.toml"), "{said}");
224    }
225
226    #[test]
227    fn a_profile_with_no_folder_has_nothing_added_and_saving_nothing_leaves_no_file() {
228        let root = std::env::temp_dir().join(format!("qcode-own-{}", std::process::id()));
229        let (own, diagnostics) = Own::load(&root, &name());
230        assert!(own.is_empty() && diagnostics.is_empty());
231        let mut own = Own::default();
232        own.absorb(vec!["apt-get install -y jq".to_owned()], vec!["a".to_owned(), "a".to_owned()]);
233        own.save(&root, &name()).expect("saved");
234        assert_eq!(
235            Own::load(&root, &name()).0,
236            Own { steps: vec!["apt-get install -y jq".to_owned()], home: vec!["a".to_owned()] }
237        );
238        Own::default().save(&root, &name()).expect("emptied");
239        assert!(!Own::folder(&root, &name()).join(FILE).exists());
240        let _ = std::fs::remove_dir_all(&root);
241    }
242
243    #[test]
244    fn a_rebuild_runs_the_commands_as_root_then_unpacks_the_home_and_ends_as_the_images_user() {
245        let own = Own {
246            steps: vec!["apt-get update".to_owned(), "apt-get install -y jq".to_owned()],
247            home: vec!["n".to_owned()],
248        };
249        let tail = own.containerfile_tail(true);
250        let lines: Vec<&str> = tail.lines().collect();
251        assert_eq!(lines[0], "USER root");
252        assert_eq!(lines[1], "RUN apt-get update");
253        assert_eq!(lines[2], "RUN apt-get install -y jq");
254        assert_eq!(lines[3], "COPY own-home.tar /tmp/qcode-own-home.tar");
255        assert!(lines[4].starts_with("RUN tar -xf /tmp/qcode-own-home.tar -C /home/qcode"), "{tail}");
256        assert_eq!(lines.last(), Some(&"USER qcode"));
257        assert!(tail.contains("RUN qcode-open-home"), "{tail}");
258        assert_eq!(Own::default().containerfile_tail(false), "");
259    }
260}