use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use crate::fs::Fs;
use crate::shell::activation::{self, EvidenceVersion};
use crate::shell::probe::{spawn_captured, SpawnOutcome};
use crate::shell::rc::{self, HookupShell};
pub const TRACE_MARKER: &str = "dodot-trace|";
pub const RECORD_SUFFIX: &str = "|> ";
pub fn ps4(shell: HookupShell) -> String {
match shell {
HookupShell::Zsh => format!("+{TRACE_MARKER}%N|%i|$PWD|$PATH{RECORD_SUFFIX}"),
HookupShell::Bash => {
format!("+{TRACE_MARKER}${{BASH_SOURCE}}|${{LINENO}}|${{PWD}}|${{PATH}}{RECORD_SUFFIX}")
}
}
}
fn trace_args(shell: HookupShell) -> &'static [&'static str] {
match shell {
HookupShell::Zsh => &["-o", "promptsubst", "-o", "xtrace", "-i", "-c", "true"],
HookupShell::Bash => &["-x", "-i", "-c", "true"],
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceRecord {
pub file: String,
pub line: usize,
pub cwd: String,
pub path: String,
}
pub fn parse_trace(stderr: &str) -> Vec<TraceRecord> {
stderr.lines().filter_map(parse_record).collect()
}
fn parse_record(line: &str) -> Option<TraceRecord> {
let stripped = line.trim_start_matches('+');
if stripped.len() == line.len() {
return None;
}
let rest = stripped.strip_prefix(TRACE_MARKER)?;
let (file, rest) = rest.split_once('|')?;
let (line_no, rest) = rest.split_once('|')?;
let line_no = line_no.parse::<usize>().ok()?;
let (cwd, rest) = rest.split_once('|')?;
let path = &rest[..rest.find(RECORD_SUFFIX)?];
Some(TraceRecord {
file: file.to_string(),
line: line_no,
cwd: cwd.to_string(),
path: path.to_string(),
})
}
pub fn record_at<'a>(
records: &'a [TraceRecord],
files: &[&Path],
line: usize,
) -> Option<&'a TraceRecord> {
records
.iter()
.find(|r| r.line == line && files.iter().any(|f| Path::new(&r.file) == *f))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookForm {
Eval,
FileSource(SourcedScript),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourcedScript {
Path(PathBuf),
Unresolved { raw: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hook {
pub line: usize,
pub form: HookForm,
}
pub fn find_hook(text: &str, home: &Path) -> Option<Hook> {
for (idx, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.starts_with('#') {
continue;
}
let form = if line.contains("dodot init-sh") {
HookForm::Eval
} else if line.contains("dodot-init.sh") {
HookForm::FileSource(sourced_script(line, home))
} else {
continue;
};
return Some(Hook {
line: idx + 1,
form,
});
}
None
}
fn sourced_script(line: &str, home: &Path) -> SourcedScript {
let Some(raw) = source_argument(line) else {
return SourcedScript::Unresolved {
raw: line.to_string(),
};
};
match expand_hook_path(&raw, home) {
Some(path) => SourcedScript::Path(path),
None => SourcedScript::Unresolved { raw },
}
}
fn source_argument(line: &str) -> Option<String> {
let mut rest = line;
loop {
let (word, after) = next_word(rest)?;
if word == "." || word == "source" {
return next_word(after).map(|(argument, _)| argument);
}
rest = after;
}
}
fn next_word(rest: &str) -> Option<(String, &str)> {
let rest = rest.trim_start();
if rest.is_empty() {
return None;
}
for quote in ['"', '\''] {
if let Some(body) = rest.strip_prefix(quote) {
let end = body.find(quote)?;
let after = &body[end + 1..];
let word_ends_here = after.is_empty() || after.starts_with(char::is_whitespace);
return word_ends_here.then(|| (body[..end].to_string(), after));
}
}
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
Some((rest[..end].to_string(), &rest[end..]))
}
const NEEDS_A_SHELL: [char; 10] = ['$', '`', '*', '?', '~', ';', '&', '|', '<', '>'];
fn expand_hook_path(raw: &str, home: &Path) -> Option<PathBuf> {
let under_home = ["${HOME}", "$HOME", "~"]
.iter()
.find_map(|prefix| raw.strip_prefix(prefix));
let rest = under_home.map_or(raw, |rest| rest.trim_start_matches('/'));
if rest.contains(NEEDS_A_SHELL) {
return None;
}
match under_home {
Some(_) => Some(home.join(rest)),
None => Path::new(rest).is_absolute().then(|| PathBuf::from(rest)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
MissingDir,
NotPresent,
DanglingSymlink { target: Option<PathBuf> },
NotExecutable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
pub path: PathBuf,
pub skipped: Option<SkipReason>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub candidates: Vec<Candidate>,
pub winner: Option<PathBuf>,
}
impl Resolution {
pub fn notable_skips(&self) -> impl Iterator<Item = &Candidate> {
self.candidates.iter().filter(|c| {
matches!(
c.skipped,
Some(SkipReason::DanglingSymlink { .. }) | Some(SkipReason::NotExecutable)
)
})
}
}
pub fn resolve_on_path(fs: &dyn Fs, path_var: &str, cwd: &Path, binary: &str) -> Resolution {
let mut candidates = Vec::new();
let mut winner = None;
for entry in path_var.split(':') {
let dir = match entry {
"" => cwd.to_path_buf(),
other if Path::new(other).is_absolute() => PathBuf::from(other),
relative => cwd.join(relative),
};
let candidate = dir.join(binary);
let skipped = classify(fs, &dir, &candidate);
let won = skipped.is_none();
candidates.push(Candidate {
path: candidate.clone(),
skipped,
});
if won {
winner = Some(candidate);
break;
}
}
Resolution { candidates, winner }
}
fn classify(fs: &dyn Fs, dir: &Path, candidate: &Path) -> Option<SkipReason> {
if !fs.is_dir(dir) {
return Some(SkipReason::MissingDir);
}
if fs.is_symlink(candidate) && !fs.exists(candidate) {
return Some(SkipReason::DanglingSymlink {
target: fs.readlink(candidate).ok(),
});
}
if !fs.exists(candidate) {
return Some(SkipReason::NotPresent);
}
match fs.stat(candidate) {
Ok(meta) if meta.is_file && meta.mode & 0o111 != 0 => None,
_ => Some(SkipReason::NotExecutable),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceVerdict {
HookNeverRan,
Unresolvable {
path: String,
resolution: Resolution,
},
DifferentBinary {
found: PathBuf,
found_version: Option<String>,
running: PathBuf,
resolution: Resolution,
},
RunningBinary { path: PathBuf },
ScriptMissing { script: PathBuf },
ScriptPresent { script: PathBuf },
ScriptSkewed {
script: PathBuf,
version: EvidenceVersion,
},
ScriptStale {
script: PathBuf,
found: u64,
expected: u64,
},
ScriptUnverified { script: PathBuf, reason: String },
ScriptUnresolved { raw: String },
}
pub fn judge_file_source_hook(
fs: &dyn Fs,
script: PathBuf,
reference: Option<u64>,
running: &str,
) -> TraceVerdict {
if !matches!(fs.stat(&script), Ok(meta) if meta.is_file) {
return TraceVerdict::ScriptMissing { script };
}
let Ok(text) = fs.read_to_string(&script) else {
return TraceVerdict::ScriptUnverified {
script,
reason: "it could not be read".into(),
};
};
let Some(found) = activation::parse_script_generation(&text) else {
return TraceVerdict::ScriptUnverified {
script,
reason: "it carries no dodot activation stamp, so it is not a script dodot generated"
.into(),
};
};
let version = EvidenceVersion::from_field(activation::parse_script_version(&text).as_deref());
if activation::is_skewed(Some(&version), running) {
return TraceVerdict::ScriptSkewed { script, version };
}
match activation::classify_stamp(Some(found), reference) {
activation::StampState::Current => TraceVerdict::ScriptPresent { script },
_ => TraceVerdict::ScriptStale {
script,
found,
expected: reference.unwrap_or(found),
},
}
}
pub fn judge_eval_hook(
fs: &dyn Fs,
record: &TraceRecord,
running_exe: &Path,
version_of: &dyn Fn(&Path) -> Option<String>,
) -> TraceVerdict {
let resolution = resolve_on_path(fs, &record.path, Path::new(&record.cwd), "dodot");
let Some(winner) = resolution.winner.clone() else {
return TraceVerdict::Unresolvable {
path: record.path.clone(),
resolution,
};
};
let winner_real = rc::resolve_symlinks(fs, &winner);
let running_real = rc::resolve_symlinks(fs, running_exe);
if winner_real == running_real {
TraceVerdict::RunningBinary { path: winner }
} else {
TraceVerdict::DifferentBinary {
found_version: version_of(&winner),
found: winner,
running: running_exe.to_path_buf(),
resolution,
}
}
}
pub fn parse_version_output(stdout: &str) -> Option<String> {
let token = stdout.lines().next()?.split_whitespace().next_back()?;
token
.starts_with(|c: char| c.is_ascii_digit())
.then(|| token.to_string())
}
pub fn announcement(shell: HookupShell) -> String {
format!(
"tracing shell startup ({})… (runs your rc file, up to twice)",
shell.as_str()
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceError {
TimedOut,
SpawnFailed(String),
RcUnreadable(String),
FallbackUnfaithful(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceRun {
pub records: Vec<TraceRecord>,
pub used_fallback: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct TraceRequest<'a> {
pub shell_path: &'a Path,
pub shell: HookupShell,
pub home: &'a Path,
pub zdotdir: Option<&'a str>,
pub rc_nominal: &'a Path,
pub rc_resolved: &'a Path,
pub hook_line: usize,
pub timeout: Duration,
}
impl TraceRequest<'_> {
fn rc_paths(&self) -> [&Path; 2] {
[self.rc_nominal, self.rc_resolved]
}
}
pub fn run_trace(fs: &dyn Fs, req: &TraceRequest) -> Result<TraceRun, TraceError> {
let mut command = Command::new(req.shell_path);
command
.env("PS4", ps4(req.shell))
.args(trace_args(req.shell));
let records = match spawn_captured(command, req.timeout) {
SpawnOutcome::TimedOut => return Err(TraceError::TimedOut),
SpawnOutcome::SpawnFailed(e) => return Err(TraceError::SpawnFailed(e)),
SpawnOutcome::Finished(capture) => parse_trace(&capture.stderr),
};
if record_at(&records, &req.rc_paths(), req.hook_line).is_some() {
return Ok(TraceRun {
records,
used_fallback: false,
});
}
run_fallback(fs, req).map(|records| TraceRun {
records,
used_fallback: true,
})
}
fn run_fallback(fs: &dyn Fs, req: &TraceRequest) -> Result<Vec<TraceRecord>, TraceError> {
let rc_text = fs
.read_to_string(req.rc_resolved)
.map_err(|e| TraceError::RcUnreadable(format!("{e}")))?;
let scratch = scratch_dir()?;
let temp = scratch.path();
let unfaithful = |e: crate::DodotError| TraceError::FallbackUnfaithful(format!("{e}"));
let mut command = Command::new(req.shell_path);
let copy = insert_report_line(&rc_text, req.rc_nominal, req.hook_line);
let copy_path;
match req.shell {
HookupShell::Zsh => {
copy_path = temp.join(".zshrc");
fs.write_file(©_path, zshrc_copy(©).as_bytes())
.map_err(unfaithful)?;
fs.write_file(
&temp.join(".zshenv"),
zshenv_stage_one(req, temp).as_bytes(),
)
.map_err(unfaithful)?;
command.env("ZDOTDIR", temp).args(["-i", "-c", "true"]);
}
HookupShell::Bash => {
copy_path = temp.join("bashrc");
fs.write_file(©_path, copy.as_bytes())
.map_err(unfaithful)?;
command
.arg("--rcfile")
.arg(©_path)
.args(["-i", "-c", "true"]);
}
}
let copy_path = copy_path.as_path();
if let Some(broken) = insertion_broke_the_parse(req, copy_path) {
return Err(TraceError::FallbackUnfaithful(broken));
}
match spawn_captured(command, req.timeout) {
SpawnOutcome::TimedOut => Err(TraceError::TimedOut),
SpawnOutcome::SpawnFailed(e) => Err(TraceError::SpawnFailed(e)),
SpawnOutcome::Finished(capture) => Ok(parse_trace(&capture.stderr)),
}
}
fn insertion_broke_the_parse(req: &TraceRequest, copy: &Path) -> Option<String> {
let original = parses(req, req.rc_resolved)?;
let copied = parses(req, copy)?;
match (original.ok, copied.ok) {
(true, false) => Some(format!(
"inserting the trace's report line before {}:{} left the rc copy unparseable, so a \
trace of it would describe a shell that never got past the insertion{}",
req.rc_nominal.display(),
req.hook_line,
complaint(&copied.stderr),
)),
_ => None,
}
}
struct ParseCheck {
ok: bool,
stderr: String,
}
fn parses(req: &TraceRequest, file: &Path) -> Option<ParseCheck> {
let mut command = Command::new(req.shell_path);
command.arg("-n").arg(file);
match spawn_captured(command, req.timeout) {
SpawnOutcome::Finished(capture) => Some(ParseCheck {
ok: capture.status == Some(0),
stderr: capture.stderr,
}),
_ => None,
}
}
fn complaint(stderr: &str) -> String {
match stderr.lines().find(|l| !l.trim().is_empty()) {
Some(line) => format!(" ({})", line.trim()),
None => String::new(),
}
}
const EFFECTIVE_ZDOTDIR: &str = "DODOT_TRACE_ZDOTDIR";
fn zshenv_stage_one(req: &TraceRequest, temp: &Path) -> String {
let inherited = match req.zdotdir {
Some(dir) => format!("ZDOTDIR={}", shell_quote(dir)),
None => "unset ZDOTDIR".to_string(),
};
let first_stage = req
.zdotdir
.map(PathBuf::from)
.unwrap_or_else(|| req.home.to_path_buf())
.join(".zshenv");
format!(
"{inherited}\n\
[ -f {first} ] && . {first}\n\
{EFFECTIVE_ZDOTDIR}=\"${{ZDOTDIR:-$HOME}}\"\n\
ZDOTDIR={temp}\n",
first = shell_quote(&first_stage.display().to_string()),
temp = shell_quote(&temp.display().to_string()),
)
}
fn zshrc_copy(rc_copy: &str) -> String {
format!("ZDOTDIR=\"${EFFECTIVE_ZDOTDIR}\"\nunset {EFFECTIVE_ZDOTDIR}\n{rc_copy}")
}
fn insert_report_line(rc_text: &str, rc_nominal: &Path, hook_line: usize) -> String {
let report = format!(
"printf '+{TRACE_MARKER}%s|%s|%s|%s{RECORD_SUFFIX}\\n' {} {} \"$PWD\" \"$PATH\" >&2\n",
shell_quote(&rc_nominal.display().to_string()),
hook_line,
);
let mut out = String::with_capacity(rc_text.len() + report.len());
for (idx, line) in rc_text.lines().enumerate() {
if idx + 1 == hook_line {
out.push_str(&report);
}
out.push_str(line);
out.push('\n');
}
out
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn scratch_dir() -> Result<tempfile::TempDir, TraceError> {
use std::os::unix::fs::PermissionsExt;
tempfile::Builder::new()
.prefix("dodot-trace-")
.permissions(std::fs::Permissions::from_mode(0o700))
.tempdir()
.map_err(|e| TraceError::FallbackUnfaithful(format!("{e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempEnvironment;
const ZSH_TRACE: &str = include_str!("fixtures/zsh-xtrace.txt");
const BASH_TRACE: &str = include_str!("fixtures/bash-xtrace.txt");
const BASH_NO_MARKER: &str = include_str!("fixtures/bash-xtrace-no-marker.txt");
const FIXTURE_PATH: &str = "/opt/homebrew/bin:/usr/bin:/bin";
const FIXTURE_CWD: &str = "/tmp/dodot-trace-exp";
#[test]
fn the_zsh_record_at_the_hook_line_is_addressable() {
let records = parse_trace(ZSH_TRACE);
let rc = Path::new("/tmp/dodot-trace-exp/zdot/.zshrc");
let record = record_at(&records, &[rc], 4).expect("hook-line record");
assert_eq!(record.path, FIXTURE_PATH);
assert_eq!(record.cwd, FIXTURE_CWD);
assert_eq!(record.line, 4);
assert!(record_at(&records, &[rc], 2).is_some());
assert!(record_at(&records, &[rc], 99).is_none());
}
#[test]
fn the_bash_record_at_the_hook_line_is_addressable() {
let records = parse_trace(BASH_TRACE);
let rc = Path::new("/tmp/dodot-trace-exp/home/.bashrc");
let record = record_at(&records, &[rc], 4).expect("hook-line record");
assert_eq!(record.path, FIXTURE_PATH);
assert_eq!(record.cwd, FIXTURE_CWD);
}
#[test]
fn nested_records_with_repeated_plus_signs_still_parse() {
let nested: Vec<_> = BASH_TRACE.lines().filter(|l| l.starts_with("++")).collect();
assert!(!nested.is_empty(), "fixture must contain nested records");
for line in nested {
assert!(parse_record(line).is_some(), "unparsed: {line}");
}
}
#[test]
fn a_marker_less_trace_yields_no_records() {
assert!(parse_trace(BASH_NO_MARKER).is_empty());
}
#[test]
fn record_parsing_rejects_near_misses() {
assert_eq!(parse_record("dodot-trace|/rc|1|/home|/bin|> x"), None);
assert_eq!(parse_record("+dodot-trace|/rc|1|/home|/bin"), None);
assert_eq!(parse_record("+dodot-trace|/rc|1|/home"), None);
assert_eq!(parse_record("+dodot-trace|/rc|x|/home|/bin|> x"), None);
assert_eq!(
parse_record("+dodot-trace|/rc|3|/home||> eval"),
Some(TraceRecord {
file: "/rc".into(),
line: 3,
cwd: "/home".into(),
path: String::new(),
})
);
}
const HOME: &str = "/home/u";
fn hook_in(rc_text: &str) -> Option<Hook> {
find_hook(rc_text, Path::new(HOME))
}
fn sourced(rc_text: &str) -> SourcedScript {
match hook_in(rc_text).expect("a hook").form {
HookForm::FileSource(script) => script,
other => panic!("expected a file-source hook, got {other:?}"),
}
}
#[test]
fn find_hook_names_line_and_form() {
let eval_rc = "# comment\nexport A=1\neval \"$(dodot init-sh)\"\n";
assert_eq!(
hook_in(eval_rc),
Some(Hook {
line: 3,
form: HookForm::Eval
})
);
let file_rc = "[ -f \"$HOME/.local/share/dodot/shell/dodot-init.sh\" ] && \
. \"$HOME/.local/share/dodot/shell/dodot-init.sh\"\n";
assert_eq!(
hook_in(file_rc),
Some(Hook {
line: 1,
form: HookForm::FileSource(SourcedScript::Path(
"/home/u/.local/share/dodot/shell/dodot-init.sh".into()
))
})
);
}
#[test]
fn the_retained_path_is_the_one_the_line_sources() {
for (rc, expected) in [
(
". \"$HOME/old/dodot-init.sh\"\n",
"/home/u/old/dodot-init.sh",
),
(
"source ${HOME}/old/dodot-init.sh\n",
"/home/u/old/dodot-init.sh",
),
(". ~/old/dodot-init.sh\n", "/home/u/old/dodot-init.sh"),
(
". /opt/elsewhere/dodot-init.sh\n",
"/opt/elsewhere/dodot-init.sh",
),
(
"[ -f \"$HOME/a/dodot-init.sh\" ] && . \"$HOME/b/dodot-init.sh\"\n",
"/home/u/b/dodot-init.sh",
),
] {
assert_eq!(
sourced(rc),
SourcedScript::Path(expected.into()),
"rc: {rc}"
);
}
}
#[test]
fn a_line_dodot_cannot_read_resolves_to_nothing_rather_than_a_guess() {
for rc in [
". \"$XDG_DATA_HOME/dodot/shell/dodot-init.sh\"\n",
". \"$(dirname \"$0\")/dodot-init.sh\"\n",
". ./dodot-init.sh\n",
". /opt/*/dodot-init.sh\n",
"if true; then . $HOME/dodot-init.sh; fi\n",
". \"$HOME\"/dodot-init.sh\n",
"echo dodot-init.sh is missing\n",
". \"/opt/dodot-init.sh\n",
] {
assert!(
matches!(sourced(rc), SourcedScript::Unresolved { .. }),
"rc must not resolve: {rc}"
);
}
}
#[test]
fn a_commented_hook_is_no_hook() {
assert_eq!(hook_in("# eval \"$(dodot init-sh)\"\n"), None);
assert_eq!(hook_in("alias ll='ls -l'\n"), None);
}
fn executable(env: &TempEnvironment, dir: &str, name: &str) -> PathBuf {
let dir = env.home.join(dir);
env.fs.mkdir_all(&dir).unwrap();
let path = dir.join(name);
env.fs
.write_file_with_mode(&path, b"#!/bin/sh\n", 0o755)
.unwrap();
path
}
fn nowhere() -> &'static Path {
Path::new("/nonexistent-cwd")
}
#[test]
fn the_first_executable_match_wins() {
let env = TempEnvironment::builder().build();
let first = executable(&env, "a", "dodot");
executable(&env, "b", "dodot");
let path_var = format!(
"{}:{}",
env.home.join("a").display(),
env.home.join("b").display()
);
let r = resolve_on_path(env.fs.as_ref(), &path_var, nowhere(), "dodot");
assert_eq!(r.winner, Some(first));
assert_eq!(r.candidates.len(), 1);
}
#[test]
fn a_dangling_symlink_is_reported_never_silently_skipped() {
let env = TempEnvironment::builder().build();
let linkdir = env.home.join("links");
env.fs.mkdir_all(&linkdir).unwrap();
let gone = env.home.join("gone/dodot");
env.fs.symlink(&gone, &linkdir.join("dodot")).unwrap();
let real = executable(&env, "real", "dodot");
let path_var = format!("{}:{}", linkdir.display(), env.home.join("real").display());
let r = resolve_on_path(env.fs.as_ref(), &path_var, nowhere(), "dodot");
assert_eq!(r.winner, Some(real));
let skips: Vec<_> = r.notable_skips().collect();
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].path, linkdir.join("dodot"));
assert_eq!(
skips[0].skipped,
Some(SkipReason::DanglingSymlink { target: Some(gone) })
);
}
#[test]
fn missing_dirs_and_non_executables_are_classified() {
let env = TempEnvironment::builder().build();
let plain_dir = env.home.join("plain");
env.fs.mkdir_all(&plain_dir).unwrap();
env.fs
.write_file(&plain_dir.join("dodot"), b"not executable")
.unwrap();
let path_var = format!(
"{}:{}:{}",
env.home.join("nowhere").display(),
plain_dir.display(),
env.home.join("empty").display()
);
env.fs.mkdir_all(&env.home.join("empty")).unwrap();
let r = resolve_on_path(env.fs.as_ref(), &path_var, nowhere(), "dodot");
assert_eq!(r.winner, None);
assert_eq!(r.candidates.len(), 3);
assert_eq!(r.candidates[0].skipped, Some(SkipReason::MissingDir));
assert_eq!(r.candidates[1].skipped, Some(SkipReason::NotExecutable));
assert_eq!(r.candidates[2].skipped, Some(SkipReason::NotPresent));
let notable: Vec<_> = r.notable_skips().collect();
assert_eq!(notable.len(), 1);
assert_eq!(notable[0].skipped, Some(SkipReason::NotExecutable));
}
#[test]
fn empty_path_components_search_the_working_directory() {
let env = TempEnvironment::builder().build();
let here = executable(&env, "here", "dodot");
let cwd = env.home.join("here");
let elsewhere = env.home.join("elsewhere").display().to_string();
for path_var in [
"",
":",
&format!(":{elsewhere}"),
&format!("{elsewhere}:"),
&format!("{elsewhere}::{elsewhere}"),
] {
let r = resolve_on_path(env.fs.as_ref(), path_var, &cwd, "dodot");
assert_eq!(r.winner, Some(here.clone()), "PATH={path_var:?}");
}
}
#[test]
fn relative_path_components_resolve_against_the_traced_directory() {
let env = TempEnvironment::builder().build();
let nested = executable(&env, "project/bin", "dodot");
let r = resolve_on_path(env.fs.as_ref(), "bin", &env.home.join("project"), "dodot");
assert_eq!(r.winner, Some(nested));
let r = resolve_on_path(env.fs.as_ref(), "bin", &env.home, "dodot");
assert_eq!(r.winner, None);
}
fn no_version(_: &Path) -> Option<String> {
None
}
fn record(path: &str) -> TraceRecord {
TraceRecord {
file: "/home/u/.zshrc".into(),
line: 1,
cwd: nowhere().display().to_string(),
path: path.to_string(),
}
}
#[test]
fn verdict_unresolvable_carries_the_searched_path() {
let env = TempEnvironment::builder().build();
let running = executable(&env, "run", "dodot");
let v = judge_eval_hook(env.fs.as_ref(), &record("/nowhere"), &running, &no_version);
match v {
TraceVerdict::Unresolvable { path, resolution } => {
assert_eq!(path, "/nowhere");
assert!(resolution.winner.is_none());
}
other => panic!("expected Unresolvable, got {other:?}"),
}
}
#[test]
fn verdict_running_binary_follows_symlinks_to_identity() {
let env = TempEnvironment::builder().build();
let running = executable(&env, "real", "dodot");
let linkdir = env.home.join("links");
env.fs.mkdir_all(&linkdir).unwrap();
env.fs.symlink(&running, &linkdir.join("dodot")).unwrap();
let v = judge_eval_hook(
env.fs.as_ref(),
&record(&linkdir.display().to_string()),
&running,
&no_version,
);
assert_eq!(
v,
TraceVerdict::RunningBinary {
path: linkdir.join("dodot")
}
);
}
#[test]
fn verdict_different_binary_names_the_winner_and_its_version() {
let env = TempEnvironment::builder().build();
let running = executable(&env, "new", "dodot");
let stale = executable(&env, "old", "dodot");
let version_of = |p: &Path| {
assert_eq!(p, &stale);
Some("5.0.0".to_string())
};
let v = judge_eval_hook(
env.fs.as_ref(),
&record(&env.home.join("old").display().to_string()),
&running,
&version_of,
);
match v {
TraceVerdict::DifferentBinary {
found,
found_version,
running: reported_running,
..
} => {
assert_eq!(found, stale);
assert_eq!(found_version.as_deref(), Some("5.0.0"));
assert_eq!(reported_running, running);
}
other => panic!("expected DifferentBinary, got {other:?}"),
}
}
const CURRENT_GEN: u64 = 100;
fn init_script(env: &TempEnvironment, at: &Path, generation: u64, version: Option<&str>) {
let mut text = format!("# dodot init\nexport DODOT_INIT_GEN={generation}\n");
if let Some(v) = version {
text.push_str(&format!("export DODOT_INIT_VERSION={v}\n"));
}
env.fs.mkdir_all(at.parent().unwrap()).unwrap();
env.fs.write_file(at, text.as_bytes()).unwrap();
}
fn judge(env: &TempEnvironment, script: &Path) -> TraceVerdict {
judge_file_source_hook(
env.fs.as_ref(),
script.to_path_buf(),
Some(CURRENT_GEN),
"5.6.0",
)
}
#[test]
fn the_file_source_verdict_judges_the_sourced_path_not_the_expected_one() {
use crate::paths::Pather;
let env = TempEnvironment::builder().build();
let datastore = env.paths.init_script_path();
init_script(&env, &datastore, CURRENT_GEN, Some("5.6.0"));
let gone = env.home.join("old/dodot-init.sh");
assert_eq!(
judge(&env, &gone),
TraceVerdict::ScriptMissing {
script: gone.clone()
},
"the datastore's script existing says nothing about this hook"
);
assert_eq!(
judge(&env, &datastore),
TraceVerdict::ScriptPresent { script: datastore }
);
}
#[test]
fn a_script_written_by_another_dodot_is_skew_not_soundness() {
let env = TempEnvironment::builder().build();
let foreign = env.home.join("old/dodot-init.sh");
init_script(&env, &foreign, CURRENT_GEN, Some("5.0.0"));
assert_eq!(
judge(&env, &foreign),
TraceVerdict::ScriptSkewed {
script: foreign,
version: EvidenceVersion::Known("5.0.0".into())
}
);
let pre = env.home.join("older/dodot-init.sh");
init_script(&env, &pre, CURRENT_GEN, None);
assert_eq!(
judge(&env, &pre),
TraceVerdict::ScriptSkewed {
script: pre,
version: EvidenceVersion::PreVersion
}
);
}
#[test]
fn a_script_older_than_the_one_up_maintains_is_stale_not_sound() {
let env = TempEnvironment::builder().build();
let old = env.home.join("old/dodot-init.sh");
init_script(&env, &old, CURRENT_GEN - 10, Some("5.6.0"));
assert_eq!(
judge(&env, &old),
TraceVerdict::ScriptStale {
script: old,
found: CURRENT_GEN - 10,
expected: CURRENT_GEN
}
);
}
#[test]
fn an_unstamped_file_is_unverified_never_sound() {
let env = TempEnvironment::builder().build();
let impostor = env.home.join("other/dodot-init.sh");
env.fs.mkdir_all(impostor.parent().unwrap()).unwrap();
env.fs
.write_file(&impostor, b"# someone else's script\nexport PATH=/x\n")
.unwrap();
assert!(
matches!(
judge(&env, &impostor),
TraceVerdict::ScriptUnverified { .. }
),
"a file with no dodot stamp is not a dodot init script"
);
}
#[test]
fn a_path_that_is_not_a_readable_file_is_not_a_present_script() {
let env = TempEnvironment::builder().build();
let dir = env.home.join("dodot-init.sh");
env.fs.mkdir_all(&dir).unwrap();
assert!(matches!(
judge(&env, &dir),
TraceVerdict::ScriptMissing { .. }
));
let dangling = env.home.join("link/dodot-init.sh");
env.fs.mkdir_all(&env.home.join("link")).unwrap();
env.fs
.symlink(&env.home.join("gone.sh"), &dangling)
.unwrap();
assert!(matches!(
judge(&env, &dangling),
TraceVerdict::ScriptMissing { .. }
));
}
#[test]
fn version_output_parses_the_clap_shape_and_rejects_noise() {
assert_eq!(parse_version_output("dodot 5.5.1\n"), Some("5.5.1".into()));
assert_eq!(
parse_version_output("dodot 5.6.0-rc1"),
Some("5.6.0-rc1".into())
);
assert_eq!(parse_version_output("zsh: command not found\n"), None);
assert_eq!(parse_version_output(""), None);
}
#[test]
fn the_report_line_is_inserted_not_truncated() {
let rc = "if true; then\n eval \"$(dodot init-sh)\"\nfi\n";
let copy = insert_report_line(rc, Path::new("/home/u/.zshrc"), 2);
let lines: Vec<&str> = copy.lines().collect();
assert_eq!(lines.len(), 4, "insertion, not replacement: {copy}");
assert!(lines[1].starts_with("printf '+dodot-trace|"), "{copy}");
assert!(lines[1].contains("'/home/u/.zshrc' 2"), "{copy}");
assert!(lines[1].contains("\"$PWD\" \"$PATH\""), "{copy}");
assert_eq!(lines[0], "if true; then");
assert_eq!(lines[2], " eval \"$(dodot init-sh)\"");
assert_eq!(lines[3], "fi");
}
#[test]
fn shell_quoting_survives_embedded_single_quotes() {
assert_eq!(shell_quote("plain"), "'plain'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
}
#[test]
fn the_scratch_dir_is_private_unpredictable_and_transient() {
use std::os::unix::fs::PermissionsExt;
let scratch = scratch_dir().expect("scratch dir");
let path = scratch.path().to_path_buf();
let meta = std::fs::symlink_metadata(&path).expect("scratch dir exists");
assert!(meta.file_type().is_dir(), "a directory, never a symlink");
assert_eq!(meta.permissions().mode() & 0o777, 0o700, "{path:?}");
let other = scratch_dir().expect("second scratch dir");
assert_ne!(other.path(), path);
drop(scratch);
assert!(!path.exists(), "removed with its handle");
}
use crate::testing::EnvVarGuard;
fn bash() -> Option<&'static Path> {
let p = Path::new("/bin/bash");
p.exists().then_some(p)
}
const TIMEOUT: Duration = Duration::from_secs(10);
fn request<'a>(
env: &'a TempEnvironment,
shell_path: &'a Path,
shell: HookupShell,
rc: &'a Path,
hook_line: usize,
) -> TraceRequest<'a> {
TraceRequest {
shell_path,
shell,
home: &env.home,
zdotdir: None,
rc_nominal: rc,
rc_resolved: rc,
hook_line,
timeout: TIMEOUT,
}
}
#[test]
fn a_real_bash_reports_path_at_the_hook_line() {
let Some(bash) = bash() else { return };
let env = TempEnvironment::builder().build();
let _home = EnvVarGuard::set("HOME", &env.home.display().to_string());
let rc = env.home.join(".bashrc");
env.fs
.write_file(
&rc,
b"export PATH=/mangled/by/rc\neval \"$(dodot init-sh)\"\n",
)
.unwrap();
let run = run_trace(
env.fs.as_ref(),
&request(&env, bash, HookupShell::Bash, &rc, 2),
)
.expect("trace runs");
let record = record_at(&run.records, &[&rc], 2).expect("hook-line record");
assert_eq!(record.path, "/mangled/by/rc");
}
#[test]
fn a_ps4_override_falls_back_to_the_inserted_report_copy() {
let Some(bash) = bash() else { return };
let env = TempEnvironment::builder().build();
let _home = EnvVarGuard::set("HOME", &env.home.display().to_string());
let rc = env.home.join(".bashrc");
env.fs
.write_file(
&rc,
b"PS4='+ '\nexport PATH=/from/fallback\neval \"$(dodot init-sh)\"\n",
)
.unwrap();
let before = env.fs.read_to_string(&rc).unwrap();
let run = run_trace(
env.fs.as_ref(),
&request(&env, bash, HookupShell::Bash, &rc, 3),
)
.expect("fallback runs");
assert!(run.used_fallback);
let record = record_at(&run.records, &[&rc], 3).expect("inserted report record");
assert_eq!(record.path, "/from/fallback");
assert_eq!(env.fs.read_to_string(&rc).unwrap(), before);
}
#[test]
fn an_insertion_that_breaks_the_copys_parse_refuses_to_answer() {
let Some(bash) = bash() else { return };
let env = TempEnvironment::builder().build();
let _home = EnvVarGuard::set("HOME", &env.home.display().to_string());
let rc = env.home.join(".bashrc");
env.fs
.write_file(
&rc,
b"PS4='+ '\ncase x in\n x) eval \"$(dodot init-sh)\" ;;\nesac\n",
)
.unwrap();
let err = run_trace(
env.fs.as_ref(),
&request(&env, bash, HookupShell::Bash, &rc, 3),
)
.expect_err("an unparseable copy is not a verdict");
let TraceError::FallbackUnfaithful(reason) = err else {
panic!("expected FallbackUnfaithful, got {err:?}");
};
assert!(reason.contains("unparseable"), "{reason}");
assert!(
reason.contains("syntax error") || reason.contains("unexpected"),
"the shell's diagnostic must survive into the message: {reason}"
);
}
#[test]
fn an_rc_that_never_parsed_is_still_traced() {
let Some(bash) = bash() else { return };
let env = TempEnvironment::builder().build();
let _home = EnvVarGuard::set("HOME", &env.home.display().to_string());
let rc = env.home.join(".bashrc");
env.fs
.write_file(
&rc,
b"PS4='+ '\nif then fi oops(\neval \"$(dodot init-sh)\"\n",
)
.unwrap();
match run_trace(
env.fs.as_ref(),
&request(&env, bash, HookupShell::Bash, &rc, 3),
) {
Ok(_) => {}
Err(TraceError::FallbackUnfaithful(reason)) => {
panic!("an already-broken rc must not read as dodot's doing: {reason}")
}
Err(_) => {}
}
}
#[test]
fn a_hook_inside_a_dead_branch_yields_no_record_twice() {
let Some(bash) = bash() else { return };
let env = TempEnvironment::builder().build();
let _home = EnvVarGuard::set("HOME", &env.home.display().to_string());
let rc = env.home.join(".bashrc");
env.fs
.write_file(
&rc,
b"PS4='+ '\nif false; then\n eval \"$(dodot init-sh)\"\nfi\n",
)
.unwrap();
let run = run_trace(
env.fs.as_ref(),
&request(&env, bash, HookupShell::Bash, &rc, 3),
)
.expect("trace runs");
assert!(run.used_fallback);
assert!(record_at(&run.records, &[&rc], 3).is_none());
}
fn zsh() -> Option<&'static Path> {
let p = Path::new("/bin/zsh");
p.exists().then_some(p)
}
#[test]
fn a_real_zsh_reports_path_at_the_hook_line_via_zdotdir() {
let Some(zsh) = zsh() else { return };
let env = TempEnvironment::builder().build();
let zdot = env.home.join("zdot");
env.fs.mkdir_all(&zdot).unwrap();
let rc = zdot.join(".zshrc");
env.fs
.write_file(
&rc,
b"export PATH=/mangled/by/zshrc\neval \"$(dodot init-sh)\"\n",
)
.unwrap();
let zdot_display = zdot.display().to_string();
let _zdot = EnvVarGuard::set("ZDOTDIR", &zdot_display);
let mut req = request(&env, zsh, HookupShell::Zsh, &rc, 2);
req.zdotdir = Some(&zdot_display);
let run = run_trace(env.fs.as_ref(), &req).expect("trace runs");
let record = record_at(&run.records, &[&rc], 2).expect("hook-line record");
assert_eq!(record.path, "/mangled/by/zshrc");
}
#[test]
fn the_zsh_fallback_runs_the_zshenv_that_establishes_zdotdir() {
let Some(zsh) = zsh() else { return };
let env = TempEnvironment::builder().build();
let zdot = env.home.join("config/zsh");
env.fs.mkdir_all(&zdot).unwrap();
env.fs
.write_file(
&env.home.join(".zshenv"),
format!(
"export ZDOTDIR={}\nexport PATH=/from/zshenv\n",
zdot.display()
)
.as_bytes(),
)
.unwrap();
let rc = zdot.join(".zshrc");
env.fs
.write_file(
&rc,
b"PS4='+ '\nexport PATH=$PATH:/from/zshrc\neval \"$(dodot init-sh)\"\n",
)
.unwrap();
let _home = EnvVarGuard::set("HOME", &env.home.display().to_string());
let run = run_trace(
env.fs.as_ref(),
&request(&env, zsh, HookupShell::Zsh, &rc, 3),
)
.expect("trace runs");
assert!(run.used_fallback);
let record = record_at(&run.records, &[&rc], 3).expect("hook-line record");
assert_eq!(
record.path, "/from/zshenv:/from/zshrc",
"the PATH at the hook line must include what ~/.zshenv exported"
);
}
#[test]
fn the_fallback_restores_zdotdir_inside_the_copy() {
let Some(zsh) = zsh() else { return };
let env = TempEnvironment::builder().build();
let zdot = env.home.join("config/zsh");
env.fs.mkdir_all(&zdot).unwrap();
env.fs
.write_file(
&env.home.join(".zshenv"),
format!("export ZDOTDIR={}\n", zdot.display()).as_bytes(),
)
.unwrap();
let rc = zdot.join(".zshrc");
env.fs
.write_file(
&rc,
b"PS4='+ '\nexport PATH=$ZDOTDIR\neval \"$(dodot init-sh)\"\n",
)
.unwrap();
let _home = EnvVarGuard::set("HOME", &env.home.display().to_string());
let run = run_trace(
env.fs.as_ref(),
&request(&env, zsh, HookupShell::Zsh, &rc, 3),
)
.expect("trace runs");
let record = record_at(&run.records, &[&rc], 3).expect("hook-line record");
assert_eq!(record.path, zdot.display().to_string());
}
#[test]
fn an_unrunnable_shell_is_an_error_not_a_panic() {
let env = TempEnvironment::builder().build();
let rc = env.home.join(".bashrc");
env.fs
.write_file(&rc, b"eval \"$(dodot init-sh)\"\n")
.unwrap();
let missing = env.home.join("no-such-shell");
assert!(matches!(
run_trace(
env.fs.as_ref(),
&request(&env, &missing, HookupShell::Bash, &rc, 1)
),
Err(TraceError::SpawnFailed(_))
));
}
}