1use tatara_nix::derivation::{Derivation, Outputs, Source};
29use tatara_os::SshdSpec;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct InitrdFile {
34 pub path: String,
36 pub content: InitrdContent,
38 pub mode: u32,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum InitrdContent {
44 Inline(String),
45 StorePath(String),
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GuestPackage {
54 pub attr_path: String,
56 pub bin_names: Vec<String>,
58}
59
60impl GuestPackage {
61 pub fn new(attr_path: impl Into<String>) -> Self {
62 Self {
63 attr_path: attr_path.into(),
64 bin_names: vec![],
65 }
66 }
67}
68
69pub struct LinuxRootfs {
71 pub init_binary: String,
72 pub init_config: String,
73 pub extra_files: Vec<InitrdFile>,
74 pub busybox: Option<String>,
76 pub sshd: Option<SshdSpec>,
82 pub packages: Vec<GuestPackage>,
85 pub name: String,
87}
88
89impl Default for LinuxRootfs {
90 fn default() -> Self {
91 Self {
92 init_binary: String::new(),
93 init_config: String::new(),
94 extra_files: vec![],
95 busybox: Some("busybox".into()),
96 sshd: None,
97 packages: vec![],
98 name: "tatara-rootfs".into(),
99 }
100 }
101}
102
103impl LinuxRootfs {
104 pub fn new(init_binary: impl Into<String>, init_config: impl Into<String>) -> Self {
105 Self {
106 init_binary: init_binary.into(),
107 init_config: init_config.into(),
108 ..Default::default()
109 }
110 }
111
112 pub fn with_name(mut self, n: impl Into<String>) -> Self {
113 self.name = n.into();
114 self
115 }
116
117 pub fn with_file(mut self, path: impl Into<String>, content: impl Into<String>) -> Self {
118 self.extra_files.push(InitrdFile {
119 path: path.into(),
120 content: InitrdContent::Inline(content.into()),
121 mode: 0o644,
122 });
123 self
124 }
125
126 pub fn with_file_from_store(
127 mut self,
128 path: impl Into<String>,
129 store_path: impl Into<String>,
130 ) -> Self {
131 self.extra_files.push(InitrdFile {
132 path: path.into(),
133 content: InitrdContent::StorePath(store_path.into()),
134 mode: 0o644,
135 });
136 self
137 }
138
139 pub fn without_busybox(mut self) -> Self {
140 self.busybox = None;
141 self
142 }
143
144 pub fn with_sshd(mut self, spec: SshdSpec) -> Self {
147 self.sshd = Some(spec);
148 self
149 }
150
151 pub fn with_package(mut self, attr_path: impl Into<String>) -> Self {
154 self.packages.push(GuestPackage::new(attr_path));
155 self
156 }
157
158 pub fn with_packages<I, S>(mut self, attrs: I) -> Self
160 where
161 I: IntoIterator<Item = S>,
162 S: Into<String>,
163 {
164 for a in attrs {
165 self.packages.push(GuestPackage::new(a));
166 }
167 self
168 }
169
170 pub fn derivation(&self) -> Derivation {
172 Derivation {
173 name: self.name.clone(),
174 version: None,
175 inputs: vec![],
176 source: Source::default(),
177 builder: Default::default(),
178 outputs: Outputs::default(),
179 env: vec![],
180 sandbox: Default::default(),
181 bridge: None,
182 nix_expr: Some(self.to_nix_expr()),
183 }
184 }
185
186 pub fn to_nix_expr(&self) -> String {
188 let busybox_line = match &self.busybox {
189 Some(attr) => format!(
190 " mkdir -p root/bin\n\
191 \x20 cp ${{pkgs.{attr}}}/bin/busybox root/bin/busybox\n\
192 \x20 # Install busybox applet symlinks so sh/mount/mkdir/… work\n\
193 \x20 for app in $(root/bin/busybox --list); do\n\
194 \x20 ln -sf /bin/busybox root/bin/$app\n\
195 \x20 done\n"
196 ),
197 None => String::new(),
198 };
199
200 let (pkg_prelude, pkg_block) = if self.packages.is_empty() {
203 (String::new(), String::new())
204 } else {
205 let mut prelude = String::from(" guestPackages = [\n");
206 for p in &self.packages {
207 prelude.push_str(&format!(" pkgs.{}\n", p.attr_path));
208 }
209 prelude.push_str(" ];\n");
210 prelude.push_str(" guestClosure = pkgs.closureInfo { rootPaths = guestPackages; };\n");
211
212 let mut block = String::from(
213 " # userspace packages: pull the full closure into root/nix/store\n\
214 \x20 mkdir -p root/nix/store root/bin\n\
215 \x20 while read -r store_path; do\n\
216 \x20 cp -r \"$store_path\" root/nix/store/\n\
217 \x20 done < ${guestClosure}/store-paths\n\
218 \x20 # Symlink each package's bin/* into /bin (best-effort — skip\n\
219 \x20 # packages with no bin/ dir).\n",
220 );
221 for p in &self.packages {
222 if p.bin_names.is_empty() {
223 block.push_str(&format!(
224 " if [ -d ${{pkgs.{attr}}}/bin ]; then\n\
225 \x20 for bin in ${{pkgs.{attr}}}/bin/*; do\n\
226 \x20 [ -e \"$bin\" ] && ln -sf \"$bin\" root/bin/$(basename \"$bin\")\n\
227 \x20 done\n\
228 \x20 fi\n",
229 attr = p.attr_path,
230 ));
231 } else {
232 for name in &p.bin_names {
233 block.push_str(&format!(
234 " [ -e ${{pkgs.{attr}}}/bin/{name} ] && ln -sf ${{pkgs.{attr}}}/bin/{name} root/bin/{name}\n",
235 attr = p.attr_path,
236 name = name,
237 ));
238 }
239 }
240 }
241 (prelude, block)
242 };
243
244 let (sshd_prelude, sshd_block) = match &self.sshd {
248 Some(s) => {
249 let auth_keys = s.authorized_keys.join("\n");
250 let permit_root = if s.permit_root { "yes" } else { "no" };
251 let pass_auth = if s.password_authentication {
252 "yes"
253 } else {
254 "no"
255 };
256 let cfg = format!(
257 "Port {port}\n\
258 HostKey /etc/ssh/ssh_host_ed25519_key\n\
259 PermitRootLogin {permit_root}\n\
260 PasswordAuthentication {pass_auth}\n\
261 PubkeyAuthentication yes\n\
262 AuthorizedKeysFile /etc/ssh/authorized_keys\n\
263 StrictModes no\n\
264 UsePAM no\n\
265 Subsystem sftp internal-sftp\n",
266 port = s.port,
267 permit_root = permit_root,
268 pass_auth = pass_auth,
269 );
270 let prelude = " openssh = pkgs.openssh;\n\
272 \x20 opensshClosure = pkgs.closureInfo { rootPaths = [ pkgs.openssh ]; };\n";
273 let block = format!(
275 " # openssh: bring the closure into root/nix/store\n\
276 \x20 mkdir -p root/nix/store root/bin root/etc/ssh root/var/empty\n\
277 \x20 while read -r store_path; do\n\
278 \x20 cp -r \"$store_path\" root/nix/store/\n\
279 \x20 done < ${{opensshClosure}}/store-paths\n\
280 \x20 ln -sf ${{openssh}}/bin/sshd root/bin/sshd\n\
281 \x20 ln -sf ${{openssh}}/bin/ssh-keygen root/bin/ssh-keygen\n\
282 \x20 cat > root/etc/ssh/sshd_config <<'TATARA_SSHD_CFG_EOF'\n\
283 {cfg}TATARA_SSHD_CFG_EOF\n\
284 \x20 cat > root/etc/ssh/authorized_keys <<'TATARA_AUTH_KEYS_EOF'\n\
285 {auth_keys}\n\
286 TATARA_AUTH_KEYS_EOF\n\
287 \x20 chmod 0600 root/etc/ssh/authorized_keys\n\
288 \x20 # Deterministic(-ish) host key: generate at build.\n\
289 \x20 ${{openssh}}/bin/ssh-keygen -t ed25519 -N '' \\\n\
290 \x20 -f root/etc/ssh/ssh_host_ed25519_key \\\n\
291 \x20 -C \"tatara-os-{name}\"\n\
292 \x20 chmod 0600 root/etc/ssh/ssh_host_ed25519_key\n",
293 cfg = cfg,
294 auth_keys = auth_keys,
295 name = self.name,
296 );
297 (prelude, block)
298 }
299 None => ("", String::new()),
300 };
301
302 let mut file_cmds = String::new();
303 for f in &self.extra_files {
304 let dir = match f.path.rsplit_once('/') {
306 Some((d, _)) if !d.is_empty() => d.to_string(),
307 _ => "".to_string(),
308 };
309 if !dir.is_empty() {
310 file_cmds.push_str(&format!(" mkdir -p root{}\n", nix_path_escape(&dir)));
311 }
312 match &f.content {
313 InitrdContent::Inline(body) => {
314 let body_nl = if body.ends_with('\n') {
317 body.clone()
318 } else {
319 format!("{body}\n")
320 };
321 file_cmds.push_str(&format!(
322 " cat > root{} <<'TATARA_ROOTFS_EOF'\n{body_nl}TATARA_ROOTFS_EOF\n",
323 nix_path_escape(&f.path)
324 ));
325 }
326 InitrdContent::StorePath(sp) => {
327 file_cmds.push_str(&format!(" cp {sp} root{}\n", nix_path_escape(&f.path)));
328 }
329 }
330 let mode = f.mode;
331 file_cmds.push_str(&format!(
332 " chmod {mode:o} root{}\n",
333 nix_path_escape(&f.path)
334 ));
335 }
336
337 let init_config_escaped = {
338 let s = heredoc_escape(&self.init_config);
339 if s.ends_with('\n') {
340 s
341 } else {
342 format!("{s}\n")
343 }
344 };
345
346 format!(
347 r#"let
348 pkgs = import <nixpkgs> {{}};
349{pkg_prelude}{sshd_prelude}in
350pkgs.runCommand "{name}" {{
351 buildInputs = [ pkgs.cpio pkgs.gzip pkgs.coreutils pkgs.findutils ];
352}} ''
353 mkdir -p $out
354 mkdir -p root/bin root/sbin root/etc/tatara root/proc root/sys root/dev root/run root/tmp
355 # tatara-init — the PID 1 supervisor
356 cp {init_binary} root/bin/tatara-init
357 chmod 0755 root/bin/tatara-init
358 # Linux looks for /init at initramfs root before honoring kernel cmdline
359 # `init=…`. Symlink both so either path works.
360 ln -sf /bin/tatara-init root/init
361 ln -sf /bin/tatara-init root/sbin/init
362 # init.lisp — the service manifest
363 cat > root/etc/tatara/init.lisp <<'TATARA_INIT_LISP_EOF'
364{init_config_escaped}TATARA_INIT_LISP_EOF
365 chmod 0644 root/etc/tatara/init.lisp
366{busybox_line}{pkg_block}{sshd_block}{file_cmds} # cpio + gzip into initrd
367 ( cd root && find . -print0 | cpio -o -0 --format=newc ) | gzip -9 > $out/initrd.cpio.gz
368 # Emit the top-level filesystem tree too, for anyone who wants ext4 later.
369 cp -r root $out/rootfs
370''"#,
371 name = self.name,
372 init_binary = self.init_binary,
373 init_config_escaped = init_config_escaped,
374 busybox_line = busybox_line,
375 pkg_prelude = pkg_prelude,
376 pkg_block = pkg_block,
377 sshd_block = sshd_block,
378 sshd_prelude = sshd_prelude,
379 file_cmds = file_cmds,
380 )
381 }
382}
383
384fn nix_path_escape(s: &str) -> String {
385 s.to_string()
389}
390
391fn heredoc_escape(s: &str) -> String {
392 if s.contains("TATARA_INIT_LISP_EOF") {
394 s.replace("TATARA_INIT_LISP_EOF", "TATARA_INIT_LISP_ESC_EOF")
395 } else {
396 s.to_string()
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn minimal_rootfs_emits_expected_shape() {
406 let r = LinuxRootfs::new(
407 "/nix/store/xxx-tatara-init/bin/tatara-init",
408 "(definit :name \"plex\")",
409 );
410 let d = r.derivation();
411 assert_eq!(d.name, "tatara-rootfs");
412 let expr = d.nix_expr.as_ref().unwrap();
413 assert!(expr.contains("pkgs.cpio"));
414 assert!(expr.contains("pkgs.gzip"));
415 assert!(expr.contains("cp /nix/store/xxx-tatara-init/bin/tatara-init root/bin/tatara-init"));
416 assert!(expr.contains("ln -sf /bin/tatara-init root/sbin/init"));
417 assert!(expr.contains("cpio -o -0 --format=newc"));
418 assert!(expr.contains("gzip -9 > $out/initrd.cpio.gz"));
419 assert!(expr.contains("(definit :name \"plex\")"));
420 }
421
422 #[test]
423 fn busybox_applets_get_symlinked_by_default() {
424 let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "");
425 let expr = r.derivation().nix_expr.unwrap();
426 assert!(expr.contains("cp ${pkgs.busybox}/bin/busybox root/bin/busybox"));
427 assert!(expr.contains("for app in $(root/bin/busybox --list)"));
428 }
429
430 #[test]
431 fn without_busybox_drops_applet_installation() {
432 let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "").without_busybox();
433 let expr = r.derivation().nix_expr.unwrap();
434 assert!(!expr.contains("busybox"));
435 }
436
437 #[test]
438 fn extra_files_get_heredoc_blocks() {
439 let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "")
440 .with_file("/etc/hosts", "127.0.0.1 localhost\n")
441 .with_file("/etc/hostname", "plex-guest\n");
442 let expr = r.derivation().nix_expr.unwrap();
443 assert!(expr.contains("mkdir -p root/etc"));
444 assert!(expr.contains("cat > root/etc/hosts <<'TATARA_ROOTFS_EOF'"));
445 assert!(expr.contains("127.0.0.1 localhost"));
446 assert!(expr.contains("cat > root/etc/hostname <<'TATARA_ROOTFS_EOF'"));
447 }
448
449 #[test]
450 fn store_path_files_get_cp_commands() {
451 let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "").with_file_from_store(
452 "/etc/ssl/certs/ca-cert.pem",
453 "/nix/store/y-ca-bundle/cert.pem",
454 );
455 let expr = r.derivation().nix_expr.unwrap();
456 assert!(expr.contains("cp /nix/store/y-ca-bundle/cert.pem root/etc/ssl/certs/ca-cert.pem"));
457 }
458
459 #[test]
460 fn init_config_with_sentinel_is_escaped() {
461 let r = LinuxRootfs::new(
462 "/nix/store/x/bin/tatara-init",
463 "line1\nTATARA_INIT_LISP_EOF\nline3",
464 );
465 let expr = r.derivation().nix_expr.unwrap();
466 assert!(expr.contains("TATARA_INIT_LISP_ESC_EOF"));
469 }
470
471 #[test]
472 fn custom_name_propagates_to_derivation() {
473 let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "").with_name("plex-guest-initrd");
474 let d = r.derivation();
475 assert_eq!(d.name, "plex-guest-initrd");
476 let expr = d.nix_expr.unwrap();
477 assert!(expr.contains(r#"runCommand "plex-guest-initrd""#));
478 }
479}