use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use openmoat_audit::{ChainHead, ChainReport, EventId};
use serde::{Deserialize, Serialize};
use crate::home::{Home, private_append_options, write_private};
pub mod git;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Format {
#[serde(rename = "moat-audit-anchor-v1")]
V1,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Record {
pub format: Format,
pub event: EventId,
pub hash: String,
pub event_ms: i64,
pub anchored_ms: i64,
}
impl Record {
pub fn new(head: &ChainHead, now_ms: i64) -> Self {
Self {
format: Format::V1,
event: head.id,
hash: head.hash.clone(),
event_ms: head.ts_ms,
anchored_ms: now_ms,
}
}
pub fn line(&self) -> Result<String> {
Ok(serde_json::to_string(self)?)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Place {
Git(PathBuf),
File(PathBuf),
}
impl Place {
pub fn of(path: &Path) -> Self {
if path.is_dir() {
Self::Git(path.to_path_buf())
} else {
Self::File(path.to_path_buf())
}
}
pub fn describe(&self) -> String {
match self {
Self::Git(repo) => format!("{} ({})", repo.display(), git::REF),
Self::File(file) => file.display().to_string(),
}
}
pub fn append(&self, record: &Record, git_program: &Path) -> Result<Option<String>> {
match self {
Self::Git(repo) => git::Repo::new(git_program, repo)?.append(record).map(Some),
Self::File(file) => {
let mut out = private_append_options()
.open(file)
.with_context(|| format!("opening {}", file.display()))?;
writeln!(out, "{}", record.line()?)
.and_then(|()| out.sync_all())
.with_context(|| format!("writing {}", file.display()))?;
Ok(None)
}
}
}
pub fn records(&self, git_program: &Path) -> Result<Vec<Record>> {
let text = match self {
Self::Git(repo) => git::Repo::new(git_program, repo)?.read()?,
Self::File(file) => {
fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?
}
};
parse(&text).with_context(|| format!("reading the anchors in {}", self.describe()))
}
}
pub fn parse(text: &str) -> Result<Vec<Record>> {
let mut out = Vec::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let record: Record = serde_json::from_str(line)
.with_context(|| format!("line {} is not a moat-audit-anchor-v1 record", index + 1))?;
out.push(record);
}
if out.is_empty() {
bail!("no anchors recorded");
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Mismatch {
Missing,
Rewritten,
ChainBroken,
}
impl Mismatch {
pub fn describe(self) -> &'static str {
match self {
Self::Missing => "its event is no longer in the log: events were deleted",
Self::Rewritten => "its event has another hash now: events up to it were rewritten",
Self::ChainBroken => "the hash chain breaks at or before its event",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Failed {
pub number: usize,
pub record: Record,
pub why: Mismatch,
}
pub fn check(
records: &[Record],
chain: &ChainReport,
hash_of: impl Fn(EventId) -> Result<Option<String>>,
) -> Result<Option<Failed>> {
for (index, record) in records.iter().enumerate() {
let broken_before = chain
.broken
.as_ref()
.is_some_and(|b| b.id.0 <= record.event.0);
let why = match hash_of(record.event)? {
_ if broken_before => Some(Mismatch::ChainBroken),
None => Some(Mismatch::Missing),
Some(hash) if hash != record.hash => Some(Mismatch::Rewritten),
Some(_) => None,
};
if let Some(why) = why {
return Ok(Some(Failed {
number: index + 1,
record: record.clone(),
why,
}));
}
}
Ok(None)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Last {
pub anchored_ms: i64,
pub event: EventId,
pub to: String,
}
impl Last {
pub fn load(home: &Home) -> Option<Self> {
let text = fs::read_to_string(home.anchor_path()).ok()?;
serde_json::from_str(&text).ok()
}
pub fn save(&self, home: &Home) -> Result<()> {
write_private(
&home.anchor_path(),
(serde_json::to_string_pretty(self)? + "\n").as_bytes(),
)
}
}
#[cfg(test)]
mod tests;