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                        None,
292                        false,
293                        None,
294                        false,
295                        false,
296                        false,
297                        false,
298                        3,
299                        false,
300                        false,
301                        Some(project_config)
302                    )?;
303                }
304
305                // Finalize Generation
306                zoi_system::distro::finalize_first_generation(
307                    target_path,
308                    config.packages.clone(),
309                    dry_run
310                )?;
311
312                let success_msg = if dry_run {
313                    "Dry-run complete."
314                } else {
315                    "ZoiOS build complete."
316                };
317                println!(
318                    "{} {} on {}.",
319                    "Success:".green(),
320                    success_msg,
321                    target.cyan()
322                );
323            }
324            DistroSubcommands::Chroot {
325                target,
326                run,
327                verbose
328            } => {
329                let target_path = std::path::Path::new(&target);
330                if !target_path.exists() {
331                    return Err(anyhow!(
332                        "Target path '{target}' does not exist."
333                    ));
334                }
335
336                let os_release = target_path.join("etc/os-release");
337                if !os_release.exists() {
338                    return Err(anyhow!(
339                        "Target path '{target}' is not a valid ZoiOS root \
340                         (missing /etc/os-release)."
341                    ));
342                }
343
344                // --- BOOTSTRAP AUDIT ---
345
346                // Filesystem check
347                let fs_type = std::process::Command::new("stat")
348                    .arg("-f")
349                    .arg("-c")
350                    .arg("%T")
351                    .arg(&target)
352                    .output();
353                if let Ok(out) = fs_type {
354                    let t =
355                        String::from_utf8_lossy(&out.stdout).trim().to_string();
356                    if t == "msdos" || t == "vfat" {
357                        eprintln!(
358                            "\n{} CRITICAL: Target filesystem is '{}'. ZoiOS \
359                             requires a Linux filesystem (ext4, btrfs, xfs) \
360                             to support hard links and permissions. Your \
361                             bootstrap will NOT work on FAT32.",
362                            "Error:".red().bold(),
363                            t.yellow()
364                        );
365                    } else if verbose {
366                        println!(
367                            "{} Filesystem type: {}",
368                            "::".bold().blue(),
369                            t.green()
370                        );
371                    }
372                }
373
374                // Merged-Usr Symlink Audit
375                let mut broken_layout = false;
376                for sym in &["bin", "sbin", "lib", "lib64"] {
377                    let p = target_path.join(sym);
378                    let meta = std::fs::symlink_metadata(&p);
379                    if let Ok(m) = meta {
380                        if !m.file_type().is_symlink() {
381                            eprintln!(
382                                "{} WARNING: '/{}' is a real directory, but \
383                                 ZoiOS expects a merged-usr symlink to \
384                                 'usr/{}'.",
385                                "::".bold().yellow(),
386                                sym,
387                                sym
388                            );
389                            broken_layout = true;
390                        } else if let Ok(target) = std::fs::read_link(&p) {
391                            if target.is_absolute() {
392                                eprintln!(
393                                    "{} WARNING: '/{}' is an absolute symlink \
394                                     to '{}'. This WILL break inside the \
395                                     chroot. It should be relative (e.g. \
396                                     'usr/{}').",
397                                    "::".bold().yellow(),
398                                    sym,
399                                    target.display(),
400                                    sym
401                                );
402                                broken_layout = true;
403                            } else {
404                                let abs_target = target_path.join(target);
405                                if !abs_target.exists() {
406                                    eprintln!(
407                                        "{} WARNING: Symlink '/{}' points to \
408                                         non-existent path '{}'.",
409                                        "::".bold().yellow(),
410                                        sym,
411                                        abs_target.display()
412                                    );
413                                    broken_layout = true;
414                                }
415                            }
416                        }
417                    } else if *sym != "sbin" {
418                        eprintln!(
419                            "{} WARNING: '/{}' is missing! Your binaries will \
420                             likely fail to find their loader or shell.",
421                            "::".bold().yellow(),
422                            sym
423                        );
424                        broken_layout = true;
425                    }
426                }
427
428                // Dynamic Loader Validation (The common cause of 139)
429                let mut loader_found = false;
430                let loaders = [
431                    "usr/lib/ld-linux-x86-64.so.2",
432                    "lib64/ld-linux-x86-64.so.2",
433                    "lib/ld-linux-x86-64.so.2"
434                ];
435                for l in &loaders {
436                    let lp = target_path.join(l);
437                    if lp.exists() {
438                        loader_found = true;
439                        if let Ok(mut file) = std::fs::File::open(&lp) {
440                            let mut magic = [0u8; 4];
441                            if file.read_exact(&mut magic).is_ok() {
442                                if magic != [0x7f, b'E', b'L', b'F'] {
443                                    eprintln!(
444                                        "{} CRITICAL: Dynamic loader '{}' is \
445                                         NOT an ELF file! Your glibc \
446                                         installation is corrupted.",
447                                        "Error:".red().bold(),
448                                        l
449                                    );
450                                }
451                            } else {
452                                eprintln!(
453                                    "{} CRITICAL: Dynamic loader '{}' is 0 \
454                                     bytes or unreadable.",
455                                    "Error:".red().bold(),
456                                    l
457                                );
458                            }
459                        }
460                        break;
461                    }
462                }
463                if !loader_found {
464                    eprintln!(
465                        "{} Dynamic loader not found. Binaries WILL Segfault \
466                         (139).",
467                        "::".bold().yellow()
468                    );
469                }
470
471                if broken_layout || !loader_found {
472                    println!(
473                        "{} Hint: Your ZoiOS bootstrap appears incomplete or \
474                         corrupted. Please verify your base system packages.",
475                        "::".bold().blue()
476                    );
477                }
478
479                if verbose {
480                    println!(
481                        "{} Entering sysroot at {}...",
482                        "::".bold().blue(),
483                        target.cyan()
484                    );
485                }
486
487                let mut envs = std::collections::HashMap::new();
488                envs.insert(
489                    "PATH".to_string(),
490                    "/usr/bin:/bin:/usr/sbin:/sbin".to_string()
491                );
492                envs.insert("SHELL".to_string(), "/usr/bin/bash".to_string());
493                envs.insert(
494                    "TERM".to_string(),
495                    std::env::var("TERM")
496                        .unwrap_or_else(|_| "xterm-256color".to_string())
497                );
498
499                // Resolve shell path in guest (Prefer /usr/bin/bash)
500                let mut shell_bin = std::path::PathBuf::from("/usr/bin/bash");
501                if !target_path.join("usr/bin/bash").exists()
502                    && target_path.join("bin/bash").exists()
503                {
504                    shell_bin = std::path::PathBuf::from("/bin/bash");
505                }
506
507                if verbose {
508                    let cmd_display = if let Some(r) = &run {
509                        format!("{} -c '{}'", shell_bin.display(), r)
510                    } else {
511                        shell_bin.display().to_string()
512                    };
513                    println!(
514                        "{} Running inside chroot: {}",
515                        "::".bold().blue(),
516                        cmd_display.green()
517                    );
518                }
519
520                #[cfg(target_os = "linux")]
521                {
522                    let mut cmd = if let Some(run_cmd) = run {
523                        let args = vec!["-c".to_string(), run_cmd];
524                        crate::sandbox::wrap_command_in_root(
525                            target_path,
526                            &shell_bin,
527                            &args,
528                            &envs,
529                            &[],
530                            false
531                        )?
532                    } else {
533                        crate::sandbox::wrap_command_in_root(
534                            target_path,
535                            &shell_bin,
536                            &[],
537                            &envs,
538                            &[],
539                            false
540                        )?
541                    };
542
543                    if verbose {
544                        println!(
545                            "{} Full command: {:?}",
546                            "::".bold().blue(),
547                            cmd
548                        );
549                    }
550
551                    let status = cmd.status()?;
552                    if !status.success() {
553                        let code = status.code().unwrap_or(1);
554                        eprintln!(
555                            "\n{} Chroot execution failed with exit code: {}",
556                            "Error:".red().bold(),
557                            code.to_string().yellow()
558                        );
559                        if code == 139 {
560                            println!(
561                                "{} Hint: Segfaults (139) often indicate an \
562                                 instruction set mismatch (e.g. x86-64-v3 \
563                                 binaries on older CPUs).",
564                                "::".bold().blue()
565                            );
566                        }
567                        std::process::exit(code);
568                    }
569                }
570
571                #[cfg(not(target_os = "linux"))]
572                return Err(anyhow!(
573                    "Distro chroot is only supported on Linux via Bubblewrap."
574                ));
575            }
576        },
577        SystemSubcommands::Apply { file } => {
578            #[cfg(unix)]
579            {
580                println!(
581                    "Reading system configuration from {}...",
582                    file.cyan()
583                );
584                let config = load_system_lua(&file)?;
585                let response =
586                    send_request(Request::ApplySystemConfig(Box::new(config)))?;
587                handle_response(response)?;
588            }
589            #[cfg(not(unix))]
590            {
591                let _ = file;
592                return Err(anyhow!(
593                    "OS management daemon commands are only supported on Unix."
594                ));
595            }
596        }
597        SystemSubcommands::List => {
598            #[cfg(unix)]
599            {
600                let response = send_request(Request::ListGenerations)?;
601                match response {
602                    Response::Generations(gens) => {
603                        println!(
604                            "{:<5} {:<25} {:<50}",
605                            "ID", "Created At", "Packages"
606                        );
607                        println!("{:-<80}", "");
608                        for generation in gens {
609                            println!(
610                                "{:<5} {:<25} {:<50}",
611                                generation.id,
612                                generation.created_at.to_rfc3339(),
613                                generation.packages.join(", ")
614                            );
615                        }
616                    }
617                    _ => handle_response(response)?
618                }
619            }
620            #[cfg(not(unix))]
621            return Err(anyhow!(
622                "OS management daemon commands are only supported on Unix."
623            ));
624        }
625        SystemSubcommands::Status => {
626            #[cfg(unix)]
627            {
628                let response = send_request(Request::GetStatus)?;
629                handle_response(response)?;
630            }
631            #[cfg(not(unix))]
632            return Err(anyhow!(
633                "OS management daemon commands are only supported on Unix."
634            ));
635        }
636        SystemSubcommands::Rollback { id } => {
637            #[cfg(unix)]
638            {
639                println!(
640                    "Rolling back to generation {}...",
641                    id.to_string().yellow()
642                );
643                let response = send_request(Request::RollbackGeneration(id))?;
644                handle_response(response)?;
645            }
646            #[cfg(not(unix))]
647            let _ = id;
648            #[cfg(not(unix))]
649            return Err(anyhow!(
650                "OS management daemon commands are only supported on Unix."
651            ));
652        }
653        SystemSubcommands::Pin { id } => {
654            #[cfg(unix)]
655            {
656                let response = send_request(Request::PinGeneration(id, true))?;
657                handle_response(response)?;
658            }
659            #[cfg(not(unix))]
660            let _ = id;
661            #[cfg(not(unix))]
662            return Err(anyhow!(
663                "OS management daemon commands are only supported on Unix."
664            ));
665        }
666        SystemSubcommands::Unpin { id } => {
667            #[cfg(unix)]
668            {
669                let response = send_request(Request::PinGeneration(id, false))?;
670                handle_response(response)?;
671            }
672            #[cfg(not(unix))]
673            let _ = id;
674            #[cfg(not(unix))]
675            return Err(anyhow!(
676                "OS management daemon commands are only supported on Unix."
677            ));
678        }
679    }
680
681    Ok(())
682}
683
684/// Prints a summary of the system build plan.
685fn print_build_summary(
686    target: &str,
687    config: &zoi_system::config::SystemConfig,
688    dry_run: bool
689) {
690    use comfy_table::presets::UTF8_FULL_CONDENSED;
691    use comfy_table::{Cell, Color, Table};
692
693    println!("\n{}", " ZoiOS Build Plan ".bold().on_blue().white());
694    if dry_run {
695        println!(
696            "{}",
697            " [DRY-RUN MODE - NO CHANGES WILL BE MADE] "
698                .on_yellow()
699                .black()
700                .bold()
701        );
702    }
703    println!("{} {}\n", "Target Root:".bold(), target.cyan());
704
705    // Filesystems
706    let mut fs_table = Table::new();
707    fs_table
708        .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
709        .set_header(vec![
710            Cell::new("Action").fg(Color::Yellow),
711            Cell::new("Device").fg(Color::Yellow),
712            Cell::new("FS Type").fg(Color::Yellow),
713            Cell::new("Mount Point").fg(Color::Yellow),
714            Cell::new("Options").fg(Color::Yellow),
715        ]);
716
717    for fs in &config.filesystems {
718        fs_table.add_row(vec![
719            Cell::new("Configure (fstab)").fg(Color::Blue),
720            Cell::new(&fs.device),
721            Cell::new(&fs.fs_type),
722            Cell::new(&fs.mount).fg(Color::Cyan),
723            Cell::new(fs.options.as_deref().unwrap_or("defaults")),
724        ]);
725    }
726    println!("{}", " 1. Filesystem & Partitioning ".bold().underline());
727    println!("{fs_table}\n");
728
729    // System Info
730    let mut sys_table = Table::new();
731    sys_table
732        .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
733        .set_header(vec![
734            Cell::new("Property").fg(Color::Yellow),
735            Cell::new("Value").fg(Color::Yellow),
736        ]);
737
738    sys_table.add_row(vec![
739        Cell::new("Hostname"),
740        Cell::new(config.system.hostname.as_deref().unwrap_or("zoios"))
741            .fg(Color::Cyan),
742    ]);
743    sys_table.add_row(vec![
744        Cell::new("Timezone"),
745        Cell::new(config.system.timezone.as_deref().unwrap_or("UTC")),
746    ]);
747    sys_table.add_row(vec![
748        Cell::new("Locale"),
749        Cell::new(config.system.locale.as_deref().unwrap_or("en_US.UTF-8")),
750    ]);
751
752    println!("{}", " 2. System Configuration ".bold().underline());
753    println!("{sys_table}\n");
754
755    // Packages
756    println!("{}", " 3. Packages ".bold().underline());
757    println!(
758        "{} base packages will be installed from the registry.\n",
759        config.packages.len().to_string().green().bold()
760    );
761
762    let mut pkg_list = String::new();
763    for (i, pkg) in config.packages.iter().enumerate() {
764        let _ = write!(pkg_list, "{}", pkg.cyan());
765        if i < config.packages.len() - 1 {
766            pkg_list.push_str(", ");
767        }
768        if (i + 1) % 5 == 0 {
769            pkg_list.push('\n');
770        }
771    }
772    println!("{pkg_list}\n");
773}
774
775/// Handles the response from the system daemon.
776#[cfg(unix)]
777fn handle_response(response: Response) -> Result<()> {
778    match response {
779        Response::Ok => println!("{}", "Operation successful.".green()),
780        Response::Success(msg) => println!("{} {}", "Success:".green(), msg),
781        Response::Status(msg) => println!("Daemon status: {}", msg.cyan()),
782        Response::Error(err) => return Err(anyhow!("Daemon error: {err}")),
783        Response::Generations(_) => {
784            return Err(anyhow!("Unexpected response from daemon"));
785        }
786    }
787    Ok(())
788}