use crate::diagnostic_log::debug;
use generations::{Retention, install_generation, retention_of, trim_generations};
use crate::iso_time::format_iso_milliseconds;
use crate::json_record::{quote, string_array};
use crate::service_protocol::RELEASE;
use crate::rule_linked_worktree::GuardedCommand;
use crate::worktree_identity::WorktreeIdentity;
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
pub const AUDIT_FILE: &str = "cli-git-audit.jsonl";
const ROTATE_AT_BYTES: u64 = 1024 * 1024;
pub const AUDIT_HISTORY_BYTES: u64 = 4 * ROTATE_AT_BYTES;
const TAG: &str = "discardAudit";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuditOutcome {
Rejected,
Allowed,
Exempt,
Hatch,
SavedConflict,
}
impl AuditOutcome {
fn word(self) -> &'static str {
match self {
AuditOutcome::Rejected => return "rejected",
AuditOutcome::Allowed => return "allowed",
AuditOutcome::Exempt => return "exempt",
AuditOutcome::Hatch => return "hatch",
AuditOutcome::SavedConflict => return "saved-conflict",
}
}
}
pub fn audit_place(identity: &WorktreeIdentity) -> Option<(PathBuf, &'static str)> {
match identity {
WorktreeIdentity::OutsideWorktree => return None,
WorktreeIdentity::BareRepository { common_dir, .. } => {
return Some((common_dir.clone(), "bare"));
}
WorktreeIdentity::MainWorktree { common_dir, .. } => {
return Some((common_dir.clone(), "main"));
}
WorktreeIdentity::LinkedWorktree { common_dir, .. } => {
return Some((common_dir.clone(), "linked"));
}
}
}
pub fn audit_git_dir(identity: &WorktreeIdentity) -> Option<&Path> {
return match identity {
WorktreeIdentity::OutsideWorktree => None,
WorktreeIdentity::BareRepository { git_dir, .. } | WorktreeIdentity::MainWorktree { git_dir, .. } | WorktreeIdentity::LinkedWorktree { git_dir, .. } => Some(git_dir.as_path()),
};
}
pub struct AuditRecord<'record> {
pub time_millis: u128,
pub process: u32,
pub parent: u32,
pub form: GuardedCommand,
pub outcome: AuditOutcome,
pub place: &'record (PathBuf, &'static str),
pub git_dir: &'record Path,
pub directory: &'record Path,
pub arguments: &'record [OsString],
pub dirty: Option<bool>,
pub copies: &'record [String],
}
pub fn audit_line(record: &AuditRecord) -> String {
let rendered: Vec<String> = record
.arguments
.iter()
.map(|argument| { return argument.to_string_lossy().into_owned(); })
.collect();
let dirty: &str = match record.dirty {
Some(true) => "true",
Some(false) => "false",
None => "null",
};
let copies: String = if record.copies.is_empty() {
String::new()
} else {
format!(",\"copies\":{}", string_array(record.copies))
};
let level: &str = if record.outcome == AuditOutcome::Rejected { "warn" } else { "info" };
let time: String = format_iso_milliseconds(i64::try_from(record.time_millis).unwrap_or(i64::MAX));
return format!(
"{{\"schemaVersion\":1,\"time\":{},\"level\":\"{level}\",\"timeMillis\":{},\"release\":{},\"pid\":{},\"parentPid\":{},\"worktree\":{},\"gitDir\":{},\"form\":{},\
\"outcome\":{},\"dirty\":{dirty},\"cwd\":{},\"arguments\":{}{copies}}}\n",
quote(&time),
quote(&record.time_millis.to_string()),
quote(RELEASE),
record.process,
record.parent,
quote(record.place.1),
quote(&record.git_dir.to_string_lossy()),
quote(&format!("{:?}", record.form)),
quote(record.outcome.word()),
quote(&record.directory.to_string_lossy()),
string_array(&rendered),
);
}
fn parent_process() -> u32 {
#[cfg(unix)]
{
return std::os::unix::process::parent_id();
}
#[cfg(not(unix))]
{
return 0;
}
}
pub fn record_audit(
identity: &WorktreeIdentity,
form: GuardedCommand,
outcome: AuditOutcome,
arguments: &[OsString],
dirty: Option<bool>,
) {
record_audit_with_copies(identity, form, outcome, arguments, dirty, &[]);
}
pub fn record_audit_with_copies(
identity: &WorktreeIdentity,
form: GuardedCommand,
outcome: AuditOutcome,
arguments: &[OsString],
dirty: Option<bool>,
copies: &[String],
) {
let Some(place) = audit_place(identity) else {
return;
};
let target: PathBuf = place.0.join(AUDIT_FILE);
let git_dir: &Path = audit_git_dir(identity).unwrap_or(place.0.as_path());
let time_millis: u128 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| { return elapsed.as_millis(); })
.unwrap_or_default();
let directory: PathBuf = std::env::current_dir().unwrap_or_default();
let line: String = audit_line(&AuditRecord {
time_millis,
process: std::process::id(),
parent: parent_process(),
form,
outcome,
place: &place,
git_dir,
directory: directory.as_path(),
arguments,
dirty,
copies,
});
if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
debug(TAG, &format!("the audit record could not be written to {}: {error}", target.display()));
}
}
pub fn append_line(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file: std::fs::File = open_for_append(target)?;
if file.metadata()?.len() >= ROTATE_AT_BYTES {
if let Err(error) = move_aside_if_still_named(target, &file) {
debug(TAG, &format!("the audit file {} could not be moved aside: {error}", target.display()));
} else {
if retention_of(target) == Retention::Generations {
if let Err(error) = trim_generations(target, AUDIT_HISTORY_BYTES) {
debug(TAG, &format!("the generations of {} could not be trimmed: {error}", target.display()));
}
}
if let Ok(fresh) = open_for_append(target) {
file = fresh;
}
}
}
let length: u64 = file.metadata()?.len();
let mut line: Vec<u8> = Vec::with_capacity(bytes.len() + 1);
if length > 0 && !ends_with_newline(&file, length) {
line.push(b'\n');
}
line.extend_from_slice(bytes);
return write_once(&mut file, line.as_slice());
}
fn write_once(file: &mut std::fs::File, line: &[u8]) -> std::io::Result<()> {
loop {
match file.write(line) {
Ok(count) if count == line.len() => return Ok(()),
Ok(count) => return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, format!("the system took {count} of {} bytes", line.len()))),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
pub fn check_appendable(target: &Path) -> std::io::Result<()> {
open_for_append(target)?;
return Ok(());
}
fn open_for_append(target: &Path) -> std::io::Result<std::fs::File> {
return match std::fs::OpenOptions::new().read(true).append(true).create(true).open(target) {
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => std::fs::OpenOptions::new().append(true).create(true).open(target),
other => other,
};
}
fn ends_with_newline(file: &std::fs::File, length: u64) -> bool {
use std::os::unix::fs::FileExt;
let mut last: [u8; 1] = *b"\n";
return file.read_exact_at(&mut last, length - 1).is_err() || last[0] == b'\n';
}
fn move_aside_if_still_named(target: &Path, opened: &std::fs::File) -> std::io::Result<()> {
return move_aside_between(target, opened, &mut |_step| {});
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RotationStep {
BeforeMove,
AfterMove,
}
fn private_name(target: &Path) -> PathBuf {
static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let call: u64 = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut name: std::ffi::OsString = target.as_os_str().to_os_string();
name.push(format!(".moving-{}-{call}", std::process::id()));
return PathBuf::from(name);
}
fn give_back(taken: &Path, target: &Path) -> std::io::Result<()> {
match std::fs::hard_link(taken, target) {
Ok(()) => {
if let Err(error) = std::fs::remove_file(taken) {
debug(TAG, &format!("{} is back at its name but its second name {} stays: {error}", target.display(), taken.display()));
}
return Ok(());
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
debug(TAG, &format!("a newer audit file is at {}, so the file taken from it stays at {}", target.display(), taken.display()));
return Ok(());
}
Err(error) => return Err(error),
}
}
fn move_aside_between(target: &Path, opened: &std::fs::File, at_step: &mut dyn FnMut(RotationStep)) -> std::io::Result<()> {
use std::os::unix::fs::MetadataExt;
let Ok(named) = std::fs::metadata(target) else {
return Ok(());
};
let held: std::fs::Metadata = opened.metadata()?;
if (named.dev(), named.ino()) != (held.dev(), held.ino()) {
return Ok(());
}
at_step(RotationStep::BeforeMove);
let taken_at: PathBuf = private_name(target);
match std::fs::rename(target, &taken_at) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
}
at_step(RotationStep::AfterMove);
let taken: std::fs::Metadata = match std::fs::metadata(&taken_at) {
Ok(taken) => taken,
Err(error) => {
give_back(taken_at.as_path(), target)?;
return Err(error);
}
};
if (taken.dev(), taken.ino()) != (held.dev(), held.ino()) {
return give_back(taken_at.as_path(), target);
}
let placed: std::io::Result<()> = match retention_of(target) {
Retention::OneGeneration => {
let mut aside: std::ffi::OsString = target.as_os_str().to_os_string();
aside.push(".1");
std::fs::rename(&taken_at, aside)
}
Retention::Generations => install_generation(taken_at.as_path(), target),
};
if let Err(error) = placed {
give_back(taken_at.as_path(), target)?;
return Err(error);
}
return Ok(());
}
#[path = "discard_audit_generations.rs"]
mod generations;
#[cfg(test)]
#[path = "discard_audit_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "discard_audit_generation_tests.rs"]
mod generation_tests;