Skip to main content

zoi_cli/cmd/
system.rs

1//! Logic for the `system` command.
2//!
3//! This module provides commands for managing ZoiOS systems, including
4//! declarative configuration, system generations, secrets management, and
5//! distribution building.
6
7use std::fmt::Write as _;
8use std::io::Read;
9
10use anyhow::{Result, anyhow};
11use clap::{Parser, Subcommand};
12use colored::Colorize;
13use zoi_core::utils::is_zoios;
14#[cfg(unix)]
15use zoi_system::client::send_request;
16use zoi_system::config::load_system_lua;
17#[cfg(unix)]
18use zoi_system::protocol::{Request, Response};
19
20/// The root system management command.
21#[derive(Parser, Debug)]
22pub struct SystemCommand {
23    /// The specific system subcommand to execute.
24    #[command(subcommand)]
25    pub command: SystemSubcommands
26}
27
28/// Available system subcommands.
29#[derive(Subcommand, Debug)]
30pub enum SystemSubcommands {
31    /// Apply a declarative system configuration from system.lua
32    Apply {
33        /// Path to the system configuration file
34        #[arg(default_value = "/etc/zoi/system.lua")]
35        file: String
36    },
37    /// List all system generations
38    List,
39    /// Show current system status and active generation
40    Status,
41    /// Rollback to a previous system generation
42    Rollback {
43        /// Generation ID to roll back to
44        id: u32
45    },
46    /// Pin a system generation to prevent it from being pruned
47    Pin {
48        /// Generation ID to pin
49        id: u32
50    },
51    /// Unpin a system generation
52    Unpin {
53        /// Generation ID to unpin
54        id: u32
55    },
56    /// Manage secrets (hashes and encrypted strings)
57    Secret {
58        /// Secret subcommands.
59        #[command(subcommand)]
60        command: SecretSubcommands
61    },
62    /// Commands for building and managing `ZoiOS` distributions
63    Distro {
64        /// Distro subcommands.
65        #[command(subcommand)]
66        command: DistroSubcommands
67    }
68}
69
70/// Commands for building and managing `ZoiOS` distributions.
71#[derive(Subcommand, Debug)]
72pub enum DistroSubcommands {
73    /// Build a new `ZoiOS` distribution image or install to a disk
74    Build {
75        /// The target device or image path (e.g. /dev/sdb)
76        #[arg(short, long)]
77        target: String,
78        /// Path to the system configuration to use for the build
79        #[arg(short, long)]
80        config: String,
81        /// Show the build plan without executing destructive commands
82        #[arg(long)]
83        dry_run: bool
84    },
85    /// Enter a `ZoiOS` sysroot (chroot) with automatic device mounting
86    Chroot {
87        /// Path to the `ZoiOS` root directory
88        target: String,
89        /// Command to run inside the chroot (defaults to /bin/bash)
90        #[arg(short, long)]
91        run: Option<String>,
92        /// Show additional details
93        #[arg(long, short)]
94        verbose: bool
95    }
96}
97
98/// Commands for managing secrets like password hashes and encrypted strings.
99#[derive(Subcommand, Debug)]
100pub enum SecretSubcommands {
101    /// Generate a one-way hash of a password for use in system.lua
102    Hash {
103        /// The password to hash
104        password: String
105    },
106    /// Encrypt a sensitive string (like an API key) so only Zoi can decrypt it
107    Encrypt {
108        /// The plaintext string to encrypt
109        value: String
110    },
111    /// Decrypt a ZOISEC string (only works on the same machine where it was
112    /// encrypted)
113    Decrypt {
114        /// The encrypted ZOISEC string
115        secret: String
116    },
117    /// Export the `ZoiSEC` master key as a base64 string
118    ExportKey,
119    /// Import a `ZoiSEC` master key from a base64 string
120    ImportKey {
121        /// The base64-encoded master key
122        key: String
123    }
124}
125
126/// Run the system management command.
127///
128/// # Errors
129///
130/// Returns an error if:
131/// - The command is not run on `ZoiOS` (except for secrets and distro
132///   commands).
133/// - Package validation fails during a distro build.
134/// - The user aborts a build.
135/// - Any OS management daemon request fails.
136/// # Errors
137///
138/// Returns an error if the system operation fails.
139pub fn run(args: SystemCommand, yes: bool) -> Result<()> {
140    let is_secret = matches!(args.command, SystemSubcommands::Secret { .. });
141    let is_distro = matches!(args.command, SystemSubcommands::Distro { .. });
142
143    if !is_secret && !is_distro && !is_zoios() {
144        return Err(anyhow!(
145            "OS management features are only available on ZoiOS systems."
146        ));
147    }
148
149    match args.command {
150        SystemSubcommands::Secret { command } => match command {
151            SecretSubcommands::Hash { password } => {
152                let hash = zoi_system::secret::hash_password(&password)?;
153                println!(
154                    "Password hash generated successfully. Use this in your \
155                     system.lua:"
156                );
157                println!("\n  {}", hash.green());
158            }
159            SecretSubcommands::Encrypt { value } => {
160                let encrypted = zoi_system::secret::encrypt_secret(&value)?;
161                println!(
162                    "Value encrypted successfully. Use this in your \
163                     system.lua or home.lua:"
164                );
165                println!("\n  {}", encrypted.yellow());
166                println!(
167                    "\n{}",
168                    "Note: This can only be decrypted by Zoi on this specific \
169                     machine."
170                        .dimmed()
171                );
172            }
173            SecretSubcommands::Decrypt { secret } => {
174                let decrypted = zoi_system::secret::decrypt_secret(&secret)?;
175                if decrypted == secret {
176                    return Err(anyhow!(
177                        "Input is not a valid Zoi secret string."
178                    ));
179                }
180                println!("Secret decrypted successfully:");
181                println!("\n  {}", decrypted.green());
182            }
183            SecretSubcommands::ExportKey => {
184                let key = zoi_system::secret::export_master_key()?;
185                println!("ZoiSEC Master Key (base64):");
186                println!("\n  {}", key.yellow());
187                println!(
188                    "\n{}",
189                    "Keep this key safe! Anyone with this key can decrypt \
190                     your ZoiSEC secrets."
191                        .red()
192                        .bold()
193                );
194            }
195            SecretSubcommands::ImportKey { key } => {
196                zoi_system::secret::import_master_key(&key)?;
197                println!(
198                    "{} ZoiSEC master key imported successfully.",
199                    "Success:".green()
200                );
201            }
202        },
203        SystemSubcommands::Distro { command } => match command {
204            DistroSubcommands::Build {
205                target,
206                config,
207                dry_run
208            } => {
209                let target_path = std::path::Path::new(&target);
210                let config = load_system_lua(&config)?;
211
212                // Pre-flight: Validate packages exist in registry
213                println!(
214                    "{} Validating {} packages...",
215                    "::".bold().blue(),
216                    config.packages.len().to_string().cyan()
217                );
218                for pkg_id in &config.packages {
219                    if let Err(e) = zoi_resolver::resolve::resolve_source(
220                        pkg_id, None, true, true
221                    ) {
222                        return Err(anyhow!(
223                            "Package validation failed for '{pkg_id}': {e}"
224                        ));
225                    }
226                }
227
228                print_build_summary(&target, &config, dry_run);
229
230                if !dry_run
231                    && !zoi_core::utils::ask_for_confirmation(
232                        "Are you sure you want to proceed with the build? \
233                         This will install ZoiOS to the target device.",
234                        yes
235                    )
236                {
237                    return Err(anyhow!("Build aborted by user."));
238                }
239
240                println!(
241                    "{} Orchestrating ZoiOS build on {}...",
242                    "::".bold().blue(),
243                    target.cyan()
244                );
245
246                // Marker
247                zoi_system::distro::initialize_zoios_marker(
248                    target_path,
249                    config.system.hostname.as_deref(),
250                    dry_run
251                )?;
252
253                // Install packages into target sysroot
254                if dry_run {
255                    println!(
256                        "  {} Would install base packages: {}",
257                        "[DRY-RUN]".dimmed(),
258                        config.packages.join(", ")
259                    );
260                } else {
261                    println!(
262                        "{} Installing base packages to {}...",
263                        "::".bold().blue(),
264                        target.cyan()
265                    );
266
267                    // Use CLI's install engine
268                    let project_config = zoi_project::config::ProjectConfig {
269                        name: "system".to_string(),
270                        registries: std::collections::HashMap::new(),
271                        packages: Vec::new(),
272                        pkgs: config.packages.clone(),
273                        pkgs_v2: config.packages_v2.clone(),
274                        config:
275                            zoi_project::config::ProjectLocalConfig::default(),
276                        commands: Vec::new(),
277                        environments: Vec::new(),
278                        shell: Some(zoi_project::config::ShellSpec::default())
279                    };
280
281                    crate::cmd::install::run(
282                        &config.packages,
283                        None,
284                        false, // force
285                        false, // all_optional
286                        yes,
287                        Some(crate::cli::InstallScope::System),
288                        false,
289                        false,
290                        false,
291                        false,
292                        false,
293                        None,
294                        false,
295                        None,
296                        false,
297                        false,
298                        false,
299                        false,
300                        3,
301                        false,
302                        false,
303                        Some(project_config)
304                    )?;
305                }
306
307                // Finalize Generation
308                zoi_system::distro::finalize_first_generation(
309                    target_path,
310                    config.packages.clone(),
311                    dry_run
312                )?;
313
314                let success_msg = if dry_run {
315                    "Dry-run complete."
316                } else {
317                    "ZoiOS build complete."
318                };
319                println!(
320                    "{} {} on {}.",
321                    "Success:".green(),
322                    success_msg,
323                    target.cyan()
324                );
325            }
326            DistroSubcommands::Chroot {
327                target,
328                run,
329                verbose
330            } => {
331                let target_path = std::path::Path::new(&target);
332                if !target_path.exists() {
333                    return Err(anyhow!(
334                        "Target path '{target}' does not exist."
335                    ));
336                }
337
338                let os_release = target_path.join("etc/os-release");
339                if !os_release.exists() {
340                    return Err(anyhow!(
341                        "Target path '{target}' is not a valid ZoiOS root \
342                         (missing /etc/os-release)."
343                    ));
344                }
345
346                // --- BOOTSTRAP AUDIT ---
347
348                // Filesystem check
349                let fs_type = std::process::Command::new("stat")
350                    .arg("-f")
351                    .arg("-c")
352                    .arg("%T")
353                    .arg(&target)
354                    .output();
355                if let Ok(out) = fs_type {
356                    let t =
357                        String::from_utf8_lossy(&out.stdout).trim().to_string();
358                    if t == "msdos" || t == "vfat" {
359                        eprintln!(
360                            "\n{} CRITICAL: Target filesystem is '{}'. ZoiOS \
361                             requires a Linux filesystem (ext4, btrfs, xfs) \
362                             to support hard links and permissions. Your \
363                             bootstrap will NOT work on FAT32.",
364                            "Error:".red().bold(),
365                            t.yellow()
366                        );
367                    } else if verbose {
368                        println!(
369                            "{} Filesystem type: {}",
370                            "::".bold().blue(),
371                            t.green()
372                        );
373                    }
374                }
375
376                // Merged-Usr Symlink Audit
377                let mut broken_layout = false;
378                for sym in &["bin", "sbin", "lib", "lib64"] {
379                    let p = target_path.join(sym);
380                    let meta = std::fs::symlink_metadata(&p);
381                    if let Ok(m) = meta {
382                        if !m.file_type().is_symlink() {
383                            eprintln!(
384                                "{} WARNING: '/{}' is a real directory, but \
385                                 ZoiOS expects a merged-usr symlink to \
386                                 'usr/{}'.",
387                                "::".bold().yellow(),
388                                sym,
389                                sym
390                            );
391                            broken_layout = true;
392                        } else if let Ok(target) = std::fs::read_link(&p) {
393                            if target.is_absolute() {
394                                eprintln!(
395                                    "{} WARNING: '/{}' is an absolute symlink \
396                                     to '{}'. This WILL break inside the \
397                                     chroot. It should be relative (e.g. \
398                                     'usr/{}').",
399                                    "::".bold().yellow(),
400                                    sym,
401                                    target.display(),
402                                    sym
403                                );
404                                broken_layout = true;
405                            } else {
406                                let abs_target = target_path.join(target);
407                                if !abs_target.exists() {
408                                    eprintln!(
409                                        "{} WARNING: Symlink '/{}' points to \
410                                         non-existent path '{}'.",
411                                        "::".bold().yellow(),
412                                        sym,
413                                        abs_target.display()
414                                    );
415                                    broken_layout = true;
416                                }
417                            }
418                        }
419                    } else if *sym != "sbin" {
420                        eprintln!(
421                            "{} WARNING: '/{}' is missing! Your binaries will \
422                             likely fail to find their loader or shell.",
423                            "::".bold().yellow(),
424                            sym
425                        );
426                        broken_layout = true;
427                    }
428                }
429
430                // Dynamic Loader Validation (The common cause of 139)
431                let mut loader_found = false;
432                let loaders = [
433                    "usr/lib/ld-linux-x86-64.so.2",
434                    "lib64/ld-linux-x86-64.so.2",
435                    "lib/ld-linux-x86-64.so.2"
436                ];
437                for l in &loaders {
438                    let lp = target_path.join(l);
439                    if lp.exists() {
440                        loader_found = true;
441                        if let Ok(mut file) = std::fs::File::open(&lp) {
442                            let mut magic = [0u8; 4];
443                            if file.read_exact(&mut magic).is_ok() {
444                                if magic != [0x7f, b'E', b'L', b'F'] {
445                                    eprintln!(
446                                        "{} CRITICAL: Dynamic loader '{}' is \
447                                         NOT an ELF file! Your glibc \
448                                         installation is corrupted.",
449                                        "Error:".red().bold(),
450                                        l
451                                    );
452                                }
453                            } else {
454                                eprintln!(
455                                    "{} CRITICAL: Dynamic loader '{}' is 0 \
456                                     bytes or unreadable.",
457                                    "Error:".red().bold(),
458                                    l
459                                );
460                            }
461                        }
462                        break;
463                    }
464                }
465                if !loader_found {
466                    eprintln!(
467                        "{} Dynamic loader not found. Binaries WILL Segfault \
468                         (139).",
469                        "::".bold().yellow()
470                    );
471                }
472
473                if broken_layout || !loader_found {
474                    println!(
475                        "{} Hint: Your ZoiOS bootstrap appears incomplete or \
476                         corrupted. Please verify your base system packages.",
477                        "::".bold().blue()
478                    );
479                }
480
481                if verbose {
482                    println!(
483                        "{} Entering sysroot at {}...",
484                        "::".bold().blue(),
485                        target.cyan()
486                    );
487                }
488
489                let mut envs = std::collections::HashMap::new();
490                envs.insert(
491                    "PATH".to_string(),
492                    "/usr/bin:/bin:/usr/sbin:/sbin".to_string()
493                );
494                envs.insert("SHELL".to_string(), "/usr/bin/bash".to_string());
495                envs.insert(
496                    "TERM".to_string(),
497                    std::env::var("TERM")
498                        .unwrap_or_else(|_| "xterm-256color".to_string())
499                );
500
501                // Resolve shell path in guest (Prefer /usr/bin/bash)
502                let mut shell_bin = std::path::PathBuf::from("/usr/bin/bash");
503                if !target_path.join("usr/bin/bash").exists()
504                    && target_path.join("bin/bash").exists()
505                {
506                    shell_bin = std::path::PathBuf::from("/bin/bash");
507                }
508
509                if verbose {
510                    let cmd_display = if let Some(r) = &run {
511                        format!("{} -c '{}'", shell_bin.display(), r)
512                    } else {
513                        shell_bin.display().to_string()
514                    };
515                    println!(
516                        "{} Running inside chroot: {}",
517                        "::".bold().blue(),
518                        cmd_display.green()
519                    );
520                }
521
522                #[cfg(target_os = "linux")]
523                {
524                    let mut cmd = if let Some(run_cmd) = run {
525                        let args = vec!["-c".to_string(), run_cmd];
526                        crate::sandbox::wrap_command_in_root(
527                            target_path,
528                            &shell_bin,
529                            &args,
530                            &envs,
531                            &[],
532                            false
533                        )?
534                    } else {
535                        crate::sandbox::wrap_command_in_root(
536                            target_path,
537                            &shell_bin,
538                            &[],
539                            &envs,
540                            &[],
541                            false
542                        )?
543                    };
544
545                    if verbose {
546                        println!(
547                            "{} Full command: {:?}",
548                            "::".bold().blue(),
549                            cmd
550                        );
551                    }
552
553                    let status = cmd.status()?;
554                    if !status.success() {
555                        let code = status.code().unwrap_or(1);
556                        eprintln!(
557                            "\n{} Chroot execution failed with exit code: {}",
558                            "Error:".red().bold(),
559                            code.to_string().yellow()
560                        );
561                        if code == 139 {
562                            println!(
563                                "{} Hint: Segfaults (139) often indicate an \
564                                 instruction set mismatch (e.g. x86-64-v3 \
565                                 binaries on older CPUs).",
566                                "::".bold().blue()
567                            );
568                        }
569                        std::process::exit(code);
570                    }
571                }
572
573                #[cfg(not(target_os = "linux"))]
574                return Err(anyhow!(
575                    "Distro chroot is only supported on Linux via Bubblewrap."
576                ));
577            }
578        },
579        SystemSubcommands::Apply { file } => {
580            #[cfg(unix)]
581            {
582                println!(
583                    "Reading system configuration from {}...",
584                    file.cyan()
585                );
586                let config = load_system_lua(&file)?;
587                let response =
588                    send_request(Request::ApplySystemConfig(Box::new(config)))?;
589                handle_response(response)?;
590            }
591            #[cfg(not(unix))]
592            {
593                let _ = file;
594                return Err(anyhow!(
595                    "OS management daemon commands are only supported on Unix."
596                ));
597            }
598        }
599        SystemSubcommands::List => {
600            #[cfg(unix)]
601            {
602                let response = send_request(Request::ListGenerations)?;
603                match response {
604                    Response::Generations(gens) => {
605                        println!(
606                            "{:<5} {:<25} {:<50}",
607                            "ID", "Created At", "Packages"
608                        );
609                        println!("{:-<80}", "");
610                        for generation in gens {
611                            println!(
612                                "{:<5} {:<25} {:<50}",
613                                generation.id,
614                                generation.created_at.to_rfc3339(),
615                                generation.packages.join(", ")
616                            );
617                        }
618                    }
619                    _ => handle_response(response)?
620                }
621            }
622            #[cfg(not(unix))]
623            return Err(anyhow!(
624                "OS management daemon commands are only supported on Unix."
625            ));
626        }
627        SystemSubcommands::Status => {
628            #[cfg(unix)]
629            {
630                let response = send_request(Request::GetStatus)?;
631                handle_response(response)?;
632            }
633            #[cfg(not(unix))]
634            return Err(anyhow!(
635                "OS management daemon commands are only supported on Unix."
636            ));
637        }
638        SystemSubcommands::Rollback { id } => {
639            #[cfg(unix)]
640            {
641                println!(
642                    "Rolling back to generation {}...",
643                    id.to_string().yellow()
644                );
645                let response = send_request(Request::RollbackGeneration(id))?;
646                handle_response(response)?;
647            }
648            #[cfg(not(unix))]
649            let _ = id;
650            #[cfg(not(unix))]
651            return Err(anyhow!(
652                "OS management daemon commands are only supported on Unix."
653            ));
654        }
655        SystemSubcommands::Pin { id } => {
656            #[cfg(unix)]
657            {
658                let response = send_request(Request::PinGeneration(id, true))?;
659                handle_response(response)?;
660            }
661            #[cfg(not(unix))]
662            let _ = id;
663            #[cfg(not(unix))]
664            return Err(anyhow!(
665                "OS management daemon commands are only supported on Unix."
666            ));
667        }
668        SystemSubcommands::Unpin { id } => {
669            #[cfg(unix)]
670            {
671                let response = send_request(Request::PinGeneration(id, false))?;
672                handle_response(response)?;
673            }
674            #[cfg(not(unix))]
675            let _ = id;
676            #[cfg(not(unix))]
677            return Err(anyhow!(
678                "OS management daemon commands are only supported on Unix."
679            ));
680        }
681    }
682
683    Ok(())
684}
685
686/// Prints a summary of the system build plan.
687fn print_build_summary(
688    target: &str,
689    config: &zoi_system::config::SystemConfig,
690    dry_run: bool
691) {
692    use comfy_table::presets::UTF8_FULL_CONDENSED;
693    use comfy_table::{Cell, Color, Table};
694
695    println!("\n{}", " ZoiOS Build Plan ".bold().on_blue().white());
696    if dry_run {
697        println!(
698            "{}",
699            " [DRY-RUN MODE - NO CHANGES WILL BE MADE] "
700                .on_yellow()
701                .black()
702                .bold()
703        );
704    }
705    println!("{} {}\n", "Target Root:".bold(), target.cyan());
706
707    // Filesystems
708    let mut fs_table = Table::new();
709    fs_table
710        .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
711        .set_header(vec![
712            Cell::new("Action").fg(Color::Yellow),
713            Cell::new("Device").fg(Color::Yellow),
714            Cell::new("FS Type").fg(Color::Yellow),
715            Cell::new("Mount Point").fg(Color::Yellow),
716            Cell::new("Options").fg(Color::Yellow),
717        ]);
718
719    for fs in &config.filesystems {
720        fs_table.add_row(vec![
721            Cell::new("Configure (fstab)").fg(Color::Blue),
722            Cell::new(&fs.device),
723            Cell::new(&fs.fs_type),
724            Cell::new(&fs.mount).fg(Color::Cyan),
725            Cell::new(fs.options.as_deref().unwrap_or("defaults")),
726        ]);
727    }
728    println!("{}", " 1. Filesystem & Partitioning ".bold().underline());
729    println!("{fs_table}\n");
730
731    // System Info
732    let mut sys_table = Table::new();
733    sys_table
734        .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
735        .set_header(vec![
736            Cell::new("Property").fg(Color::Yellow),
737            Cell::new("Value").fg(Color::Yellow),
738        ]);
739
740    sys_table.add_row(vec![
741        Cell::new("Hostname"),
742        Cell::new(config.system.hostname.as_deref().unwrap_or("zoios"))
743            .fg(Color::Cyan),
744    ]);
745    sys_table.add_row(vec![
746        Cell::new("Timezone"),
747        Cell::new(config.system.timezone.as_deref().unwrap_or("UTC")),
748    ]);
749    sys_table.add_row(vec![
750        Cell::new("Locale"),
751        Cell::new(config.system.locale.as_deref().unwrap_or("en_US.UTF-8")),
752    ]);
753
754    println!("{}", " 2. System Configuration ".bold().underline());
755    println!("{sys_table}\n");
756
757    // Packages
758    println!("{}", " 3. Packages ".bold().underline());
759    println!(
760        "{} base packages will be installed from the registry.\n",
761        config.packages.len().to_string().green().bold()
762    );
763
764    let mut pkg_list = String::new();
765    for (i, pkg) in config.packages.iter().enumerate() {
766        let _ = write!(pkg_list, "{}", pkg.cyan());
767        if i < config.packages.len() - 1 {
768            pkg_list.push_str(", ");
769        }
770        if (i + 1) % 5 == 0 {
771            pkg_list.push('\n');
772        }
773    }
774    println!("{pkg_list}\n");
775}
776
777/// Handles the response from the system daemon.
778#[cfg(unix)]
779fn handle_response(response: Response) -> Result<()> {
780    match response {
781        Response::Ok => println!("{}", "Operation successful.".green()),
782        Response::Success(msg) => println!("{} {}", "Success:".green(), msg),
783        Response::Status(msg) => println!("Daemon status: {}", msg.cyan()),
784        Response::Error(err) => return Err(anyhow!("Daemon error: {err}")),
785        Response::Generations(_) => {
786            return Err(anyhow!("Unexpected response from daemon"));
787        }
788    }
789    Ok(())
790}