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