use crate::args::{ActionReportVerifyArgs, OutputFormat, ScanArgs};
use anyhow::{bail, Context, Result};
use keyhog_core::ScanCompletionStatus;
use sha2::{Digest, Sha256};
use std::fs::{self, File, OpenOptions};
use std::io::Read;
use std::path::Path;
use std::process::ExitCode;
const SCHEMA: &str = "keyhog-action-report-v1";
const MAX_RECEIPT_BYTES: u64 = 1024;
pub(crate) fn validate_scan_paths(args: &ScanArgs) -> Result<()> {
let Some(receipt) = args.action_receipt.as_ref() else {
return Ok(());
};
let report = args
.output
.as_ref()
.context("--action-receipt requires --output")?;
match fs::symlink_metadata(receipt) {
Ok(_) => bail!(
"Action receipt destination must be absent before scan: {}",
receipt.display()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| {
format!(
"inspecting Action receipt destination {}",
receipt.display()
)
})
}
}
if canonical_destination(report)? == canonical_destination(receipt)? {
bail!("Action report and receipt paths must be distinct");
}
Ok(())
}
pub(crate) fn write_scan_receipt(
args: &ScanArgs,
findings: usize,
exit_code: u8,
status: ScanCompletionStatus,
) -> Result<()> {
let Some(receipt_path) = args.action_receipt.as_ref() else {
return Ok(());
};
let report_path = args
.output
.as_ref()
.context("--action-receipt requires --output")?;
let format = action_format(&args.format)
.context("--action-receipt supports only sarif, json, jsonl, or text reports")?;
validate_semantics(findings, exit_code, status_token(status))?;
let _span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
let (report_bytes, report_sha256) = digest_regular(report_path)?;
let body = format!(
"schema={SCHEMA}\nformat={format}\nfindings={findings}\nreport-bytes={report_bytes}\nreport-sha256={report_sha256}\nscan-status={}\nexit-code={exit_code}\n",
status_token(status)
);
write_receipt_noclobber(receipt_path, body.as_bytes()).with_context(|| {
format!(
"atomically creating Action receipt {}",
receipt_path.display()
)
})
}
pub(crate) fn verify(args: ActionReportVerifyArgs) -> Result<ExitCode> {
let mut receipt = open_regular(&args.receipt)?;
reject_same_open_file(&receipt, &args.receipt, &args.report)?;
let receipt_len = receipt.metadata()?.len();
if receipt_len == 0 || receipt_len > MAX_RECEIPT_BYTES {
bail!("Action receipt length {receipt_len} is outside 1..={MAX_RECEIPT_BYTES} bytes");
}
let mut body = String::with_capacity(receipt_len as usize);
receipt
.read_to_string(&mut body)
.context("Action receipt must be strict UTF-8 text")?;
if !body.is_ascii() {
bail!("Action receipt must contain ASCII only");
}
let lines = body.lines().collect::<Vec<_>>();
if lines.len() != 7 || !body.ends_with('\n') {
bail!("Action receipt must contain exactly seven newline-terminated fields");
}
let schema = field(lines[0], "schema")?;
let format = field(lines[1], "format")?;
let findings = parse_decimal(field(lines[2], "findings")?, "findings")?;
let expected_bytes = parse_decimal(field(lines[3], "report-bytes")?, "report-bytes")?;
let expected_sha = field(lines[4], "report-sha256")?;
let status = field(lines[5], "scan-status")?;
let receipt_exit = parse_decimal(field(lines[6], "exit-code")?, "exit-code")?;
if schema != SCHEMA {
bail!("unsupported Action receipt schema {schema:?}");
}
if format != args.format.to_string() {
bail!(
"Action receipt format {format:?} contradicts requested {}",
args.format
);
}
if receipt_exit != usize::from(args.exit_code) {
bail!(
"Action receipt exit {receipt_exit} contradicts scanner exit {}",
args.exit_code
);
}
if expected_sha.len() != 64
|| !expected_sha
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
bail!("Action receipt report-sha256 must be exactly 64 lowercase hexadecimal characters");
}
validate_semantics(findings, args.exit_code, status)?;
let (actual_bytes, actual_sha) = digest_regular(&args.report)?;
if actual_bytes != expected_bytes as u64 {
bail!("Action report length changed: receipt={expected_bytes}, actual={actual_bytes}");
}
if actual_sha != expected_sha {
bail!("Action report SHA-256 changed after scan");
}
println!("{findings}");
Ok(ExitCode::SUCCESS)
}
fn field<'a>(line: &'a str, name: &str) -> Result<&'a str> {
line.strip_prefix(name)
.and_then(|value| value.strip_prefix('='))
.filter(|value| !value.is_empty())
.with_context(|| format!("Action receipt field {name} is missing, empty, or out of order"))
}
fn parse_decimal(value: &str, name: &str) -> Result<usize> {
if !value.bytes().all(|byte| byte.is_ascii_digit()) {
bail!("Action receipt field {name} must be canonical unsigned decimal");
}
if value.len() > 1 && value.starts_with('0') {
bail!("Action receipt field {name} must not contain leading zeroes");
}
value
.parse()
.with_context(|| format!("Action receipt field {name} overflows"))
}
fn validate_semantics(findings: usize, exit_code: u8, status: &str) -> Result<()> {
match (exit_code, status, findings) {
(0, "success" | "complete_after_recovery" | "partial", 0) => Ok(()),
(1 | 10, "success" | "complete_after_recovery" | "partial", 1..) => Ok(()),
(13, "partial", _) => Ok(()),
_ => bail!("Action receipt count/status/exit semantics contradict: findings={findings}, status={status}, exit={exit_code}"),
}
}
fn status_token(status: ScanCompletionStatus) -> &'static str {
match status {
ScanCompletionStatus::Success => "success",
ScanCompletionStatus::CompleteAfterRecovery => "complete_after_recovery",
ScanCompletionStatus::Partial => "partial",
ScanCompletionStatus::Cancelled => "cancelled",
ScanCompletionStatus::Failed => "failed",
}
}
fn action_format(format: &OutputFormat) -> Option<&'static str> {
match format {
OutputFormat::Sarif => Some("sarif"),
OutputFormat::Json => Some("json"),
OutputFormat::Jsonl => Some("jsonl"),
OutputFormat::Text => Some("text"),
_ => None,
}
}
fn write_receipt_noclobber(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new(".")); let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
use std::io::Write as _;
temporary.write_all(bytes)?;
temporary.as_file().sync_all()?;
temporary
.persist_noclobber(path)
.map(drop)
.map_err(|error| error.error)
}
fn open_regular(path: &Path) -> Result<File> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options
.open(path)
.with_context(|| format!("opening Action file {}", path.display()))?;
if !file.metadata()?.file_type().is_file() {
bail!(
"Action file must be a regular, non-symlink file: {}",
path.display()
);
}
Ok(file)
}
fn canonical_destination(path: &Path) -> Result<std::path::PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let parent = absolute.parent().context("Action path has no parent")?;
let name = absolute
.file_name()
.context("Action path has no file name")?;
Ok(parent
.canonicalize()
.with_context(|| format!("canonicalizing Action path parent {}", parent.display()))?
.join(name))
}
fn reject_same_open_file(receipt: &File, _receipt_path: &Path, report_path: &Path) -> Result<()> {
let report = open_regular(report_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let receipt_meta = receipt.metadata()?;
let report_meta = report.metadata()?;
if receipt_meta.dev() == report_meta.dev() && receipt_meta.ino() == report_meta.ino() {
bail!("Action report and receipt resolve to the same file");
}
}
#[cfg(not(unix))]
if canonical_destination(_receipt_path)? == canonical_destination(report_path)? {
bail!("Action report and receipt resolve to the same file");
}
Ok(())
}
fn digest_regular(path: &Path) -> Result<(u64, String)> {
let mut file = open_regular(path)?;
let mut hasher = Sha256::new();
let mut bytes = 0u64;
let mut buffer = [0u8; 128 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
bytes = bytes
.checked_add(read as u64)
.context("Action report length overflow")?;
hasher.update(&buffer[..read]);
}
Ok((bytes, keyhog_core::hex_encode(hasher.finalize())))
}