Skip to main content

nex_pkg/
bootstrap.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{bail, Context, Result};
5use console::style;
6
7use crate::discover::Platform;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum BootstrapScope {
11    DarwinBootstrap,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct BootstrapFinding {
16    pub id: &'static str,
17    pub message: String,
18    pub repair: Option<BootstrapRepair>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct BootstrapRepair {
23    pub description: String,
24    pub kind: BootstrapRepairKind,
25}
26
27impl BootstrapRepair {
28    pub fn command_preview(&self) -> Vec<String> {
29        match &self.kind {
30            BootstrapRepairKind::MoveShellRc { from } => {
31                let to = next_backup_path(from, "before-nix-darwin");
32                vec![
33                    "sudo".to_string(),
34                    "mv".to_string(),
35                    from.display().to_string(),
36                    to.display().to_string(),
37                ]
38            }
39            BootstrapRepairKind::EnsureSyntheticConf { path } => vec![
40                format!("sudo touch {}", path.display()),
41                format!("sudo chown root:wheel {}", path.display()),
42                format!("sudo chmod 0644 {}", path.display()),
43            ],
44        }
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum BootstrapRepairKind {
50    MoveShellRc { from: PathBuf },
51    EnsureSyntheticConf { path: PathBuf },
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BootstrapReport {
56    pub scope: BootstrapScope,
57    pub findings: Vec<BootstrapFinding>,
58}
59
60impl BootstrapReport {
61    pub fn has_blockers(&self) -> bool {
62        !self.findings.is_empty()
63    }
64}
65
66pub fn check(platform: Platform) -> Result<Option<BootstrapReport>> {
67    match platform {
68        Platform::Darwin => Ok(Some(check_darwin_bootstrap()?)),
69        Platform::Linux => Ok(None),
70    }
71}
72
73pub fn print_recommendations(report: &BootstrapReport) {
74    if !report.has_blockers() {
75        return;
76    }
77    eprintln!();
78    eprintln!(
79        "  {} Darwin bootstrap blockers detected:",
80        style("!").yellow().bold()
81    );
82    for finding in &report.findings {
83        eprintln!("    {} {}", style("!").yellow(), finding.message);
84        if let Some(repair) = &finding.repair {
85            for command in repair.command_preview() {
86                eprintln!("      {}", style(command).dim());
87            }
88        }
89    }
90    eprintln!();
91    eprintln!(
92        "  Run {} before activating this machine.",
93        style("nex doctor --fix darwin-bootstrap").bold()
94    );
95}
96
97pub fn ensure_switch_ready(platform: Platform) -> Result<()> {
98    if let Some(report) = check(platform)? {
99        if report.has_blockers() {
100            print_recommendations(&report);
101            bail!("Darwin bootstrap blockers must be fixed before activation");
102        }
103    }
104    Ok(())
105}
106
107pub fn maybe_repair_for_init(platform: Platform, dry_run: bool) -> Result<()> {
108    let Some(report) = check(platform)? else {
109        return Ok(());
110    };
111    if !report.has_blockers() {
112        return Ok(());
113    }
114    print_recommendations(&report);
115    if dry_run {
116        return Ok(());
117    }
118    let confirm = crate::input::input().confirm("  Repair Darwin bootstrap blockers now?", true)?;
119    if confirm {
120        repair(&report)?;
121    }
122    Ok(())
123}
124
125pub fn repair(report: &BootstrapReport) -> Result<()> {
126    match report.scope {
127        BootstrapScope::DarwinBootstrap => repair_darwin_bootstrap(report),
128    }
129}
130
131fn check_darwin_bootstrap() -> Result<BootstrapReport> {
132    check_darwin_bootstrap_at(&etc_root())
133}
134
135fn check_darwin_bootstrap_at(etc: &Path) -> Result<BootstrapReport> {
136    let mut findings = Vec::new();
137
138    for name in ["bashrc", "zshrc"] {
139        let path = etc.join(name);
140        if shell_rc_blocks_activation(&path)? {
141            let backup = next_backup_path(&path, "before-nix-darwin");
142            findings.push(BootstrapFinding {
143                id: "darwin.shellrc.unmanaged",
144                message: format!(
145                    "{} has unmanaged content and may block nix-darwin activation",
146                    path.display()
147                ),
148                repair: Some(BootstrapRepair {
149                    description: format!("move {} to {}", path.display(), backup.display()),
150                    kind: BootstrapRepairKind::MoveShellRc { from: path },
151                }),
152            });
153        }
154    }
155
156    add_synthetic_conf_findings(&etc.join("synthetic.conf"), &mut findings)?;
157
158    Ok(BootstrapReport {
159        scope: BootstrapScope::DarwinBootstrap,
160        findings,
161    })
162}
163
164fn add_synthetic_conf_findings(path: &Path, findings: &mut Vec<BootstrapFinding>) -> Result<()> {
165    if !path.exists() {
166        findings.push(synthetic_conf_finding(
167            "darwin.synthetic-conf.missing",
168            format!("{} is missing", path.display()),
169            path,
170        ));
171        return Ok(());
172    }
173
174    let metadata =
175        std::fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
176    #[cfg(unix)]
177    {
178        use std::os::unix::fs::{MetadataExt, PermissionsExt};
179        let mode = metadata.permissions().mode() & 0o777;
180        if metadata.uid() != 0 || metadata.gid() != 0 {
181            findings.push(synthetic_conf_finding(
182                "darwin.synthetic-conf.owner",
183                format!("{} is not owned by root:wheel", path.display()),
184                path,
185            ));
186        }
187        if mode != 0o644 {
188            findings.push(synthetic_conf_finding(
189                "darwin.synthetic-conf.mode",
190                format!("{} has mode {:03o}; expected 644", path.display(), mode),
191                path,
192            ));
193        }
194    }
195    Ok(())
196}
197
198fn synthetic_conf_finding(id: &'static str, message: String, path: &Path) -> BootstrapFinding {
199    BootstrapFinding {
200        id,
201        message,
202        repair: Some(BootstrapRepair {
203            description: format!("ensure {} exists with root:wheel 0644", path.display()),
204            kind: BootstrapRepairKind::EnsureSyntheticConf {
205                path: path.to_path_buf(),
206            },
207        }),
208    }
209}
210
211fn shell_rc_blocks_activation(path: &Path) -> Result<bool> {
212    if !path.exists() || path.is_symlink() {
213        return Ok(false);
214    }
215    if !path.is_file() {
216        return Ok(false);
217    }
218    let content =
219        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
220    Ok(!content.contains("nix-darwin") && !content.contains("NIX_DARWIN"))
221}
222
223fn repair_darwin_bootstrap(report: &BootstrapReport) -> Result<()> {
224    eprintln!();
225    eprintln!("  {} fixing Darwin bootstrap", style(">>>").cyan().bold());
226    for finding in &report.findings {
227        let Some(repair) = &finding.repair else {
228            continue;
229        };
230        match &repair.kind {
231            BootstrapRepairKind::MoveShellRc { from } => {
232                let to = next_backup_path(from, "before-nix-darwin");
233                run_sudo(
234                    "mv",
235                    &[from.display().to_string(), to.display().to_string()],
236                )?;
237                eprintln!(
238                    "  {} moved {} to {}",
239                    style("✓").green(),
240                    from.display(),
241                    to.display()
242                );
243            }
244            BootstrapRepairKind::EnsureSyntheticConf { path } => {
245                run_sudo("touch", &[path.display().to_string()])?;
246                run_sudo(
247                    "chown",
248                    &["root:wheel".to_string(), path.display().to_string()],
249                )?;
250                run_sudo("chmod", &["0644".to_string(), path.display().to_string()])?;
251                eprintln!(
252                    "  {} ensured {} root:wheel 0644",
253                    style("✓").green(),
254                    path.display()
255                );
256            }
257        }
258    }
259    Ok(())
260}
261
262fn run_sudo(program: &str, args: &[String]) -> Result<()> {
263    let status = Command::new("sudo")
264        .arg(program)
265        .args(args)
266        .status()
267        .with_context(|| format!("failed to run sudo {program}"))?;
268    if !status.success() {
269        bail!(
270            "sudo {program} failed with exit code {}",
271            status.code().unwrap_or(-1)
272        );
273    }
274    Ok(())
275}
276
277fn etc_root() -> PathBuf {
278    if std::env::var_os("NEX_TESTING").is_some() {
279        if let Some(root) = std::env::var_os("NEX_TEST_ETC_ROOT") {
280            return PathBuf::from(root);
281        }
282    }
283    PathBuf::from("/etc")
284}
285
286fn next_backup_path(path: &Path, suffix: &str) -> PathBuf {
287    let base = PathBuf::from(format!("{}.{}", path.display(), suffix));
288    if !base.exists() {
289        return base;
290    }
291    for i in 1.. {
292        let candidate = PathBuf::from(format!("{}.{}.{}", path.display(), suffix, i));
293        if !candidate.exists() {
294            return candidate;
295        }
296    }
297    unreachable!()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::{
303        check_darwin_bootstrap_at, etc_root, next_backup_path, shell_rc_blocks_activation,
304    };
305    use std::fs;
306    use std::path::{Path, PathBuf};
307    use tempfile::tempdir;
308
309    #[test]
310    fn backup_path_uses_suffix_when_free() {
311        assert_eq!(
312            next_backup_path(
313                Path::new("/tmp/nex-bootstrap-test-file"),
314                "before-nix-darwin"
315            ),
316            Path::new("/tmp/nex-bootstrap-test-file.before-nix-darwin")
317        );
318    }
319
320    #[test]
321    fn unmanaged_shell_rc_blocks_activation() {
322        let dir = tempdir().expect("temp dir");
323        let path = dir.path().join("bashrc");
324        fs::write(&path, "export PATH=/usr/local/bin:$PATH\n").expect("write shell rc");
325        assert!(shell_rc_blocks_activation(&path).expect("classify shell rc"));
326    }
327
328    #[test]
329    fn nix_darwin_shell_rc_does_not_block_activation() {
330        let dir = tempdir().expect("temp dir");
331        let path = dir.path().join("zshrc");
332        fs::write(&path, "# nix-darwin managed\n").expect("write shell rc");
333        assert!(!shell_rc_blocks_activation(&path).expect("classify shell rc"));
334    }
335
336    #[test]
337    fn test_etc_root_requires_testing_guard() {
338        let dir = tempdir().expect("temp dir");
339        let previous_testing = std::env::var_os("NEX_TESTING");
340        let previous_root = std::env::var_os("NEX_TEST_ETC_ROOT");
341        std::env::remove_var("NEX_TESTING");
342        std::env::remove_var("NEX_TEST_ETC_ROOT");
343        std::env::set_var("NEX_TEST_ETC_ROOT", dir.path());
344        assert_eq!(etc_root(), PathBuf::from("/etc"));
345        std::env::remove_var("NEX_TEST_ETC_ROOT");
346        if let Some(value) = previous_testing {
347            std::env::set_var("NEX_TESTING", value);
348        }
349        if let Some(value) = previous_root {
350            std::env::set_var("NEX_TEST_ETC_ROOT", value);
351        }
352    }
353
354    #[test]
355    fn darwin_check_reports_shellrc_and_synthetic_conf_blockers() {
356        let dir = tempdir().expect("temp dir");
357        fs::write(dir.path().join("bashrc"), "legacy bashrc\n").expect("write bashrc");
358        let report = check_darwin_bootstrap_at(dir.path()).expect("check bootstrap");
359
360        assert_eq!(report.findings.len(), 2);
361        assert!(report
362            .findings
363            .iter()
364            .any(|f| f.id == "darwin.shellrc.unmanaged"));
365        assert!(report
366            .findings
367            .iter()
368            .any(|f| f.id == "darwin.synthetic-conf.missing"));
369    }
370
371    #[test]
372    fn darwin_check_reports_synthetic_conf_mode_blocker() {
373        let dir = tempdir().expect("temp dir");
374        let synthetic = dir.path().join("synthetic.conf");
375        fs::write(&synthetic, "").expect("write synthetic conf");
376        #[cfg(unix)]
377        {
378            use std::os::unix::fs::PermissionsExt;
379            let mut permissions = fs::metadata(&synthetic).expect("metadata").permissions();
380            permissions.set_mode(0o600);
381            fs::set_permissions(&synthetic, permissions).expect("set mode");
382        }
383        let report = check_darwin_bootstrap_at(dir.path()).expect("check bootstrap");
384
385        assert!(report
386            .findings
387            .iter()
388            .any(|f| f.id == "darwin.synthetic-conf.mode"));
389    }
390}