Skip to main content

podbox/codegen/
quadlet.rs

1use std::path::{Path, PathBuf};
2
3use crate::config::{Config, GpuMode};
4use crate::env::HostEnv;
5use crate::xdg::ResolvedXdgDirs;
6
7fn home() -> PathBuf {
8    dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
9}
10
11/// Generate the `.build` Quadlet file.
12pub fn generate_build(config: &Config, containerfile_path: &Path) -> String {
13    let mut lines: Vec<String> = Vec::new();
14
15    lines.push("[Build]".into());
16    lines.push(format!(
17        "ImageTag=localhost/podbox-{}:latest",
18        config.image.name
19    ));
20    lines.push(format!("File={}", containerfile_path.to_string_lossy()));
21    lines.push(format!("Retry={}", config.image.pull_retry));
22    lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
23
24    lines.join("\n")
25}
26
27/// Generate the `.socket` Quadlet file.
28pub fn generate_socket(config: &Config) -> String {
29    let name = &config.container.name;
30    let host_service = format!("{}-host.service", name);
31    let mut lines: Vec<String> = Vec::new();
32
33    lines.push("[Unit]".into());
34    lines.push(format!("Description=podbox host-guest socket -- {}", name));
35    lines.push(String::new());
36
37    lines.push("[Socket]".into());
38    lines.push(format!("ListenStream=%t/podbox/{}.sock", name));
39    lines.push(format!("Service={}", host_service));
40    lines.push("SocketMode=0600".into());
41    lines.push("DirectoryMode=0700".into());
42    lines.push("RuntimeDirectory=podbox".into());
43    lines.push("RuntimeDirectoryMode=0700".into());
44    lines.push(String::new());
45
46    lines.push("[Install]".into());
47    lines.push("WantedBy=sockets.target".into());
48
49    lines.join("\n")
50}
51
52/// Generate the `.container` Quadlet file.
53///
54/// Pure function: all paths via HostEnv and ResolvedXdgDirs.
55pub fn generate_container(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs) -> String {
56    let name = &config.container.name;
57    let home_in_container = "/home/%u";
58    let mut lines: Vec<String> = Vec::new();
59
60    emit_unit(&mut lines, config, name);
61    emit_container_image(&mut lines, config, name, home_in_container, env);
62    emit_network(&mut lines, config);
63    emit_volumes(&mut lines, config, xdg, env, name, home_in_container);
64    emit_env(&mut lines, config, name, env);
65    emit_gpu(&mut lines, config, env);
66    emit_auto_update(&mut lines, config);
67    emit_podman_args(&mut lines, config);
68    emit_service_section(&mut lines, config);
69    emit_install_section(&mut lines, config);
70
71    lines.join("\n")
72}
73
74fn emit_unit(lines: &mut Vec<String>, config: &Config, name: &str) {
75    lines.push("[Unit]".into());
76    lines.push(format!("Description=podbox -- {}", name));
77    lines.push(format!("Requires={}.socket", name));
78    lines.push(format!("After={}.socket", name));
79    for dep in &config.systemd.requires {
80        lines.push(format!("Requires={}", dep));
81    }
82    for dep in &config.systemd.after {
83        lines.push(format!("After={}", dep));
84    }
85    if config.use_dbus_proxy() {
86        lines.push(format!("Requires={}-proxy.service", name));
87        lines.push(format!("After={}-proxy.service", name));
88    }
89    if config.use_wayland_proxy() {
90        lines.push(format!("Requires={}-compositor.service", name));
91        lines.push(format!("After={}-compositor.service", name));
92    }
93    lines.push("StartLimitBurst=5".into());
94    lines.push("StartLimitIntervalSec=30s".into());
95    lines.push(String::new());
96}
97
98fn emit_container_image(
99    lines: &mut Vec<String>,
100    config: &Config,
101    name: &str,
102    home_in_container: &str,
103    env: &HostEnv,
104) {
105    lines.push("[Container]".into());
106    if config.image.source().is_prebuilt() && config.image.packages.install.is_empty() {
107        let ref_str = match config.image.source() {
108            crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
109            _ => config.image.base.clone(),
110        };
111        lines.push(format!("Image={}", ref_str));
112        lines.push(format!("Retry={}", config.image.pull_retry));
113        lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
114    } else {
115        lines.push(format!(
116            "Image=localhost/podbox-{}:latest",
117            config.image.name
118        ));
119    }
120    lines.push(format!("ContainerName={}", name));
121    if let Some(ref mode) = config.security.userns {
122        lines.push(format!("UserNS={}", mode));
123    } else {
124        lines.push("UserNS=keep-id".into());
125    }
126    lines.push("User=root".into());
127    if config.security.security_label_disable {
128        lines.push("SecurityLabelDisable=true".into());
129    }
130    if let Some(ref seccomp) = config.security.seccomp {
131        lines.push(format!("SeccompProfile={}", seccomp));
132    }
133    if config.security.no_new_privileges {
134        lines.push("NoNewPrivileges=true".into());
135    }
136    if let Some(ref mem) = config.container.memory {
137        lines.push(format!("Memory={}", mem));
138    }
139    if let Some(ref cpus) = config.container.cpus {
140        if let Ok(v) = cpus.parse::<f64>() {
141            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
142            let quota = (v * 100_000.0) as u64;
143            lines.push(format!("CpuQuota={}", quota));
144        }
145    }
146    if config.security.read_only_rootfs {
147        lines.push("ReadOnly=true".into());
148    }
149    if let Some(ref profile) = config.security.apparmor {
150        lines.push(format!("AppArmor={}", profile));
151    }
152    lines.push(format!("Environment=HOME={}", home_in_container));
153    lines.push(format!("Environment=HOST_USER={}", env.username));
154    lines.push("Environment=HOST_UID=%U".into());
155    lines.push("Environment=HOST_GID=%G".into());
156    lines.push("Environment=PATH=/run/podbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
157    lines.push(String::new());
158}
159
160fn emit_network(lines: &mut Vec<String>, config: &Config) {
161    lines.push(format!("Network={}", config.network.mode));
162    if config.network.mode != "host" {
163        for port in &config.network.ports {
164            lines.push(format!("PublishPort={}", port));
165        }
166    }
167    lines.push(String::new());
168}
169
170fn emit_volumes(
171    lines: &mut Vec<String>,
172    config: &Config,
173    xdg: &ResolvedXdgDirs,
174    env: &HostEnv,
175    name: &str,
176    home_in_container: &str,
177) {
178    // Isolated custom home
179    let host_home = config.container.home.to_string_lossy().to_string();
180    lines.push(format!("Volume={host_home}:{home_in_container}:Z",));
181    lines.push(String::new());
182
183    // Selective XDG dirs
184    emit_xdg_dir(lines, "Documents", &xdg.documents, home_in_container);
185    emit_xdg_dir(lines, "Downloads", &xdg.downloads, home_in_container);
186    emit_xdg_dir(lines, "Pictures", &xdg.pictures, home_in_container);
187    emit_xdg_dir(lines, "Music", &xdg.music, home_in_container);
188    emit_xdg_dir(lines, "Videos", &xdg.videos, home_in_container);
189    emit_xdg_dir(lines, "Desktop", &xdg.desktop, home_in_container);
190    emit_xdg_dir(lines, "Projects", &xdg.projects, home_in_container);
191
192    if xdg.documents.is_some()
193        || xdg.downloads.is_some()
194        || xdg.pictures.is_some()
195        || xdg.music.is_some()
196        || xdg.videos.is_some()
197        || xdg.desktop.is_some()
198        || xdg.projects.is_some()
199    {
200        lines.push(String::new());
201    }
202
203    // Visual integration: themes, fonts, icons
204    if config.integration.sync_themes {
205        let h = home();
206        if h.join(".themes").exists() {
207            lines.push(format!("Volume=%h/.themes:{home_in_container}/.themes:ro"));
208        }
209        if env.host_has_local_share_themes {
210            lines.push(format!(
211                "Volume=%h/.local/share/themes:{home_in_container}/.local/share/themes:ro"
212            ));
213        }
214    }
215    if config.integration.sync_icons {
216        let h = home();
217        if h.join(".icons").exists() {
218            lines.push(format!("Volume=%h/.icons:{home_in_container}/.icons:ro"));
219        }
220        if env.host_has_local_share_icons {
221            lines.push(format!(
222                "Volume=%h/.local/share/icons:{home_in_container}/.local/share/icons:ro"
223            ));
224        }
225    }
226    if config.integration.sync_fonts {
227        let h = home();
228        if h.join(".fonts").exists() {
229            lines.push(format!("Volume=%h/.fonts:{home_in_container}/.fonts:ro"));
230        }
231        if env.host_has_local_share_fonts {
232            lines.push(format!(
233                "Volume=%h/.local/share/fonts:{home_in_container}/.local/share/fonts:ro"
234            ));
235        }
236    }
237    if config.integration.sync_themes
238        || config.integration.sync_icons
239        || config.integration.sync_fonts
240    {
241        lines.push(String::new());
242    }
243
244    // Timezone sync
245    if env.host_has_localtime {
246        lines.push("Volume=/etc/localtime:/etc/localtime:ro".into());
247    }
248    if env.host_has_timezone_file {
249        lines.push("Volume=/etc/timezone:/etc/timezone:ro".into());
250    }
251    if env.host_has_localtime || env.host_has_timezone_file {
252        lines.push(String::new());
253    }
254
255    // The guest daemon needs XDG_RUNTIME_DIR to locate the host socket
256    // regardless of Wayland/audio integration.
257    lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
258
259    // Wayland
260    if config.integration.wayland {
261        if let Some(ref display) = env.wayland_display {
262            lines.push(format!("Environment=WAYLAND_DISPLAY={}", display));
263            lines.push("Environment=MOZ_ENABLE_WAYLAND=1".into());
264            if config.wayland.firewall {
265                lines.push(format!(
266                    "Volume=%t/podbox/{}-wayland.sock:%t/{}:ro",
267                    name, display
268                ));
269            } else {
270                lines.push(format!("Volume=%t/{}:%t/{}:ro", display, display));
271            }
272            lines.push(String::new());
273        }
274    }
275
276    // Audio (PipeWire + PulseAudio)
277    if config.integration.audio {
278        if env.pipewire_socket.is_some() {
279            lines.push("Volume=%t/pipewire-0:%t/pipewire-0".into());
280            lines.push("Environment=PIPEWIRE_RUNTIME_DIR=%t".into());
281        }
282        if env.pulse_dir.is_some() {
283            lines.push("Volume=%t/pulse:%t/pulse".into());
284            lines.push("Environment=PULSE_SERVER=unix:%t/pulse/native".into());
285        }
286        if env.pipewire_socket.is_some() || env.pulse_dir.is_some() {
287            lines.push(String::new());
288        }
289    }
290
291    // SSH agent
292    if config.integration.ssh_agent {
293        if let Some(ref sock) = env.ssh_agent_socket {
294            lines.push(format!(
295                "Volume={}:/run/podbox/ssh-agent.sock",
296                sock.display()
297            ));
298            lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
299        } else {
300            eprintln!(
301                "Warning: ssh_agent = true but SSH_AUTH_SOCK not found on host. Skipping SSH agent."
302            );
303        }
304        lines.push(String::new());
305    }
306
307    // GPG agent
308    if config.integration.gpg_agent {
309        if let Some(ref sock) = env.gpg_agent_socket {
310            lines.push(format!(
311                "Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
312                sock.display()
313            ));
314            lines.push("Environment=GPG_TTY=/dev/pts/0".into());
315            lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
316        } else {
317            eprintln!(
318                "Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
319            );
320        }
321        lines.push(String::new());
322    }
323
324    // Sandbox environment detection marker (read-only host-side kernel mount)
325    let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
326    lines.push(format!(
327        "Volume={}:/.flatpak-info:ro",
328        flatpak_info_path.display()
329    ));
330    lines.push(String::new());
331
332    // D-Bus
333    if config.integration.dbus && env.dbus_socket.is_some() {
334        if config.use_dbus_proxy() {
335            lines.push(format!(
336                "Volume=%t/podbox/{}-dbus.sock:/run/podbox/dbus.sock:ro",
337                name
338            ));
339            lines.push(
340                "Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
341            );
342        } else {
343            lines.push("Volume=%t/bus:%t/bus".into());
344            lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
345        }
346        lines.push(String::new());
347    }
348
349    // Host-guest socket
350    lines.push(format!(
351        "Volume=%t/podbox/{}.sock:%t/podbox/{}.sock",
352        name, name
353    ));
354    lines.push(String::new());
355
356    // Extra mounts
357    for mount in &config.container.mounts.extra {
358        lines.push(format!("Volume={}", mount));
359    }
360    if !config.container.mounts.extra.is_empty() {
361        lines.push(String::new());
362    }
363}
364
365fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
366    // Locale environment
367    if let Some(ref locale) = _env.host_locale {
368        lines.push(format!("Environment=LANG={}", locale));
369        lines.push(format!("Environment=LC_ALL={}", locale));
370        lines.push(format!("Environment=LC_CTYPE={}", locale));
371        lines.push(String::new());
372    }
373
374    // Extra user env
375    for (key, value) in &config.container.env {
376        if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
377            let clean = value.replace('\n', " ").replace('\r', "");
378            let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
379            let env_val = if escaped.contains(' ') || escaped.is_empty() {
380                format!("\"{}\"", escaped)
381            } else {
382                escaped
383            };
384            lines.push(format!("Environment={}={}", key, env_val));
385        } else {
386            eprintln!(
387                "Warning: ignoring invalid environment variable key '{}'",
388                key
389            );
390        }
391    }
392    lines.push(format!("Environment=PODBOX_CONTAINER={}", name));
393    lines.push(String::new());
394}
395
396fn emit_gpu(lines: &mut Vec<String>, config: &Config, env: &HostEnv) {
397    match config.integration.gpu {
398        GpuMode::Enabled => {
399            lines.push("AddDevice=/dev/dri".into());
400            lines.push(String::new());
401        }
402        GpuMode::Nvidia => {
403            lines.push("AddDevice=/dev/dri".into());
404            lines.push("AddDevice=-/dev/nvidiactl".into());
405            lines.push("AddDevice=-/dev/nvidia0".into());
406            if env.gpu_has_nvidia_uvm {
407                lines.push("AddDevice=-/dev/nvidia-uvm".into());
408            }
409            lines.push(String::new());
410        }
411        GpuMode::Auto => {
412            if env.gpu_has_dri {
413                lines.push("AddDevice=/dev/dri".into());
414            }
415            if env.gpu_has_nvidia {
416                lines.push("AddDevice=-/dev/nvidiactl".into());
417                lines.push("AddDevice=-/dev/nvidia0".into());
418                if env.gpu_has_nvidia_uvm {
419                    lines.push("AddDevice=-/dev/nvidia-uvm".into());
420                }
421            }
422            if env.gpu_has_dri || env.gpu_has_nvidia {
423                lines.push(String::new());
424            }
425        }
426        GpuMode::Disabled => {}
427    }
428}
429
430fn emit_auto_update(lines: &mut Vec<String>, config: &Config) {
431    if config.lifecycle.auto_update {
432        if config.image.source().is_prebuilt() {
433            lines.push("AutoUpdate=registry".into());
434        } else {
435            lines.push("AutoUpdate=local".into());
436        }
437        lines.push(String::new());
438    }
439}
440
441fn emit_podman_args(lines: &mut Vec<String>, config: &Config) {
442    lines.push("PodmanArgs=--init".into());
443    lines.push("PodmanArgs=--workdir=/home/%u".into());
444    let cap_preset = config.security.cap_preset;
445    let has_any_cap = !cap_preset.caps().is_empty() || !config.security.cap_add.is_empty();
446    for cap in cap_preset.caps() {
447        lines.push(format!("PodmanArgs=--cap-add={}", cap));
448    }
449    for cap in &config.security.cap_add {
450        lines.push(format!("PodmanArgs=--cap-add={}", cap));
451    }
452    if has_any_cap {
453        lines.push(String::new());
454    }
455    if let Some(ref cmd) = config.container.reload_cmd {
456        lines.push(format!("ReloadCmd={}", cmd));
457        lines.push(String::new());
458    }
459}
460
461fn emit_service_section(lines: &mut Vec<String>, config: &Config) {
462    lines.push("[Service]".into());
463    lines.push("Restart=on-failure".into());
464    lines.push("RestartSec=2s".into());
465    if config.lifecycle.on_stop == crate::config::OnStop::Remove {
466        lines.push("AutoRemove=true".into());
467    }
468    lines.push(String::new());
469}
470
471fn emit_install_section(lines: &mut Vec<String>, config: &Config) {
472    lines.push("[Install]".into());
473    if config.lifecycle.autostart {
474        lines.push("WantedBy=default.target".into());
475    }
476}
477
478/// Generate the companion D-Bus proxy `.service` unit.
479pub fn generate_dbus_proxy_service(name: &str, config: &Config) -> Option<String> {
480    if !config.use_dbus_proxy() {
481        return None;
482    }
483
484    let mut args = vec![
485        "unix:path=%t/bus".to_string(),
486        format!("%t/podbox/{}-dbus.sock", name),
487    ];
488
489    args.push("--filter".into());
490
491    for service in &config.dbus_effective_talk() {
492        args.push(format!("--talk={}", service));
493    }
494    for rule in config.dbus_portal_calls() {
495        args.push(rule);
496    }
497    for service in &config.dbus.own {
498        args.push(format!("--own={}", service));
499    }
500
501    let exec_start = format!("/usr/bin/xdg-dbus-proxy {}", args.join(" "));
502
503    Some(format!(
504        r#"[Unit]
505Description=D-Bus Proxy for podbox container {name}
506PartOf={name}.service
507
508[Service]
509Type=simple
510ExecStart={exec_start}
511Restart=on-failure
512RestartSec=1s
513
514[Install]
515WantedBy={name}.service
516"#,
517        name = name,
518        exec_start = exec_start,
519    ))
520}
521
522/// Generate the companion Wayland firewall `.service` unit.
523/// Returns `None` when the Wayland proxy is disabled in config.
524pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
525    if !config.use_wayland_proxy() {
526        return None;
527    }
528    let podbox_bin = std::env::current_exe()
529        .map(|p| p.to_string_lossy().to_string())
530        .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
531
532    Some(format!(
533        r#"[Unit]
534Description=Wayland Firewall Proxy for podbox container {name}
535PartOf={name}.service
536
537[Service]
538Type=simple
539ExecStart={podbox_bin} compositor {name}
540Restart=on-failure
541RestartSec=1s
542
543[Install]
544WantedBy={name}.service
545"#,
546        name = name,
547        podbox_bin = podbox_bin,
548    ))
549}
550
551/// Generate the companion host socket server `.service` unit.
552pub fn generate_host_service(name: &str) -> String {
553    let podbox_bin = std::env::current_exe()
554        .map(|p| p.to_string_lossy().to_string())
555        .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
556
557    format!(
558        r#"[Unit]
559Description=podbox host socket server -- {name}
560
561[Service]
562Type=simple
563ExecStart={podbox_bin} serve {name}
564Restart=on-failure
565RestartSec=2s
566
567[Install]
568WantedBy={name}.socket
569"#,
570        name = name,
571        podbox_bin = podbox_bin,
572    )
573}
574
575fn emit_xdg_dir(
576    lines: &mut Vec<String>,
577    dir_name: &str,
578    xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
579    container_home: &str,
580) {
581    if let Some(resolved) = xdg_dir {
582        let mode = if resolved.read_write { "z" } else { "ro,z" };
583        lines.push(format!(
584            "Volume={}:{container_home}/{dir_name}:{mode}",
585            resolved.path.display()
586        ));
587    }
588}