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        let ver = crate::podman::podman_version().ok();
294        if ver.is_some_and(|v| v.at_least(5, 6) && !v.at_least(6, 0)) {
295            lines.push("SshAgent=default".into());
296            lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
297        } else {
298            eprintln!(
299                "Warning: ssh_agent = true requires Podman 5.6 - 5.x for SSH_AUTH_SOCK passthrough. Skipping SSH agent."
300            );
301        }
302        lines.push(String::new());
303    }
304
305    // GPG agent
306    if config.integration.gpg_agent {
307        if let Some(ref sock) = env.gpg_agent_socket {
308            lines.push(format!(
309                "Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
310                sock.display()
311            ));
312            lines.push("Environment=GPG_TTY=/dev/pts/0".into());
313            lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
314        } else {
315            eprintln!(
316                "Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
317            );
318        }
319        lines.push(String::new());
320    }
321
322    // Sandbox environment detection marker (read-only host-side kernel mount)
323    let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
324    lines.push(format!(
325        "Volume={}:/.flatpak-info:ro",
326        flatpak_info_path.display()
327    ));
328    lines.push(String::new());
329
330    // D-Bus
331    if config.integration.dbus && env.dbus_socket.is_some() {
332        if config.use_dbus_proxy() {
333            lines.push(format!(
334                "Volume=%t/podbox/{}-dbus.sock:/run/podbox/dbus.sock:ro",
335                name
336            ));
337            lines.push(
338                "Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
339            );
340        } else {
341            lines.push("Volume=%t/bus:%t/bus".into());
342            lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
343        }
344        lines.push(String::new());
345    }
346
347    // Host-guest socket
348    lines.push(format!(
349        "Volume=%t/podbox/{}.sock:%t/podbox/{}.sock",
350        name, name
351    ));
352    lines.push(String::new());
353
354    // Extra mounts
355    for mount in &config.container.mounts.extra {
356        lines.push(format!("Volume={}", mount));
357    }
358    if !config.container.mounts.extra.is_empty() {
359        lines.push(String::new());
360    }
361}
362
363fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
364    // Locale environment
365    if let Some(ref locale) = _env.host_locale {
366        lines.push(format!("Environment=LANG={}", locale));
367        lines.push(format!("Environment=LC_ALL={}", locale));
368        lines.push(format!("Environment=LC_CTYPE={}", locale));
369        lines.push(String::new());
370    }
371
372    // Extra user env
373    for (key, value) in &config.container.env {
374        if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
375            let clean = value.replace('\n', " ").replace('\r', "");
376            let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
377            let env_val = if escaped.contains(' ') || escaped.is_empty() {
378                format!("\"{}\"", escaped)
379            } else {
380                escaped
381            };
382            lines.push(format!("Environment={}={}", key, env_val));
383        } else {
384            eprintln!(
385                "Warning: ignoring invalid environment variable key '{}'",
386                key
387            );
388        }
389    }
390    lines.push(format!("Environment=PODBOX_CONTAINER={}", name));
391    lines.push(String::new());
392}
393
394fn emit_gpu(lines: &mut Vec<String>, config: &Config, env: &HostEnv) {
395    match config.integration.gpu {
396        GpuMode::Enabled => {
397            lines.push("AddDevice=/dev/dri".into());
398            lines.push(String::new());
399        }
400        GpuMode::Nvidia => {
401            lines.push("AddDevice=/dev/dri".into());
402            lines.push("AddDevice=-/dev/nvidiactl".into());
403            lines.push("AddDevice=-/dev/nvidia0".into());
404            if env.gpu_has_nvidia_uvm {
405                lines.push("AddDevice=-/dev/nvidia-uvm".into());
406            }
407            lines.push(String::new());
408        }
409        GpuMode::Auto => {
410            if env.gpu_has_dri {
411                lines.push("AddDevice=/dev/dri".into());
412            }
413            if env.gpu_has_nvidia {
414                lines.push("AddDevice=-/dev/nvidiactl".into());
415                lines.push("AddDevice=-/dev/nvidia0".into());
416                if env.gpu_has_nvidia_uvm {
417                    lines.push("AddDevice=-/dev/nvidia-uvm".into());
418                }
419            }
420            if env.gpu_has_dri || env.gpu_has_nvidia {
421                lines.push(String::new());
422            }
423        }
424        GpuMode::Disabled => {}
425    }
426}
427
428fn emit_auto_update(lines: &mut Vec<String>, config: &Config) {
429    if config.lifecycle.auto_update {
430        if config.image.source().is_prebuilt() {
431            lines.push("AutoUpdate=registry".into());
432        } else {
433            lines.push("AutoUpdate=local".into());
434        }
435        lines.push(String::new());
436    }
437}
438
439fn emit_podman_args(lines: &mut Vec<String>, config: &Config) {
440    lines.push("PodmanArgs=--init".into());
441    lines.push("PodmanArgs=--workdir=/home/%u".into());
442    let cap_preset = config.security.cap_preset;
443    let has_any_cap = !cap_preset.caps().is_empty() || !config.security.cap_add.is_empty();
444    for cap in cap_preset.caps() {
445        lines.push(format!("PodmanArgs=--cap-add={}", cap));
446    }
447    for cap in &config.security.cap_add {
448        lines.push(format!("PodmanArgs=--cap-add={}", cap));
449    }
450    if has_any_cap {
451        lines.push(String::new());
452    }
453    if let Some(ref cmd) = config.container.reload_cmd {
454        lines.push(format!("ReloadCmd={}", cmd));
455        lines.push(String::new());
456    }
457}
458
459fn emit_service_section(lines: &mut Vec<String>, config: &Config) {
460    lines.push("[Service]".into());
461    lines.push("Restart=on-failure".into());
462    lines.push("RestartSec=2s".into());
463    if config.lifecycle.on_stop == crate::config::OnStop::Remove {
464        lines.push("AutoRemove=true".into());
465    }
466    lines.push(String::new());
467}
468
469fn emit_install_section(lines: &mut Vec<String>, config: &Config) {
470    lines.push("[Install]".into());
471    if config.lifecycle.autostart {
472        lines.push("WantedBy=default.target".into());
473    }
474}
475
476/// Generate the companion D-Bus proxy `.service` unit.
477pub fn generate_dbus_proxy_service(name: &str, config: &Config) -> Option<String> {
478    if !config.use_dbus_proxy() {
479        return None;
480    }
481
482    let mut args = vec![
483        "unix:path=%t/bus".to_string(),
484        format!("%t/podbox/{}-dbus.sock", name),
485    ];
486
487    args.push("--filter".into());
488
489    for service in &config.dbus_effective_talk() {
490        args.push(format!("--talk={}", service));
491    }
492    for rule in config.dbus_portal_calls() {
493        args.push(rule);
494    }
495    for service in &config.dbus.own {
496        args.push(format!("--own={}", service));
497    }
498
499    let exec_start = format!("/usr/bin/xdg-dbus-proxy {}", args.join(" "));
500
501    Some(format!(
502        r#"[Unit]
503Description=D-Bus Proxy for podbox container {name}
504PartOf={name}.service
505
506[Service]
507Type=simple
508ExecStart={exec_start}
509Restart=on-failure
510RestartSec=1s
511
512[Install]
513WantedBy={name}.service
514"#,
515        name = name,
516        exec_start = exec_start,
517    ))
518}
519
520/// Generate the companion Wayland firewall `.service` unit.
521/// Returns `None` when the Wayland proxy is disabled in config.
522pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
523    if !config.use_wayland_proxy() {
524        return None;
525    }
526    let podbox_bin = std::env::current_exe()
527        .map(|p| p.to_string_lossy().to_string())
528        .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
529
530    Some(format!(
531        r#"[Unit]
532Description=Wayland Firewall Proxy for podbox container {name}
533PartOf={name}.service
534
535[Service]
536Type=simple
537ExecStart={podbox_bin} compositor {name}
538Restart=on-failure
539RestartSec=1s
540
541[Install]
542WantedBy={name}.service
543"#,
544        name = name,
545        podbox_bin = podbox_bin,
546    ))
547}
548
549/// Generate the companion host socket server `.service` unit.
550pub fn generate_host_service(name: &str) -> String {
551    let podbox_bin = std::env::current_exe()
552        .map(|p| p.to_string_lossy().to_string())
553        .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
554
555    format!(
556        r#"[Unit]
557Description=podbox host socket server -- {name}
558
559[Service]
560Type=simple
561ExecStart={podbox_bin} serve {name}
562Restart=on-failure
563RestartSec=2s
564
565[Install]
566WantedBy={name}.socket
567"#,
568        name = name,
569        podbox_bin = podbox_bin,
570    )
571}
572
573fn emit_xdg_dir(
574    lines: &mut Vec<String>,
575    dir_name: &str,
576    xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
577    container_home: &str,
578) {
579    if let Some(resolved) = xdg_dir {
580        let mode = if resolved.read_write { "z" } else { "ro,z" };
581        lines.push(format!(
582            "Volume={}:{container_home}/{dir_name}:{mode}",
583            resolved.path.display()
584        ));
585    }
586}