use crate::config::{Config, CONFIG_FILE};
use crate::error::{GwmError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::Builder;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustMode {
Prompt,
Allow,
Deny,
}
pub fn resolve_mode(allow_flag: bool, deny_flag: bool) -> TrustMode {
if deny_flag {
return TrustMode::Deny;
}
if allow_flag || env_truthy("GWM_ALLOW_BOOTSTRAP") {
return TrustMode::Allow;
}
TrustMode::Prompt
}
pub fn env_truthy(key: &str) -> bool {
match std::env::var(key) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
!v.is_empty() && v != "0" && v != "false" && v != "no"
}
Err(_) => false,
}
}
pub fn origin_key_for_repo(repo: &git2::Repository, workdir: &Path) -> String {
let url = repo
.find_remote("origin")
.ok()
.and_then(|r| r.url().ok().map(String::from));
resolve_origin_key(url.as_deref(), workdir)
}
pub fn resolve_origin_key(origin_url: Option<&str>, workdir: &Path) -> String {
if let Some(url) = origin_url {
if !url.is_empty() {
return url.to_string();
}
}
workdir
.canonicalize()
.unwrap_or_else(|_| workdir.to_path_buf())
.display()
.to_string()
}
#[derive(Debug)]
pub enum TrustOutcome {
Proceed,
Refuse { message: String },
Prompt {
cfg_path: PathBuf,
body: Vec<u8>,
sha: String,
origin: String,
ledger: TrustLedger,
ledger_path: PathBuf,
},
}
pub fn config_is_trusted(workdir: &Path, origin: &str, mode: TrustMode) -> Result<bool> {
let bytes = match fs::read(workdir.join(CONFIG_FILE)) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e.into()),
};
match mode {
TrustMode::Deny => return Ok(false),
TrustMode::Allow => return Ok(true),
TrustMode::Prompt => {}
}
let ledger_path = default_ledger_path()?;
Ok(TrustLedger::load(&ledger_path)?.lookup(origin, &hash_config(&bytes)))
}
pub fn record_config(workdir: &Path, origin: &str, actor: &str) -> Result<Option<String>> {
let path = workdir.join(CONFIG_FILE);
let bytes = match fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let sha = hash_config(&bytes);
let ledger_path = default_ledger_path()?;
let mut ledger = TrustLedger::load(&ledger_path)?;
ledger.record(origin, &sha, actor);
ledger.save(&ledger_path)?;
Ok(Some(sha))
}
pub fn evaluate(workdir: &Path, origin: &str, mode: TrustMode) -> Result<TrustOutcome> {
let cfg_path = workdir.join(CONFIG_FILE);
let bytes = match fs::read(&cfg_path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(TrustOutcome::Proceed),
Err(e) => return Err(e.into()),
};
let sha = hash_config(&bytes);
if mode == TrustMode::Deny {
let short_sha: String = sha.chars().take(12).collect();
return Ok(TrustOutcome::Refuse {
message: format!(
"--deny-bootstrap: refusing to run .gwm.toml bootstrap (config hash: {})",
short_sha
),
});
}
if let Ok(body_str) = std::str::from_utf8(&bytes) {
if let Ok(cfg) = toml::from_str::<Config>(body_str) {
let bs = &cfg.bootstrap;
if bs.copy.is_empty()
&& bs.guard.is_empty()
&& bs.no_symlink.is_empty()
&& bs.command.is_empty()
&& !cfg.hooks.has_any()
{
return Ok(TrustOutcome::Proceed);
}
}
}
if mode == TrustMode::Allow {
return Ok(TrustOutcome::Proceed);
}
let ledger_path = default_ledger_path()?;
let ledger = TrustLedger::load(&ledger_path)?;
if ledger.lookup(origin, &sha) {
return Ok(TrustOutcome::Proceed);
}
Ok(TrustOutcome::Prompt {
cfg_path,
body: bytes,
sha,
origin: origin.to_string(),
ledger,
ledger_path,
})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustLedger {
#[serde(default, rename = "entries")]
pub entries: Vec<TrustEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustEntry {
pub origin: String,
pub config_sha: String,
pub trusted_at: DateTime<Utc>,
pub trusted_by: String,
}
impl TrustLedger {
pub fn load(path: &Path) -> Result<Self> {
match fs::read_to_string(path) {
Ok(raw) => {
let ledger: TrustLedger = toml::from_str(&raw)?;
Ok(ledger)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e.into()),
}
}
pub fn save(&self, path: &Path) -> Result<()> {
let parent = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => {
fs::create_dir_all(p)?;
p.to_path_buf()
}
_ => PathBuf::from("."),
};
let body = toml::to_string_pretty(self)?;
let mut tmp = Builder::new()
.prefix("gwm-trust-")
.suffix(".tmp")
.tempfile_in(&parent)?;
tmp.write_all(body.as_bytes())?;
tmp.persist(path).map_err(|e| GwmError::Io(e.error))?;
Ok(())
}
pub fn lookup(&self, origin: &str, config_sha: &str) -> bool {
self
.entries
.iter()
.any(|e| e.origin == origin && e.config_sha == config_sha)
}
pub fn record(&mut self, origin: &str, config_sha: &str, trusted_by: &str) {
self.entries.retain(|e| e.origin != origin);
self.entries.push(TrustEntry {
origin: origin.to_string(),
config_sha: config_sha.to_string(),
trusted_at: Utc::now(),
trusted_by: trusted_by.to_string(),
});
}
pub fn revoke(&mut self, origin: &str) -> usize {
let before = self.entries.len();
self.entries.retain(|e| e.origin != origin);
before - self.entries.len()
}
}
pub fn hash_config(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
hex_lower(&digest)
}
pub fn default_ledger_path() -> Result<PathBuf> {
if let Ok(p) = std::env::var("GWM_TRUST_LEDGER") {
if !p.is_empty() {
return Ok(PathBuf::from(p));
}
}
let base = dirs::config_dir().ok_or_else(|| {
GwmError::Other("could not resolve user config directory — set GWM_TRUST_LEDGER to override".into())
})?;
Ok(base.join("gwm").join("trust.toml"))
}
pub fn current_actor() -> String {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".into());
let host = current_hostname().unwrap_or_else(|| "unknown".into());
format!("{}@{}", user, host)
}
#[cfg(unix)]
fn current_hostname() -> Option<String> {
let mut buf = [0i8; 256];
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if rc != 0 {
return None;
}
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let bytes: Vec<u8> = buf[..len].iter().map(|&b| b as u8).collect();
String::from_utf8(bytes).ok()
}
#[cfg(not(unix))]
fn current_hostname() -> Option<String> {
std::env::var("COMPUTERNAME")
.ok()
.or_else(|| std::env::var("HOSTNAME").ok())
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}