1use std::ffi::OsString;
2use std::os::unix::fs::PermissionsExt;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use nix::fcntl::{Flock, FlockArg};
7use sha2::{Digest, Sha256};
8
9use crate::codegen::containerfile;
10use crate::codegen::distros::DistroFamily;
11use crate::config::Config;
12use crate::env::HostEnv;
13use crate::error::PodboxError;
14use crate::xdg::ResolvedXdgDirs;
15
16pub fn checksum(content: &str) -> String {
18 let mut hasher = Sha256::new();
19 hasher.update(content.as_bytes());
20 hex::encode(hasher.finalize())
21}
22
23pub fn build_context_dir(name: &str) -> PathBuf {
25 dirs::data_dir()
26 .unwrap_or_else(|| PathBuf::from("~/.local/share"))
27 .join("podbox")
28 .join(name)
29}
30
31pub fn run(
33 config: &Config,
34 env: &HostEnv,
35 xdg: &ResolvedXdgDirs,
36 dry_run: bool,
37 rebuild: bool,
38) -> Result<()> {
39 if config.image.source().is_prebuilt() {
40 run_prebuilt(config, dry_run, rebuild)
41 } else {
42 if crate::guest::PODBOX_GUEST.is_none() {
46 return Err(PodboxError::GuestBinaryUnavailable.into());
47 }
48 run_build(config, env, xdg, dry_run, rebuild)
49 }
50}
51
52fn run_prebuilt(config: &Config, dry_run: bool, rebuild: bool) -> Result<()> {
55 let image_ref = match config.image.source() {
56 crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
57 _ => config.image.base.clone(),
58 };
59 let local_tag = format!("localhost/podbox-{}:latest", config.image.name);
60 let context_dir = build_context_dir(&config.container.name);
61 let lock_path = context_dir.join(".podbox.lock");
62 let has_packages = !config.image.packages.install.is_empty();
63
64 let _build_lock = if !dry_run {
66 std::fs::create_dir_all(&context_dir)?;
67 let file = std::fs::File::create(context_dir.join(".build.lock"))?;
68 Some(Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?)
69 } else {
70 None
71 };
72
73 let definition_toml = toml::to_string(config)
76 .with_context(|| "failed to serialize definition config".to_string())?;
77 let config_checksum = checksum(&definition_toml);
78
79 if !rebuild {
80 if let Some(lock) = crate::lock::read(&lock_path)? {
81 if lock.config_checksum == config_checksum && crate::podman::image_exists(&local_tag)? {
82 println!("Prebuilt image already present as {local_tag}. Skipping pull.");
83 println!("Use --rebuild to re-pull.");
84 return Ok(());
85 }
86 }
87 }
88
89 if dry_run {
90 println!("Would pull: {image_ref}");
91 if has_packages {
92 println!(
93 "Would install packages on top: {}",
94 config.image.packages.install.join(", ")
95 );
96 }
97 println!("Would tag as: {local_tag}");
98 println!("Would write lock file at: {}", lock_path.display());
99 return Ok(());
100 }
101
102 if let Ok(labels) = crate::podman::image_labels(&image_ref) {
104 if let Some(guest_ver) = labels
105 .get("podbox.guest_version")
106 .or_else(|| labels.get("podmgr.guest_version"))
107 {
108 let guest_clean = guest_ver.trim_start_matches('v');
109 let host_clean = crate::VERSION.trim_start_matches('v');
110 if guest_clean != host_clean {
111 eprintln!(
112 "Warning: image guest version (v{guest_clean}) differs from host (v{host_clean}). \
113 Protocol compatibility will be validated at runtime."
114 );
115 }
116 }
117 }
118
119 println!("Pulling {image_ref}...");
120 let status = std::process::Command::new("podman")
121 .args(["pull", &image_ref])
122 .status()?;
123 if !status.success() {
124 return Err(PodboxError::PullFailed {
125 image: image_ref.clone(),
126 }
127 .into());
128 }
129
130 if has_packages {
131 let distro = resolve_prebuilt_distro(config);
133 let install_cmd = distro.install_cmd();
134 let clean_cmd = distro.clean_cmd();
135
136 let packages = config.image.packages.install.join(" ");
137 let run_line = if clean_cmd.is_empty() {
138 format!("RUN {install_cmd} {packages}")
139 } else {
140 format!("RUN {install_cmd} {packages} && {clean_cmd}")
141 };
142
143 let containerfile = format!("FROM {image_ref}\n{run_line}\n");
144
145 std::fs::create_dir_all(&context_dir)
146 .with_context(|| format!("failed to create context dir '{}'", context_dir.display()))?;
147
148 let containerfile_path = context_dir.join("Containerfile");
149 std::fs::write(&containerfile_path, &containerfile).with_context(|| {
150 format!(
151 "failed to write Containerfile to '{}'",
152 containerfile_path.display()
153 )
154 })?;
155
156 println!("Installing packages on top of prebuilt image...");
157 let args: Vec<std::ffi::OsString> = vec![
158 "build".into(),
159 "-t".into(),
160 local_tag.clone().into(),
161 "-f".into(),
162 containerfile_path.clone().into(),
163 context_dir.clone().into(),
164 ];
165 let status = crate::process::spawn_interactive("podman", &args)
166 .with_context(|| format!("failed to build prebuilt overlay for '{image_ref}'"))?;
167 if !status.success() {
168 return Err(PodboxError::BuildFailed("overlay build failed".into()).into());
169 }
170 println!("Image {local_tag} ready with packages installed.");
171 } else {
172 println!("Tagging as {local_tag}...");
173 let status = std::process::Command::new("podman")
174 .args(["tag", &image_ref, &local_tag])
175 .status()?;
176 if !status.success() {
177 return Err(PodboxError::TagFailed {
178 image: local_tag.clone(),
179 }
180 .into());
181 }
182 println!("Image {local_tag} ready.");
183 }
184
185 std::fs::create_dir_all(&config.container.home).with_context(|| {
186 format!(
187 "failed to create home dir '{}'",
188 config.container.home.display()
189 )
190 })?;
191 let digest = crate::podman::image_digest(&local_tag)?;
192 let lock = crate::lock::LockFile {
193 config_checksum,
194 image_digest: digest,
195 };
196 crate::lock::write(&lock_path, &lock)?;
197
198 Ok(())
199}
200
201fn resolve_prebuilt_distro(config: &Config) -> DistroFamily {
205 match config.image.packages.manager {
206 crate::config::PackageManager::Apt => DistroFamily::DebianLike,
207 crate::config::PackageManager::Dnf => DistroFamily::FedoraLike,
208 crate::config::PackageManager::Pacman => DistroFamily::ArchLike,
209 crate::config::PackageManager::Apk => DistroFamily::AlpineLike,
210 crate::config::PackageManager::Zypper => DistroFamily::SuseLike,
211 }
212}
213
214fn run_build(
217 config: &Config,
218 _env: &HostEnv,
219 _xdg: &ResolvedXdgDirs,
220 dry_run: bool,
221 rebuild: bool,
222) -> Result<()> {
223 let name = &config.container.name;
224 let context_dir = build_context_dir(name);
225 let containerfile_path = context_dir.join("Containerfile");
226 let lock_path = context_dir.join(".podbox.lock");
227
228 let _build_lock = if !dry_run {
230 std::fs::create_dir_all(&context_dir)?;
231 let file = std::fs::File::create(context_dir.join(".build.lock"))?;
232 Some(Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?)
233 } else {
234 None
235 };
236
237 let guest_bin = crate::guest::PODBOX_GUEST.expect("custom build without embedded guest");
239
240 let definition_toml = toml::to_string(config)
241 .with_context(|| "failed to serialize definition config".to_string())?;
242 let config_checksum = checksum(&definition_toml);
243
244 if !rebuild {
245 if let Some(lock) = crate::lock::read(&lock_path)? {
246 if lock.config_checksum == config_checksum {
247 println!("Definition unchanged and image already built. Skipping.");
248 println!("Use --rebuild to force.");
249 return Ok(());
250 }
251 }
252 }
253
254 let containerfile = containerfile::generate(config, "podbox-guest")?;
255
256 if dry_run {
257 println!("=== Build context: {} ===", context_dir.display());
258 println!("=== Containerfile ===");
259 println!("{containerfile}");
260 println!();
261 println!("=== Embedded podbox-guest ===");
262 println!("{} bytes (embedded in podbox binary)", guest_bin.len());
263 println!(
264 "podman build -t localhost/podbox-{}:latest {}",
265 config.image.name,
266 context_dir.display()
267 );
268 return Ok(());
269 }
270
271 std::fs::create_dir_all(&context_dir).map_err(|e| PodboxError::HomeCreateFailed {
272 path: context_dir.clone(),
273 source: e,
274 })?;
275 let _ = std::fs::set_permissions(&context_dir, std::fs::Permissions::from_mode(0o700));
276
277 std::fs::write(&containerfile_path, containerfile).with_context(|| {
278 format!(
279 "failed to write Containerfile to '{}'",
280 containerfile_path.display()
281 )
282 })?;
283
284 let guest_dest = context_dir.join("podbox-guest");
285 std::fs::write(&guest_dest, guest_bin)
286 .with_context(|| format!("failed to write guest binary to '{}'", guest_dest.display()))?;
287
288 std::fs::create_dir_all(&config.container.home).with_context(|| {
289 format!(
290 "failed to create home dir '{}'",
291 config.container.home.display()
292 )
293 })?;
294
295 let tag = format!("localhost/podbox-{}:latest", config.image.name);
296 let args: Vec<OsString> = vec![
297 "build".into(),
298 "-t".into(),
299 tag.clone().into(),
300 context_dir.clone().into(),
301 ];
302
303 println!("Building image {tag}...");
304 let status = crate::process::spawn_interactive("podman", &args)
305 .with_context(|| format!("failed to execute podman build for image '{tag}'"))?;
306 if !status.success() {
307 return Err(PodboxError::BuildFailed("build failed".into()).into());
308 }
309 println!("Image {tag} built successfully.");
310
311 let digest = crate::podman::image_digest(&tag)?;
312 let lock = crate::lock::LockFile {
313 config_checksum,
314 image_digest: digest,
315 };
316 crate::lock::write(&lock_path, &lock)?;
317
318 Ok(())
319}