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}