//! Managed gitconfig and hook shims, written into the data dir at boot. use std::fs::{self, File}; use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::Path; use crate::error::{Error, Result}; /// Quotes a value for a git config file. Control characters are rejected. fn quote_config_value(v: &str) -> Result { if v.chars().any(char::is_control) { return Err(Error::InvalidInput(format!( "config value contains control characters: {v:?}" ))); } Ok(format!( "\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\"") )) } /// Writes `name` in `dir` atomically: temp file, fsync, rename, then fsync the directory. fn write_atomic(dir: &Path, name: &str, contents: &[u8], mode: u32) -> Result<()> { let tmp = dir.join(format!(".{name}.tmp")); { let mut f = fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&tmp)?; f.write_all(contents)?; f.set_permissions(fs::Permissions::from_mode(mode))?; f.sync_all()?; } fs::rename(&tmp, dir.join(name))?; File::open(dir)?.sync_all()?; Ok(()) } /// Writes `data_dir/gitconfig`: core.hooksPath, fsck, fsync, uploadpack and receive settings. pub fn write_managed_gitconfig(data_dir: &Path) -> Result<()> { if !data_dir.is_absolute() { return Err(Error::InvalidInput(format!( "data dir must be absolute: {}", data_dir.display() ))); } let hooks = data_dir.join("hooks"); let hooks = hooks .to_str() .ok_or_else(|| Error::InvalidInput("data dir is not valid UTF-8".into()))?; let hooks = quote_config_value(hooks)?; // `committed` hardens loose objects and packs; `reference` hardens ref updates. let body = format!( "[core]\n\ \thooksPath = {hooks}\n\ \tfsync = committed,reference\n\ \tfsyncMethod = fsync\n\ \tcommitGraph = true\n\ [receive]\n\ \tfsckObjects = true\n\ \tadvertisePushOptions = true\n\ \tdenyNonFastForwards = false\n\ [transfer]\n\ \tfsckObjects = true\n\ \thideRefs = refs/forge-internal/\n\ [fetch]\n\ \tfsckObjects = true\n\ \twriteCommitGraph = true\n\ [uploadpack]\n\ \tallowFilter = true\n\ \tallowRefInWant = true\n\ \thideRefs = refs/forge-internal/\n\ [protocol \"file\"]\n\ \tallow = never\n\ [protocol \"ext\"]\n\ \tallow = never\n" ); fs::create_dir_all(data_dir)?; write_atomic(data_dir, "gitconfig", body.as_bytes(), 0o644) } /// Writes `data_dir/hooks/{pre-receive,post-receive}` as `exec hook `, mode 0755. /// `forge_bin` must be absolute and contain no quotes or control characters. pub fn write_hook_shims(data_dir: &Path, forge_bin: &Path) -> Result<()> { let bin = forge_bin .to_str() .ok_or_else(|| Error::InvalidInput("forge binary path is not valid UTF-8".into()))?; if !forge_bin.is_absolute() { return Err(Error::InvalidInput(format!( "forge binary path must be absolute: {bin}" ))); } if bin.chars().any(|c| c == '\'' || c.is_control()) { return Err(Error::InvalidInput(format!( "forge binary path contains a quote or control character: {bin:?}" ))); } let hooks = data_dir.join("hooks"); fs::create_dir_all(&hooks)?; for name in ["pre-receive", "post-receive"] { let body = format!("#!/bin/sh\nexec '{bin}' hook {name}\n"); write_atomic(&hooks, name, body.as_bytes(), 0o755)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn managed_gitconfig_enables_commit_graph() { let tmp = tempfile::tempdir().expect("tempdir"); write_managed_gitconfig(tmp.path()).expect("write gitconfig"); let body = fs::read_to_string(tmp.path().join("gitconfig")).expect("read gitconfig"); assert!(body.contains("\tcommitGraph = true\n")); assert!(body.contains("\twriteCommitGraph = true\n")); assert!( !body.contains("gc.auto"), "gc.auto must stay at its default" ); } }