use std::path::{Path, PathBuf};
use crate::config::{Config, GpuMode};
use crate::env::HostEnv;
use crate::xdg::ResolvedXdgDirs;
fn home() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
}
pub fn generate_build(config: &Config, containerfile_path: &Path) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("[Build]".into());
lines.push(format!(
"ImageTag=localhost/podbox-{}:latest",
config.image.name
));
lines.push(format!("File={}", containerfile_path.to_string_lossy()));
lines.push(format!("Retry={}", config.image.pull_retry));
lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
lines.join("\n")
}
pub fn generate_socket(config: &Config) -> String {
let name = &config.container.name;
let host_service = format!("{name}-host.service");
let mut lines: Vec<String> = Vec::new();
lines.push("[Unit]".into());
lines.push(format!("Description=podbox host-guest socket -- {name}"));
lines.push(String::new());
lines.push("[Socket]".into());
lines.push(format!("ListenStream=%t/podbox/{name}.sock"));
lines.push(format!("Service={host_service}"));
lines.push("SocketMode=0600".into());
lines.push("DirectoryMode=0700".into());
lines.push("RuntimeDirectory=podbox".into());
lines.push("RuntimeDirectoryMode=0700".into());
lines.push("RuntimeDirectoryPreserve=yes".into());
lines.push(String::new());
lines.push("[Install]".into());
lines.push("WantedBy=sockets.target".into());
lines.join("\n")
}
pub fn generate_container(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs) -> String {
let name = &config.container.name;
let home_in_container = "/home/%u";
let mut lines: Vec<String> = Vec::new();
emit_unit(&mut lines, config, name);
emit_container_image(&mut lines, config, name, home_in_container, env);
emit_network(&mut lines, config);
emit_volumes(&mut lines, config, xdg, env, name, home_in_container);
emit_env(&mut lines, config, name, env);
emit_gpu(&mut lines, config, env);
emit_hardware_devices(&mut lines, config);
emit_secrets(&mut lines, config);
emit_auto_update(&mut lines, config);
emit_podman_args(&mut lines, config);
emit_service_section(&mut lines, config);
emit_install_section(&mut lines, config);
lines.join("\n")
}
fn emit_unit(lines: &mut Vec<String>, config: &Config, name: &str) {
lines.push("[Unit]".into());
lines.push(format!("Description=podbox -- {name}"));
lines.push(format!("Requires={name}.socket"));
lines.push(format!("After={name}.socket"));
for dep in &config.systemd.requires {
lines.push(format!("Requires={dep}"));
}
for dep in &config.systemd.after {
lines.push(format!("After={dep}"));
}
if config.use_dbus_proxy() {
lines.push(format!("Requires={name}-proxy.service"));
lines.push(format!("After={name}-proxy.service"));
}
if config.use_wayland_proxy() {
lines.push(format!("Requires={name}-compositor.service"));
lines.push(format!("After={name}-compositor.service"));
}
lines.push("StartLimitBurst=5".into());
lines.push("StartLimitIntervalSec=30s".into());
lines.push(String::new());
}
fn emit_container_image(
lines: &mut Vec<String>,
config: &Config,
name: &str,
home_in_container: &str,
env: &HostEnv,
) {
lines.push("[Container]".into());
if config.image.source().is_prebuilt() && config.image.packages.install.is_empty() {
let ref_str = match config.image.source() {
crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
_ => config.image.base.clone(),
};
lines.push(format!("Image={ref_str}"));
lines.push(format!("Retry={}", config.image.pull_retry));
lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
} else {
lines.push(format!(
"Image=localhost/podbox-{}:latest",
config.image.name
));
}
lines.push(format!("ContainerName={name}"));
if let Some(ref mode) = config.security.userns {
lines.push(format!("UserNS={mode}"));
} else {
lines.push("UserNS=keep-id".into());
}
lines.push("User=root".into());
if config.security.security_label_disable {
lines.push("SecurityLabelDisable=true".into());
}
if let Some(ref seccomp) = config.security.seccomp {
lines.push(format!("SeccompProfile={seccomp}"));
}
if config.security.no_new_privileges {
lines.push("NoNewPrivileges=true".into());
}
if let Some(ref mem) = config.container.memory {
lines.push(format!("Memory={mem}"));
}
if let Some(ref cpus) = config.container.cpus {
if let Ok(v) = cpus.parse::<f64>() {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let quota = (v * 100_000.0) as u64;
lines.push(format!("CpuQuota={quota}"));
}
}
if config.security.read_only_rootfs {
lines.push("ReadOnly=true".into());
}
if let Some(ref profile) = config.security.apparmor {
lines.push(format!("AppArmor={profile}"));
}
lines.push(format!("Environment=HOME={home_in_container}"));
lines.push(format!("Environment=HOST_USER={}", env.username));
lines.push("Environment=HOST_UID=%U".into());
lines.push("Environment=HOST_GID=%G".into());
lines.push("Environment=PATH=/run/podbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
lines.push(String::new());
}
fn emit_network(lines: &mut Vec<String>, config: &Config) {
lines.push(format!("Network={}", config.network.mode));
if config.network.mode != "host" {
for port in &config.network.ports {
lines.push(format!("PublishPort={port}"));
}
}
lines.push(String::new());
}
fn emit_volumes(
lines: &mut Vec<String>,
config: &Config,
xdg: &ResolvedXdgDirs,
env: &HostEnv,
name: &str,
home_in_container: &str,
) {
let host_home = config.container.home.to_string_lossy().to_string();
lines.push(format!("Volume={host_home}:{home_in_container}:Z",));
lines.push(String::new());
emit_xdg_dir(lines, "Documents", &xdg.documents, home_in_container);
emit_xdg_dir(lines, "Downloads", &xdg.downloads, home_in_container);
emit_xdg_dir(lines, "Pictures", &xdg.pictures, home_in_container);
emit_xdg_dir(lines, "Music", &xdg.music, home_in_container);
emit_xdg_dir(lines, "Videos", &xdg.videos, home_in_container);
emit_xdg_dir(lines, "Desktop", &xdg.desktop, home_in_container);
emit_xdg_dir(lines, "Projects", &xdg.projects, home_in_container);
if xdg.documents.is_some()
|| xdg.downloads.is_some()
|| xdg.pictures.is_some()
|| xdg.music.is_some()
|| xdg.videos.is_some()
|| xdg.desktop.is_some()
|| xdg.projects.is_some()
{
lines.push(String::new());
}
if config.integration.sync_themes {
let h = home();
if h.join(".themes").exists() {
lines.push(format!("Volume=%h/.themes:{home_in_container}/.themes:ro"));
}
if env.host_has_local_share_themes {
lines.push(format!(
"Volume=%h/.local/share/themes:{home_in_container}/.local/share/themes:ro"
));
}
}
if config.integration.sync_icons {
let h = home();
if h.join(".icons").exists() {
lines.push(format!("Volume=%h/.icons:{home_in_container}/.icons:ro"));
}
if env.host_has_local_share_icons {
lines.push(format!(
"Volume=%h/.local/share/icons:{home_in_container}/.local/share/icons:ro"
));
}
}
if config.integration.sync_fonts {
let h = home();
if h.join(".fonts").exists() {
lines.push(format!("Volume=%h/.fonts:{home_in_container}/.fonts:ro"));
}
if env.host_has_local_share_fonts {
lines.push(format!(
"Volume=%h/.local/share/fonts:{home_in_container}/.local/share/fonts:ro"
));
}
}
if config.integration.sync_themes
|| config.integration.sync_icons
|| config.integration.sync_fonts
{
lines.push(String::new());
}
if env.host_has_localtime {
lines.push("Volume=/etc/localtime:/etc/localtime:ro".into());
}
if env.host_has_timezone_file {
lines.push("Volume=/etc/timezone:/etc/timezone:ro".into());
}
if env.host_has_localtime || env.host_has_timezone_file {
lines.push(String::new());
}
lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
if config.integration.wayland {
if let Some(ref display) = env.wayland_display {
lines.push(format!("Environment=WAYLAND_DISPLAY={display}"));
lines.push("Environment=MOZ_ENABLE_WAYLAND=1".into());
if config.wayland.firewall {
lines.push(format!(
"Volume=%t/podbox/{name}-wayland.sock:%t/{display}:ro"
));
} else {
lines.push(format!("Volume=%t/{display}:%t/{display}:ro"));
}
lines.push(String::new());
}
}
if config.integration.audio {
if env.pipewire_socket.is_some() {
lines.push("Volume=%t/pipewire-0:%t/pipewire-0".into());
lines.push("Environment=PIPEWIRE_RUNTIME_DIR=%t".into());
}
if env.pulse_dir.is_some() {
lines.push("Volume=%t/pulse:%t/pulse".into());
lines.push("Environment=PULSE_SERVER=unix:%t/pulse/native".into());
}
if env.pipewire_socket.is_some() || env.pulse_dir.is_some() {
lines.push(String::new());
}
}
if config.integration.ssh_agent {
if let Some(ref sock) = env.ssh_agent_socket {
lines.push(format!(
"Volume={}:/run/podbox/ssh-agent.sock",
sock.display()
));
lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
} else {
eprintln!(
"Warning: ssh_agent = true but SSH_AUTH_SOCK not found on host. Skipping SSH agent."
);
}
lines.push(String::new());
}
if config.integration.gpg_agent {
if let Some(ref sock) = env.gpg_agent_socket {
lines.push(format!(
"Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
sock.display()
));
lines.push("Environment=GPG_TTY=/dev/pts/0".into());
lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
} else {
eprintln!(
"Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
);
}
lines.push(String::new());
}
let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
lines.push(format!(
"Volume={}:/.flatpak-info:ro",
flatpak_info_path.display()
));
lines.push(String::new());
if config.integration.dbus && env.dbus_socket.is_some() {
if config.use_dbus_proxy() {
lines.push(format!(
"Volume=%t/podbox/{name}-dbus.sock:/run/podbox/dbus.sock:ro"
));
lines.push(
"Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
);
} else {
lines.push("Volume=%t/bus:%t/bus".into());
lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
}
lines.push(String::new());
}
lines.push(format!(
"Volume=%t/podbox/{name}.sock:%t/podbox/{name}.sock"
));
lines.push(String::new());
for mount in &config.container.mounts.extra {
lines.push(format!("Volume={mount}"));
}
if !config.container.mounts.extra.is_empty() {
lines.push(String::new());
}
}
fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
if let Some(ref locale) = _env.host_locale {
lines.push(format!("Environment=LANG={locale}"));
lines.push(format!("Environment=LC_ALL={locale}"));
lines.push(format!("Environment=LC_CTYPE={locale}"));
lines.push(String::new());
}
for (key, value) in &config.container.env {
if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
let clean = value.replace('\n', " ").replace('\r', "");
let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
let env_val = if escaped.contains(' ') || escaped.is_empty() {
format!("\"{escaped}\"")
} else {
escaped
};
lines.push(format!("Environment={key}={env_val}"));
} else {
eprintln!("Warning: ignoring invalid environment variable key '{key}'");
}
}
lines.push(format!("Environment=PODBOX_CONTAINER={name}"));
lines.push(String::new());
}
fn emit_gpu(lines: &mut Vec<String>, config: &Config, env: &HostEnv) {
match config.integration.gpu {
GpuMode::Enabled => {
lines.push("AddDevice=/dev/dri".into());
lines.push(String::new());
}
GpuMode::Nvidia => {
lines.push("AddDevice=/dev/dri".into());
lines.push("AddDevice=-/dev/nvidiactl".into());
lines.push("AddDevice=-/dev/nvidia0".into());
if env.gpu_has_nvidia_uvm {
lines.push("AddDevice=-/dev/nvidia-uvm".into());
}
lines.push(String::new());
}
GpuMode::Auto => {
if env.gpu_has_dri {
lines.push("AddDevice=/dev/dri".into());
}
if env.gpu_has_nvidia {
lines.push("AddDevice=-/dev/nvidiactl".into());
lines.push("AddDevice=-/dev/nvidia0".into());
if env.gpu_has_nvidia_uvm {
lines.push("AddDevice=-/dev/nvidia-uvm".into());
}
}
if env.gpu_has_dri || env.gpu_has_nvidia {
lines.push(String::new());
}
}
GpuMode::Disabled => {}
}
}
pub fn emit_hardware_devices(lines: &mut Vec<String>, config: &Config) {
let hw = &config.integration.hardware;
let mut emitted = false;
if hw.kvm {
lines.push("AddDevice=-/dev/kvm".into());
emitted = true;
}
if hw.joystick {
lines.push("AddDevice=-/dev/uinput".into());
lines.push("AddDevice=-/dev/input".into());
emitted = true;
}
if hw.webcam {
for i in 0..16 {
lines.push(format!("AddDevice=-/dev/video{i}"));
lines.push(format!("AddDevice=-/dev/media{i}"));
}
emitted = true;
}
if hw.serial {
for i in 0..8 {
lines.push(format!("AddDevice=-/dev/ttyUSB{i}"));
lines.push(format!("AddDevice=-/dev/ttyACM{i}"));
}
emitted = true;
}
if hw.yubikey {
lines.push("Volume=-%t/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro".into());
for i in 0..16 {
lines.push(format!("AddDevice=-/dev/hidraw{i}"));
}
emitted = true;
}
if emitted {
lines.push(String::new());
}
}
pub fn emit_secrets(lines: &mut Vec<String>, config: &Config) {
use crate::config::{SecretEntry, SecretSource, SecretType};
let mut emitted = false;
for secret in &config.security.secrets {
emitted = true;
match secret {
SecretEntry::Simple(name) => {
lines.push(format!("Secret={},type=env,target={}", name, name));
}
SecretEntry::Detailed {
name,
secret_type,
target,
mode,
source,
} => match source {
SecretSource::Podman => {
let mut opts = vec![name.clone()];
match secret_type {
SecretType::Env => {
opts.push("type=env".into());
if let Some(t) = target {
opts.push(format!("target={t}"));
}
}
SecretType::Mount => {
opts.push("type=mount".into());
if let Some(t) = target {
opts.push(format!("target={t}"));
}
if let Some(m) = mode {
opts.push(format!("mode={m}"));
}
opts.push("uid=%U".into());
opts.push("gid=%G".into());
}
}
lines.push(format!("Secret={}", opts.join(",")));
}
SecretSource::Systemd => {
lines.push(format!(
"Environment={}=%d/{}",
target.as_deref().unwrap_or(name),
name
));
}
},
}
}
if emitted {
lines.push(String::new());
}
}
fn emit_auto_update(lines: &mut Vec<String>, config: &Config) {
if config.lifecycle.auto_update {
if config.image.source().is_prebuilt() {
lines.push("AutoUpdate=registry".into());
} else {
lines.push("AutoUpdate=local".into());
}
lines.push(String::new());
}
}
fn emit_podman_args(lines: &mut Vec<String>, config: &Config) {
lines.push("PodmanArgs=--init".into());
lines.push("PodmanArgs=--workdir=/home/%u".into());
let cap_preset = config.security.cap_preset;
let has_any_cap = !cap_preset.caps().is_empty() || !config.security.cap_add.is_empty();
for cap in cap_preset.caps() {
lines.push(format!("PodmanArgs=--cap-add={cap}"));
}
for cap in &config.security.cap_add {
lines.push(format!("PodmanArgs=--cap-add={cap}"));
}
if has_any_cap {
lines.push(String::new());
}
if let Some(ref cmd) = config.container.reload_cmd {
lines.push(format!("ReloadCmd={cmd}"));
lines.push(String::new());
}
}
fn emit_service_section(lines: &mut Vec<String>, config: &Config) {
lines.push("[Service]".into());
lines.push("Restart=on-failure".into());
lines.push("RestartSec=2s".into());
if config.lifecycle.on_stop == crate::config::OnStop::Remove {
lines.push("AutoRemove=true".into());
}
lines.push(String::new());
}
fn emit_install_section(lines: &mut Vec<String>, config: &Config) {
lines.push("[Install]".into());
if config.lifecycle.autostart {
lines.push("WantedBy=default.target".into());
}
}
pub fn generate_dbus_proxy_service(name: &str, config: &Config) -> Option<String> {
if !config.use_dbus_proxy() {
return None;
}
let mut args = vec![
"unix:path=%t/bus".to_string(),
format!("%t/podbox/{}-dbus.sock", name),
];
args.push("--filter".into());
for service in &config.dbus_effective_talk() {
args.push(format!("--talk={service}"));
}
for rule in config.dbus_portal_calls() {
args.push(rule);
}
for service in &config.dbus.own {
args.push(format!("--own={service}"));
}
let exec_start = format!("/usr/bin/xdg-dbus-proxy {}", args.join(" "));
Some(format!(
r#"[Unit]
Description=D-Bus Proxy for podbox container {name}
PartOf={name}.service
[Service]
Type=simple
ExecStart={exec_start}
Restart=on-failure
RestartSec=1s
[Install]
WantedBy={name}.service
"#,
))
}
pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
if !config.use_wayland_proxy() {
return None;
}
let podbox_bin = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "/usr/local/bin/podbox".into());
Some(format!(
r#"[Unit]
Description=Wayland Firewall Proxy for podbox container {name}
PartOf={name}.service
[Service]
Type=simple
ExecStart={podbox_bin} compositor {name}
Restart=on-failure
RestartSec=1s
[Install]
WantedBy={name}.service
"#,
))
}
pub fn generate_host_service(name: &str) -> String {
let podbox_bin = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "/usr/local/bin/podbox".into());
format!(
r#"[Unit]
Description=podbox host socket server -- {name}
[Service]
Type=simple
ExecStart={podbox_bin} serve {name}
Restart=on-failure
RestartSec=2s
[Install]
WantedBy={name}.socket
"#,
)
}
fn emit_xdg_dir(
lines: &mut Vec<String>,
dir_name: &str,
xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
container_home: &str,
) {
if let Some(resolved) = xdg_dir {
let mode = if resolved.read_write { "z" } else { "ro,z" };
lines.push(format!(
"Volume={}:{container_home}/{dir_name}:{mode}",
resolved.path.display()
));
}
}