Files
notfiles/crates/notstrap/src/lib.rs

299 lines
9.4 KiB
Rust
Raw Normal View History

use anyhow::{Context, Result};
use notcore::{HookPhase, Report, StepStatus};
use notfiles::{LinkOptions, link};
use nothooks::{HookRunner, run_phase};
use notsecrets::identities::Identity;
use notsecrets::sources::IdentitySource;
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub mod prereqs;
pub mod repo;
type EnvInjector = Box<dyn Fn(&Path, Vec<Box<dyn Identity>>) -> Result<String>>;
#[derive(Deserialize)]
pub struct NotstrapConfig {
pub bootstrap: BootstrapSection,
#[serde(default)]
pub hooks: Vec<notcore::HookSpec>,
}
#[derive(Deserialize)]
pub struct BootstrapSection {
pub dotfiles_repo: String,
pub dotfiles_dir: String,
#[serde(default = "default_bw_item")]
pub bw_age_item: String,
#[serde(default = "default_sops_file")]
pub sops_file: String,
}
pub(crate) fn default_bw_item() -> String {
"age-key-dotfiles".to_string()
}
pub(crate) fn default_sops_file() -> String {
"secrets/bootstrap.sops.env".to_string()
}
pub struct BootstrapOptions {
pub config: PathBuf,
pub force: bool,
pub key_file: Option<PathBuf>,
pub dotfiles: Option<PathBuf>,
/// None = skip prereq check (tests). Some(f) = run f().
pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>,
/// None = skip env injection (tests). Some(f) = decrypt sops at path and inject.
pub env_injector: Option<EnvInjector>,
}
pub fn run(opts: BootstrapOptions) -> Result<Report> {
let mut report = Report::default();
// 1. Prerequisites
if let Some(check) = opts.check_prereqs {
match check() {
Ok(_) => {
report.add("prerequisites", StepStatus::Ok);
}
Err(e) => {
report.add("prerequisites", StepStatus::Failed(e.to_string()));
return Ok(report);
}
}
}
// 2. Load config
let config_content = std::fs::read_to_string(&opts.config)
.with_context(|| format!("cannot read {}", opts.config.display()))?;
let cfg: NotstrapConfig = toml::from_str(&config_content)?;
let dotfiles_dir = match opts.dotfiles {
Some(d) => d,
None => notcore::expand_tilde(&cfg.bootstrap.dotfiles_dir).with_context(|| {
format!("cannot expand dotfiles_dir: {}", cfg.bootstrap.dotfiles_dir)
})?,
};
// 3. Clone dotfiles if missing
match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) {
Ok(true) => {
report.add("clone dotfiles", StepStatus::Ok);
}
Ok(false) => {
report.add("clone dotfiles", StepStatus::Skipped);
}
Err(e) => {
report.add("clone dotfiles", StepStatus::Failed(e.to_string()));
return Ok(report);
}
}
// 4. Resolve age identities
let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file {
vec![Box::new(FileSource::new(kf))]
} else {
vec![
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
Box::new(PromptSource),
]
};
match resolve_identities(sources) {
Ok(_identities) => {
report.add("age key", StepStatus::Ok);
}
Err(e) => {
report.add("age key", StepStatus::Failed(e.to_string()));
return Ok(report);
}
}
// 5. Decrypt sops secrets (optional)
if let Some(injector) = opts.env_injector {
let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file);
match injector(&sops_path) {
Ok(env_content) => {
for line in env_content.lines() {
match parse_env_line(line) {
Ok(Some((k, v))) => {
// SAFETY: `notstrap` is a single-threaded bootstrap binary; no other
// threads exist. Subsequent Command::spawn calls (git, hooks) will
// inherit these vars — keys are validated by parse_env_line before
// reaching this point (null bytes and dangerous keys are rejected).
unsafe {
std::env::set_var(&k, &v);
}
}
Ok(None) => {}
Err(e) => {
report.add("decrypt secrets", StepStatus::Failed(e.to_string()));
return Ok(report);
}
}
}
report.add("decrypt secrets", StepStatus::Ok);
}
Err(e) => {
report.add("decrypt secrets", StepStatus::Failed(e.to_string()));
return Ok(report);
}
}
}
// 6. Link dotfiles
let link_opts = LinkOptions {
force: opts.force,
no_backup: false,
dry_run: false,
verbose: false,
};
match link(&dotfiles_dir, &[], &link_opts) {
Ok(state) => {
let count = state.entries.len();
report.add(format!("link dotfiles ({count} files)"), StepStatus::Ok);
}
Err(e) => {
report.add("link dotfiles", StepStatus::Failed(e.to_string()));
}
}
// 7. Run hooks
let runner = if opts.force {
HookRunner::with_force(dotfiles_dir.clone())
} else {
HookRunner::new(dotfiles_dir.clone())
};
for (phase, label) in [
(HookPhase::Dot, "dot hooks"),
(HookPhase::Setup, "setup hooks"),
] {
let phase_report = run_phase(&cfg.hooks, &phase, &runner);
let failed = phase_report
.steps
.iter()
.filter(|s| matches!(s.status, notcore::StepStatus::Failed(_)))
.count();
let summary = if failed > 0 {
StepStatus::Failed(format!("{failed} failed"))
} else {
StepStatus::Ok
};
report.add(label, summary);
}
Ok(report)
}
/// Parse and validate a single env line (`KEY=value`), returning `(key, value)` if safe to inject.
/// Returns `Ok(None)` for blank lines and comments.
/// Returns `Err` for null bytes or dangerous keys.
pub fn parse_env_line(line: &str) -> Result<Option<(String, String)>> {
let Some((k, v)) = line.split_once('=') else {
return Ok(None);
};
let k = k.trim().to_string();
let v = v.trim().trim_matches('"').to_string();
if k.is_empty() || k.starts_with('#') {
return Ok(None);
}
// Issue #4: reject null bytes
if k.contains('\0') || v.contains('\0') {
anyhow::bail!("env key or value contains null byte: {:?}", k);
}
// Issue #5: block dangerous keys that affect dynamic linker / shell
const BLOCKED: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"IFS",
"PATH",
];
if BLOCKED.contains(&k.as_str()) {
anyhow::bail!("refusing to inject sensitive env key: {k}");
}
Ok(Some((k, v)))
}
#[cfg(test)]
mod tests {
use super::*;
/// Issue #4: null byte in key must be rejected.
#[test]
fn parse_env_line_rejects_null_in_key() {
let result = parse_env_line("KEY\0EVIL=value");
assert!(result.is_err(), "expected error for null byte in key");
assert!(result.unwrap_err().to_string().contains("null byte"));
}
/// Issue #4: null byte in value must be rejected.
#[test]
fn parse_env_line_rejects_null_in_value() {
let result = parse_env_line("KEY=val\0ue");
assert!(result.is_err(), "expected error for null byte in value");
assert!(result.unwrap_err().to_string().contains("null byte"));
}
/// Issue #5: LD_PRELOAD must be blocked.
#[test]
fn parse_env_line_blocks_ld_preload() {
let result = parse_env_line("LD_PRELOAD=/evil/lib.so");
assert!(result.is_err(), "expected error for LD_PRELOAD");
assert!(
result
.unwrap_err()
.to_string()
.contains("sensitive env key")
);
}
/// Issue #5: PATH must be blocked.
#[test]
fn parse_env_line_blocks_path() {
let result = parse_env_line("PATH=/attacker/bin:/usr/bin");
assert!(result.is_err(), "expected error for PATH");
}
/// Issue #5: DYLD_INSERT_LIBRARIES must be blocked.
#[test]
fn parse_env_line_blocks_dyld_insert_libraries() {
let result = parse_env_line("DYLD_INSERT_LIBRARIES=/evil/lib.dylib");
assert!(result.is_err(), "expected error for DYLD_INSERT_LIBRARIES");
}
/// Normal env lines must pass through unchanged.
#[test]
fn parse_env_line_accepts_normal_keys() {
let result = parse_env_line("MY_TOKEN=abc123").unwrap();
assert_eq!(result, Some(("MY_TOKEN".to_string(), "abc123".to_string())));
}
/// Blank lines return None.
#[test]
fn parse_env_line_ignores_blank() {
assert_eq!(parse_env_line("").unwrap(), None);
assert_eq!(parse_env_line(" ").unwrap(), None);
}
/// Comment lines return None.
#[test]
fn parse_env_line_ignores_comments() {
assert_eq!(parse_env_line("# THIS_IS_A_COMMENT=value").unwrap(), None);
}
/// Quoted values are unquoted.
#[test]
fn parse_env_line_strips_quotes() {
let result = parse_env_line(r#"API_KEY="secret""#).unwrap();
assert_eq!(result, Some(("API_KEY".to_string(), "secret".to_string())));
}
}