Skip to main content

gwm/
trust.rs

1//! TOFU (trust-on-first-use) trust ledger for `.gwm.toml` (issue #95).
2//!
3//! The ledger persists `(origin URL, sha256 of .gwm.toml)` tuples to a
4//! per-user file (default `~/.config/gwm/trust.toml`, overridable via
5//! the `GWM_TRUST_LEDGER` env var). On every `gwm create` / `gwm
6//! bootstrap` we hash the current `.gwm.toml`, look the tuple up, and
7//! either skip silently (already trusted) or prompt the user before
8//! handing control to `bootstrap::run` (which is the RCE primitive).
9//!
10//! Threat model: an attacker who controls a remote repository (a fork,
11//! a fresh hostile clone, a co-worker compromise on a shared repo) can
12//! drop arbitrary `[[bootstrap.command]]` lines and have them executed
13//! the next time anyone runs `gwm create` against that repo. Hashing
14//! the raw bytes catches both wholesale rewrites and surgical edits
15//! (whitespace included — `rm -rf /tmp/` and `rm -rf /tmp /` are one
16//! byte apart and behave catastrophically differently). Storing the
17//! origin URL alongside the hash means moving a config from one repo
18//! to another forces a fresh trust decision.
19//!
20//! The ledger format is plain TOML so it can be inspected by hand
21//! (`gwm trust show` prints the active path) and version-controlled
22//! per-machine if a team wants to share trust decisions explicitly.
23
24use crate::config::{Config, CONFIG_FILE};
25use crate::error::{GwmError, Result};
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29use std::fs;
30use std::io::Write;
31use std::path::{Path, PathBuf};
32use tempfile::Builder;
33
34/// Trust gating mode resolved at the CLI / TUI entrypoint and threaded
35/// through every code path that may invoke `bootstrap::run`. Moved
36/// here (from `src/cli.rs`) so the TUI can take the same decision
37/// without duplicating the resolution logic — see [`evaluate`].
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TrustMode {
40  /// Default: load the ledger, prompt the user on miss, abort on
41  /// non-tty.
42  Prompt,
43  /// `--allow-bootstrap` or `GWM_ALLOW_BOOTSTRAP=1` — skip the prompt
44  /// without touching the ledger. CI bypasses must NOT pollute the
45  /// local ledger of whoever ends up running an interactive `gwm`
46  /// from the same machine later.
47  Allow,
48  /// `--deny-bootstrap` — refuse to run bootstrap even if already
49  /// trusted. For forensic / first-look inspection of a hostile repo.
50  Deny,
51}
52
53/// Resolve a [`TrustMode`] from CLI flags + env. Both `--allow-bootstrap`
54/// and `--deny-bootstrap` map onto this — `conflicts_with` at the clap
55/// level guarantees they aren't both set, the explicit ordering here is
56/// defence in depth.
57pub fn resolve_mode(allow_flag: bool, deny_flag: bool) -> TrustMode {
58  if deny_flag {
59    return TrustMode::Deny;
60  }
61  if allow_flag || env_truthy("GWM_ALLOW_BOOTSTRAP") {
62    return TrustMode::Allow;
63  }
64  TrustMode::Prompt
65}
66
67/// Truthy env semantics: any non-empty value other than `0`, `false`,
68/// or `no` (case-insensitive) counts as true. Documented here as the
69/// source of truth — gwm has no shared env-bool helper yet, so this
70/// fn is the canonical reference for `GWM_*` flag-style env vars.
71pub fn env_truthy(key: &str) -> bool {
72  match std::env::var(key) {
73    Ok(v) => {
74      let v = v.trim().to_ascii_lowercase();
75      !v.is_empty() && v != "0" && v != "false" && v != "no"
76    }
77    Err(_) => false,
78  }
79}
80
81/// Resolve the trust-ledger key for the current repo: prefer the
82/// `origin` remote URL (the hostile-clone threat axis), fall back to
83/// the canonicalised workdir path (purely-local repos with no remote
84/// still benefit from the drift-detection half of the feature).
85///
86/// Takes `origin_url: Option<&str>` rather than a `git2::Repository`
87/// so this module stays git2-free — every caller already holds a
88/// Repository and extracts the URL on their side in three lines.
89pub fn resolve_origin_key(origin_url: Option<&str>, workdir: &Path) -> String {
90  if let Some(url) = origin_url {
91    if !url.is_empty() {
92      return url.to_string();
93    }
94  }
95  workdir
96    .canonicalize()
97    .unwrap_or_else(|_| workdir.to_path_buf())
98    .display()
99    .to_string()
100}
101
102/// Outcome of a trust gate evaluation. The caller (CLI or TUI) decides
103/// what to do with each variant — CLI prompts on `Prompt`, TUI refuses
104/// with a helpful message because the alternate-screen mode can't host
105/// a stdin read without a full modal view (deferred follow-up).
106#[derive(Debug)]
107pub enum TrustOutcome {
108  /// Cleared to invoke `bootstrap::run`: trusted entry hit, empty
109  /// surface, no `.gwm.toml`, or `TrustMode::Allow`.
110  Proceed,
111  /// Refuse outright. `message` is the user-facing reason; render it
112  /// verbatim in stderr (CLI) or status bar (TUI). Used for `Deny`
113  /// mode by `evaluate`; the TUI also synthesises a `Refuse` from a
114  /// `Prompt` outcome since it can't prompt today.
115  Refuse { message: String },
116  /// Caller must obtain user approval interactively. On approval the
117  /// caller records into `ledger`, then saves to `ledger_path`. The
118  /// `body` and `sha` are passed back so the prompt can display a
119  /// summary without re-reading the file.
120  Prompt {
121    cfg_path: PathBuf,
122    body: Vec<u8>,
123    sha: String,
124    origin: String,
125    ledger: TrustLedger,
126    ledger_path: PathBuf,
127  },
128}
129
130/// Silent gate evaluation. Reads `.gwm.toml`, hashes it, applies the
131/// short-circuits in this order:
132///
133///   1. No `.gwm.toml` → `Proceed` (nothing to execute).
134///   2. `TrustMode::Deny` → `Refuse` (forensic mode).
135///   3. Empty bootstrap surface → `Proceed` (UX, see comment in
136///      [`trust_or_prompt`]).
137///   4. `TrustMode::Allow` → `Proceed` BEFORE touching the ledger
138///      (malformed ledger must not break the CI bypass).
139///   5. Ledger hit on `(origin, sha)` → `Proceed`.
140///   6. Otherwise → `Prompt` (caller decides whether to actually
141///      prompt or refuse).
142///
143/// This is the single source of truth shared by `cli::trust_or_prompt`
144/// and the TUI gate — keeps the security policy in one place.
145pub fn evaluate(workdir: &Path, origin: &str, mode: TrustMode) -> Result<TrustOutcome> {
146  let cfg_path = workdir.join(CONFIG_FILE);
147  let bytes = match fs::read(&cfg_path) {
148    Ok(b) => b,
149    Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(TrustOutcome::Proceed),
150    Err(e) => return Err(e.into()),
151  };
152  let sha = hash_config(&bytes);
153
154  if mode == TrustMode::Deny {
155    let short_sha: String = sha.chars().take(12).collect();
156    return Ok(TrustOutcome::Refuse {
157      message: format!(
158        "--deny-bootstrap: refusing to run .gwm.toml bootstrap (config hash: {})",
159        short_sha
160      ),
161    });
162  }
163
164  // Empty surface short-circuit (defence-in-depth fallthrough on
165  // parse error — a broken parser must not open a bypass).
166  if let Ok(body_str) = std::str::from_utf8(&bytes) {
167    if let Ok(cfg) = toml::from_str::<Config>(body_str) {
168      let bs = &cfg.bootstrap;
169      if bs.copy.is_empty()
170        && bs.guard.is_empty()
171        && bs.no_symlink.is_empty()
172        && bs.command.is_empty()
173        && !cfg.hooks.has_any()
174      {
175        return Ok(TrustOutcome::Proceed);
176      }
177    }
178  }
179
180  // Allow short-circuit before any ledger I/O so a malformed
181  // trust.toml never breaks `--allow-bootstrap` / `GWM_ALLOW_BOOTSTRAP=1`.
182  if mode == TrustMode::Allow {
183    return Ok(TrustOutcome::Proceed);
184  }
185
186  let ledger_path = default_ledger_path()?;
187  let ledger = TrustLedger::load(&ledger_path)?;
188
189  if ledger.lookup(origin, &sha) {
190    return Ok(TrustOutcome::Proceed);
191  }
192
193  Ok(TrustOutcome::Prompt {
194    cfg_path,
195    body: bytes,
196    sha,
197    origin: origin.to_string(),
198    ledger,
199    ledger_path,
200  })
201}
202
203/// On-disk ledger schema. `serde` defaults make adding new optional
204/// fields backward-compatible: older binaries still parse newer files,
205/// they just ignore the extra keys.
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct TrustLedger {
208  #[serde(default, rename = "entries")]
209  pub entries: Vec<TrustEntry>,
210}
211
212/// One trust grant. `origin` is the remote URL (kept verbatim so SSH
213/// and HTTPS flavours of the same repo are treated as distinct trust
214/// boundaries — they ARE distinct: different auth path, different
215/// failure modes on intercept). `config_sha` is the lowercase hex
216/// sha256 of `.gwm.toml`'s raw bytes.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct TrustEntry {
219  pub origin: String,
220  pub config_sha: String,
221  /// RFC3339 timestamp of when the entry was first recorded. Surfaced
222  /// by `gwm trust list` so users can audit who/when the trust was
223  /// granted before deciding to revoke.
224  pub trusted_at: DateTime<Utc>,
225  /// Best-effort `user@host` identifier captured at record time. Not
226  /// security-relevant on its own (it's local input), purely an audit
227  /// hint for multi-machine users sharing a ledger via dotfiles.
228  pub trusted_by: String,
229}
230
231impl TrustLedger {
232  /// Load the ledger from `path`. A missing file is NOT an error — it
233  /// is treated as an empty ledger, which is the right default for
234  /// the first ever invocation. A malformed file IS an error: silently
235  /// treating it as empty would re-prompt every previously trusted
236  /// repo and train the user to mash `y` (anti-habituation goal of the
237  /// whole feature).
238  pub fn load(path: &Path) -> Result<Self> {
239    match fs::read_to_string(path) {
240      Ok(raw) => {
241        let ledger: TrustLedger = toml::from_str(&raw)?;
242        Ok(ledger)
243      }
244      Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
245      Err(e) => Err(e.into()),
246    }
247  }
248
249  /// Persist the ledger atomically: serialise, write to a uniquely-
250  /// named tmp file in the same directory, then rename(2). The
251  /// rename is the atomic step on POSIX; on Windows it is also a
252  /// single syscall but with slightly different semantics around
253  /// open file handles. `tempfile::NamedTempFile::persist` papers
254  /// over both.
255  ///
256  /// The tmp filename is randomised (`gwm-trust-<random>.tmp`) so
257  /// two `gwm` processes hitting `save` concurrently don't clobber
258  /// each other's intermediate write — pre-fix both raced on the
259  /// fixed name `trust.toml.tmp` and could corrupt the final
260  /// ledger if one process's rename interleaved with the other's
261  /// write.
262  ///
263  /// Parent directories are created on demand (`mkdir -p`) so the
264  /// first ever write on a fresh machine succeeds without the user
265  /// having to create `~/.config/gwm/` manually.
266  pub fn save(&self, path: &Path) -> Result<()> {
267    let parent = match path.parent() {
268      Some(p) if !p.as_os_str().is_empty() => {
269        fs::create_dir_all(p)?;
270        p.to_path_buf()
271      }
272      // No parent (e.g. relative `trust.toml` in CWD) or empty
273      // parent → write the tmp file in `.`.
274      _ => PathBuf::from("."),
275    };
276    let body = toml::to_string_pretty(self)?;
277    let mut tmp = Builder::new()
278      .prefix("gwm-trust-")
279      .suffix(".tmp")
280      .tempfile_in(&parent)?;
281    tmp.write_all(body.as_bytes())?;
282    // `persist` does the atomic rename and consumes the handle so
283    // the tempfile crate's drop-cleanup is short-circuited — no
284    // sidecar `.tmp` survives a successful save.
285    tmp.persist(path).map_err(|e| GwmError::Io(e.error))?;
286    Ok(())
287  }
288
289  /// Returns true iff there is an entry with both `origin` AND
290  /// `config_sha` matching verbatim. Hash drift on a known origin is
291  /// a deliberate `false` — that is the re-prompt-on-config-edit
292  /// behaviour spec'd by the issue.
293  pub fn lookup(&self, origin: &str, config_sha: &str) -> bool {
294    self
295      .entries
296      .iter()
297      .any(|e| e.origin == origin && e.config_sha == config_sha)
298  }
299
300  /// Record (or refresh) a trust grant. Always produces exactly one
301  /// entry per `origin`: any prior entry is dropped first, then a
302  /// fresh entry is pushed with `trusted_at = Utc::now()`. So:
303  ///
304  ///   * Re-recording the same `(origin, config_sha)` keeps a single
305  ///     entry but **refreshes the timestamp** — useful when a user
306  ///     explicitly re-confirms trust without editing the config.
307  ///   * Re-recording the same `origin` with a different
308  ///     `config_sha` supersedes the old hash (drift case), keeping
309  ///     the ledger bounded over a repo's lifetime — without this,
310  ///     every `.gwm.toml` edit would leak a stale tuple that
311  ///     `gwm trust list` would surface forever.
312  ///
313  /// In both cases `entries.len()` after a re-record is the same as
314  /// before; `record_is_idempotent_on_exact_match` and
315  /// `record_supersedes_drifted_hash_for_same_origin` pin both
316  /// halves down.
317  pub fn record(&mut self, origin: &str, config_sha: &str, trusted_by: &str) {
318    self.entries.retain(|e| e.origin != origin);
319    self.entries.push(TrustEntry {
320      origin: origin.to_string(),
321      config_sha: config_sha.to_string(),
322      trusted_at: Utc::now(),
323      trusted_by: trusted_by.to_string(),
324    });
325  }
326
327  /// Remove every entry matching `origin`. Returns the count so
328  /// `gwm trust revoke` can print a precise "removed N entries" line
329  /// instead of guessing.
330  pub fn revoke(&mut self, origin: &str) -> usize {
331    let before = self.entries.len();
332    self.entries.retain(|e| e.origin != origin);
333    before - self.entries.len()
334  }
335}
336
337/// SHA-256 of the raw bytes of `.gwm.toml`, lowercase hex. Whitespace-
338/// sensitive on purpose (see the module-level comment).
339pub fn hash_config(bytes: &[u8]) -> String {
340  let digest = Sha256::digest(bytes);
341  hex_lower(&digest)
342}
343
344/// Resolve the active ledger path. Order of precedence:
345///   1. `GWM_TRUST_LEDGER` env var (the testability hook + power-user
346///      override for users with non-XDG dotfiles).
347///   2. `dirs::config_dir()/gwm/trust.toml` (XDG on Linux, the
348///      `Application Support` equivalent on macOS, `%APPDATA%` on
349///      Windows).
350///
351/// Returns `Err(GwmError::Other(..))` only on the rare case where
352/// `dirs::config_dir()` cannot determine a home — extremely uncommon,
353/// but better surfaced than panicked away.
354pub fn default_ledger_path() -> Result<PathBuf> {
355  if let Ok(p) = std::env::var("GWM_TRUST_LEDGER") {
356    if !p.is_empty() {
357      return Ok(PathBuf::from(p));
358    }
359  }
360  let base = dirs::config_dir().ok_or_else(|| {
361    GwmError::Other("could not resolve user config directory — set GWM_TRUST_LEDGER to override".into())
362  })?;
363  Ok(base.join("gwm").join("trust.toml"))
364}
365
366/// Best-effort `user@host` audit string. Falls back to `"unknown"` on
367/// each half independently so we never panic in a CI shell with
368/// minimal env, and the resulting string is purely informational
369/// (never used for trust decisions).
370pub fn current_actor() -> String {
371  let user = std::env::var("USER")
372    .or_else(|_| std::env::var("USERNAME"))
373    .unwrap_or_else(|_| "unknown".into());
374  let host = current_hostname().unwrap_or_else(|| "unknown".into());
375  format!("{}@{}", user, host)
376}
377
378#[cfg(unix)]
379fn current_hostname() -> Option<String> {
380  // libc is already a pinned Unix dependency (used by bootstrap.rs's
381  // O_NOFOLLOW primitives), so reusing it for `gethostname(3)` keeps
382  // the dep tree flat — no need for an extra `gethostname`/`whoami`
383  // crate just for an audit-log string.
384  let mut buf = [0i8; 256];
385  let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
386  if rc != 0 {
387    return None;
388  }
389  // Find the NUL terminator. POSIX doesn't promise gethostname will
390  // null-terminate if the host name is exactly the buffer length, so
391  // we cap at buf.len() defensively.
392  let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
393  // SAFETY: buf[..len] is a valid byte slice; we re-interpret the
394  // i8 view as u8 (same layout) to feed it to String.
395  let bytes: Vec<u8> = buf[..len].iter().map(|&b| b as u8).collect();
396  String::from_utf8(bytes).ok()
397}
398
399#[cfg(not(unix))]
400fn current_hostname() -> Option<String> {
401  std::env::var("COMPUTERNAME")
402    .ok()
403    .or_else(|| std::env::var("HOSTNAME").ok())
404}
405
406fn hex_lower(bytes: &[u8]) -> String {
407  let mut s = String::with_capacity(bytes.len() * 2);
408  for b in bytes {
409    s.push_str(&format!("{:02x}", b));
410  }
411  s
412}