use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use crate::config::SigningConfig;
pub const DEFAULT_NAME: &str = "default";
pub fn data_dir() -> Result<PathBuf> {
Ok(SigningConfig::default_global_path()?
.parent()
.context("global config path has no parent directory")?
.to_path_buf())
}
pub fn hooks_dir() -> Result<PathBuf> {
Ok(data_dir()?.join("hooks"))
}
pub fn allowed_signers_path() -> Result<PathBuf> {
Ok(data_dir()?.join("allowed_signers"))
}
pub fn include_dir() -> Result<PathBuf> {
Ok(data_dir()?.join("gitconfig"))
}
pub fn include_path(name: &str) -> Result<PathBuf> {
Ok(include_dir()?.join(format!("{name}.gitconfig")))
}
pub fn write_include(name: &str, did_key_id: &str) -> Result<PathBuf> {
let path = include_path(name)?;
write_include_at(&path, did_key_id)?;
Ok(path)
}
pub fn write_include_at(path: &Path, did_key_id: &str) -> Result<()> {
let dir = path.parent().context("include path has no parent")?;
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
std::fs::write(
path,
"# Written by did-git-sign. A repository signs with the identity below only\n\
# when it includes this file: `did-git-sign enable` adds the include,\n\
# `did-git-sign disable` removes it. Regenerated by `did-git-sign init`.\n",
)
.with_context(|| format!("failed to write {}", path.display()))?;
let config_json = SigningConfig::default_global_path()?;
let config_json = utf8(&config_json)?;
let hooks = hooks_dir()?;
let signers = allowed_signers_path()?;
for (key, value) in [
("gpg.format", "ssh"),
("gpg.ssh.program", "did-git-sign"),
("user.signingKey", config_json),
("gpg.ssh.defaultKeyFile", config_json),
("gpg.ssh.allowedSignersFile", utf8(&signers)?),
("commit.gpgsign", "true"),
("core.hooksPath", utf8(&hooks)?),
("did-git-sign.key", did_key_id),
] {
git(&["config", "-f", utf8(path)?, key, value])
.with_context(|| format!("failed to write {key} to {}", path.display()))?;
}
Ok(())
}
pub fn include_identity(path: &Path) -> Option<String> {
git_out(&[
"config",
"-f",
utf8(path).ok()?,
"--get",
"did-git-sign.key",
])
}
pub fn is_our_include(value: &str) -> bool {
let Ok(dir) = include_dir() else { return false };
let value = expand_home(value.trim());
Path::new(&value).starts_with(&dir)
}
pub fn repo_include() -> Option<PathBuf> {
let all = git_out(&["config", "--local", "--get-all", "include.path"])?;
all.lines()
.rev()
.find(|v| is_our_include(v))
.map(|v| PathBuf::from(expand_home(v.trim())))
}
pub fn enable_repo(include: &Path) -> Result<()> {
if !inside_work_tree() {
bail!("not inside a git repository; run this in one, or use --dir");
}
refuse_foreign_hooks_path("--local")?;
refuse_foreign_hooks_path("--global")?;
remove_repo_includes()?;
git(&["config", "--local", "--add", "include.path", utf8(include)?])?;
Ok(())
}
pub fn disable_repo() -> Result<bool> {
if !inside_work_tree() {
bail!("not inside a git repository; run this in one, or use --dir");
}
remove_repo_includes()
}
pub fn enable_dir(dir: &str, include: &Path) -> Result<String> {
refuse_foreign_hooks_path("--global")?;
let key = include_if_key(dir);
git(&["config", "--global", &key, utf8(include)?])?;
Ok(key)
}
pub fn disable_dir(dir: &str) -> Result<bool> {
let key = include_if_key(dir);
match git_out(&["config", "--global", "--get", &key]) {
Some(v) if is_our_include(&v) => {
git(&["config", "--global", "--unset", &key])?;
Ok(true)
}
Some(v) => bail!("{key} is set to '{v}', which is not did-git-sign's; leaving it"),
None => Ok(false),
}
}
pub fn include_if_key(dir: &str) -> String {
let dir = dir.trim();
let dir = if dir.ends_with('/') {
dir.to_string()
} else {
format!("{dir}/")
};
format!("includeIf.gitdir:{dir}.path")
}
pub fn remove_references(include: &Path) -> Result<usize> {
let target = include.to_path_buf();
let mut removed = 0;
if let Some(list) = git_out(&[
"config",
"--global",
"--get-regexp",
r"^include(if\..*)?\.path$",
]) {
for line in list.lines() {
let Some((key, value)) = line.split_once(' ') else {
continue;
};
if Path::new(&expand_home(value.trim())) == target {
let pattern = format!("^{}$", regex_escape(value.trim()));
if git(&["config", "--global", "--unset-all", key, &pattern]).is_ok() {
removed += 1;
}
}
}
}
if inside_work_tree()
&& let Some(list) = git_out(&["config", "--local", "--get-all", "include.path"])
{
for value in list.lines() {
if Path::new(&expand_home(value.trim())) == target {
let pattern = format!("^{}$", regex_escape(value.trim()));
if git(&["config", "--local", "--unset-all", "include.path", &pattern]).is_ok() {
removed += 1;
}
}
}
}
Ok(removed)
}
fn remove_repo_includes() -> Result<bool> {
let Some(all) = git_out(&["config", "--local", "--get-all", "include.path"]) else {
return Ok(false);
};
let mut removed = false;
for value in all.lines().filter(|v| is_our_include(v)) {
let pattern = format!("^{}$", regex_escape(value.trim()));
git(&["config", "--local", "--unset-all", "include.path", &pattern])?;
removed = true;
}
Ok(removed)
}
fn refuse_foreign_hooks_path(scope: &str) -> Result<()> {
let ours = hooks_dir()?;
if let Some(existing) = git_out(&["config", scope, "--get", "core.hooksPath"])
&& Path::new(&expand_home(existing.trim())) != ours
{
bail!(
"{scope} core.hooksPath is set to '{existing}', which is not did-git-sign's. \
Enabling would replace it and its hooks would stop running. Unset it, or add the \
Signed-by-DID trailer logic to that directory's commit-msg hook by hand."
);
}
Ok(())
}
fn inside_work_tree() -> bool {
git_out(&["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
}
fn expand_home(value: &str) -> String {
match (value.strip_prefix("~/"), dirs::home_dir()) {
(Some(rest), Some(home)) => home.join(rest).to_string_lossy().into_owned(),
_ => value.to_string(),
}
}
pub fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if "\\^$.|?*+()[]{}".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
fn utf8(p: &Path) -> Result<&str> {
p.to_str()
.with_context(|| format!("path is not valid UTF-8: {}", p.display()))
}
fn git(args: &[&str]) -> Result<()> {
let out = Command::new("git")
.args(args)
.output()
.context("failed to run git")?;
if !out.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn git_out(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
(out.status.success() && !v.is_empty()).then_some(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn include_if_key_always_ends_in_a_slash() {
assert_eq!(include_if_key("~/devel"), "includeIf.gitdir:~/devel/.path");
assert_eq!(include_if_key("~/devel/"), "includeIf.gitdir:~/devel/.path");
}
#[test]
fn regex_escape_makes_a_path_literal() {
assert_eq!(
regex_escape("/a b/did-git-sign/x.gitconfig"),
r"/a b/did-git-sign/x\.gitconfig"
);
assert_eq!(regex_escape("a+b(c)"), r"a\+b\(c\)");
}
#[test]
fn the_include_file_keeps_every_value_whole() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bob.gitconfig");
let did = "did:webvh:QmBob:example.com:bob#key-0";
write_include_at(&path, did).unwrap();
let get = |key: &str| {
git_out(&["config", "-f", path.to_str().unwrap(), "--get", key]).unwrap_or_default()
};
assert_eq!(get("did-git-sign.key"), did);
assert_eq!(include_identity(&path).as_deref(), Some(did));
assert_eq!(get("gpg.ssh.program"), "did-git-sign");
assert_eq!(get("gpg.format"), "ssh");
assert_eq!(get("commit.gpgsign"), "true");
assert_eq!(
get("core.hooksPath"),
hooks_dir().unwrap().to_str().unwrap()
);
write_include_at(&path, "did:webvh:QmCarol:example.com:carol#key-1").unwrap();
assert_eq!(
get("did-git-sign.key"),
"did:webvh:QmCarol:example.com:carol#key-1"
);
}
#[test]
fn only_paths_under_the_include_dir_are_ours() {
let ours = include_path("bob").unwrap();
assert!(is_our_include(ours.to_str().unwrap()));
assert!(!is_our_include("/home/someone/.gitconfig-work"));
assert!(!is_our_include("~/.config/git/other.inc"));
}
}