use crate::daemon::client::{self, Client};
use crate::daemon::protocol::{self, GuardWireManifestEntry, Request, Response};
use anyhow::{bail, Context, Result};
use keyhog_core::guard_state::GuardReceipt;
use keyhog_sources::{StagedEntryKind, StagedManifest, StagedManifestEntry};
use std::path::Path;
const MAX_BLOB_BYTES: usize = 8 * 1024 * 1024;
pub(crate) struct GuardCommitResult {
pub findings_count: u64,
pub coverage_gaps: u64,
#[allow(dead_code)]
pub terminal_state: String,
pub fingerprint_changed: bool,
pub cache_hits: u64,
pub blobs_scanned: u64,
pub bytes_scanned: u64,
}
pub(crate) async fn run_guard_commit(
socket_path: &Path,
repo_path: &Path,
detector_rules_digest: &str,
) -> Result<GuardCommitResult> {
let repo_path = match std::fs::canonicalize(repo_path) {
Ok(p) => p,
Err(e) => bail!(
"guard commit: cannot resolve repo path {}: {e}",
repo_path.display()
),
};
let mut conn =
client::connect_with_detector_rules_digest(socket_path, detector_rules_digest.to_string())
.await
.context("guard commit: connect to daemon")?;
if let Some(status) = conn.warm_backend_status() {
if !status.ready {
bail!(
"guard commit: daemon warm backend is not ready; \
repair with `keyhog daemon stop && keyhog daemon start`"
);
}
}
match run_guard_commit_on_connection(&mut conn, &repo_path).await {
Ok(result) if result.fingerprint_changed => {
let retry = run_guard_commit_on_connection(&mut conn, &repo_path).await?;
Ok(retry)
}
other => other,
}
}
async fn run_guard_commit_on_connection(
conn: &mut Client,
repo_path: &Path,
) -> Result<GuardCommitResult> {
let manifest = StagedManifest::acquire(repo_path)
.map_err(|e| anyhow::anyhow!("guard commit: staged manifest: {e}"))?;
let wire_entries: Vec<GuardWireManifestEntry> = manifest
.entries
.iter()
.map(|e| manifest_entry_to_wire(e))
.collect();
let hash_algorithm = match manifest.hash_algorithm {
keyhog_core::guard_state::GitHashAlgorithm::Sha1 => "sha1",
keyhog_core::guard_state::GitHashAlgorithm::Sha256 => "sha256",
};
let begin_request = Request::GuardCommitBegin {
repo_path: repo_path.display().to_string(),
index_fingerprint: manifest.index_fingerprint.clone(),
hash_algorithm: hash_algorithm.to_string(),
entries: wire_entries,
};
let plan_response = conn.round_trip(&begin_request).await?;
let (transaction_id, required_blob_oids) = match plan_response {
Response::GuardCommitPlan {
transaction_id,
clean_hits: _,
required_blob_oids,
..
} => (transaction_id, required_blob_oids),
Response::Error { message } => {
bail!("guard commit: daemon rejected begin: {message}");
}
other => bail!(
"guard commit: expected GuardCommitPlan, got {}",
protocol::response_kind(&other)
),
};
let mut total_bytes_streamed: u64 = 0;
for oid in &required_blob_oids {
let payload = keyhog_sources::read_staged_blob(repo_path, oid)
.map_err(|e| anyhow::anyhow!("guard commit: read blob {oid}: {e}"))?;
if payload.len() > MAX_BLOB_BYTES {
bail!(
"guard commit: blob {} exceeds {} byte limit",
oid,
MAX_BLOB_BYTES
);
}
total_bytes_streamed += payload.len() as u64;
let chunk = keyhog_core::Chunk {
data: String::from_utf8_lossy(&payload).into_owned().into(),
metadata: keyhog_core::ChunkMetadata {
source_type: "git-staged".into(),
path: Some(oid.clone().into()),
..Default::default()
},
};
let blob_request = Request::GuardCommitBlob {
transaction_id,
blob_oid: oid.clone(),
object_size: payload.len() as u64,
payload: vec![chunk],
};
let blob_response = conn.round_trip(&blob_request).await?;
match blob_response {
Response::GuardCommitBlobAck { .. } => {}
Response::Error { message } => {
bail!("guard commit: daemon rejected blob {oid}: {message}");
}
other => bail!(
"guard commit: expected GuardCommitBlobAck for {oid}, got {}",
protocol::response_kind(&other)
),
}
}
let client_objects_streamed = required_blob_oids.len() as u64;
let finish_request = Request::GuardCommitFinish {
transaction_id,
client_objects_streamed,
client_bytes_streamed: total_bytes_streamed,
};
let finish_response = conn.round_trip(&finish_request).await?;
let (receipt, terminal_state_label) = match finish_response {
Response::GuardCommitReceipt {
objects_requested,
objects_hit,
objects_scanned,
objects_skipped,
bytes_requested,
bytes_hit,
bytes_scanned,
findings_count,
coverage_gaps,
terminal_state,
..
} => {
let label = terminal_state.clone();
let r = GuardReceipt {
objects_requested,
objects_hit,
objects_scanned,
objects_skipped,
bytes_requested,
bytes_hit,
bytes_scanned,
findings_count,
coverage_gaps,
terminal_state: keyhog_core::guard_state::GuardRootState::Indexing,
policy_identity: keyhog_core::guard_state::GuardPolicyIdentity {
build_identity: String::new(),
detector_digest: String::new(),
suppression_digest: String::new(),
keyhogignore_digest: String::new(),
config_digest: String::new(),
decode_policy_version: 0,
source_policy_digest: String::new(),
guard_schema_version: 0,
report_semantics_version: 0,
},
terminal_sequence: 0,
};
(r, label)
}
Response::Error { message } => {
bail!("guard commit: daemon rejected finish: {message}");
}
other => bail!(
"guard commit: expected GuardCommitReceipt, got {}",
protocol::response_kind(&other)
),
};
if let Err(e) = receipt.validate_conservation() {
bail!("guard commit: conservation check failed: {e}");
}
let fingerprint_changed = !manifest.fingerprint_matches(repo_path);
Ok(GuardCommitResult {
findings_count: receipt.findings_count,
coverage_gaps: receipt.coverage_gaps,
terminal_state: terminal_state_label,
fingerprint_changed,
cache_hits: receipt.objects_hit,
blobs_scanned: receipt.objects_scanned,
bytes_scanned: receipt.bytes_scanned,
})
}
fn manifest_entry_to_wire(entry: &StagedManifestEntry) -> GuardWireManifestEntry {
let kind = match entry.kind {
StagedEntryKind::File => "file",
StagedEntryKind::Deletion => "deletion",
StagedEntryKind::Symlink => "symlink",
StagedEntryKind::Submodule => "submodule",
};
GuardWireManifestEntry {
path: String::from_utf8_lossy(&entry.path_bytes).into_owned(),
kind: kind.to_string(),
object_oid: entry.object_oid.clone(),
object_size: entry.object_size,
raw_mode: entry.raw_mode,
}
}