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}