1use 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
28const FILE: &str = "own.toml";
30
31pub const HOME_ARCHIVE: &str = "own-home.tar";
33
34const STAGED: &str = "/tmp/qcode-own-home.tar";
36
37const STEP: &str = "step";
39const HOME: &str = "home";
41const RUN: &str = "run";
43const PATH: &str = "path";
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct Own {
48 pub steps: Vec<String>,
51 pub home: Vec<String>,
53}
54
55impl Own {
56 #[must_use]
58 pub fn folder(profiles: &Path, profile: &SafeName) -> PathBuf {
59 profiles.join(profile.as_str())
60 }
61
62 #[must_use]
64 pub fn archive(profiles: &Path, profile: &SafeName) -> PathBuf {
65 Self::folder(profiles, profile).join(HOME_ARCHIVE)
66 }
67
68 #[must_use]
70 pub fn is_empty(&self) -> bool {
71 self.steps.is_empty() && self.home.is_empty()
72 }
73
74 #[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 #[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 #[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 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 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 #[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 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
167fn 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
174fn 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}