use crate::models::{
EvidenceBundleEnvironment, EvidenceBundleManifest, EvidenceBundleManifestArtifact,
EvidenceBundleSummary, FieldPathTrace, FieldPathTraceReport, FieldValueShape, QuarantineConfig,
QuarantineOutputSummary, QuarantineReason, RedactionAction, RedactionReceipt,
};
use hl7v2::{Atom, Field, Message, ValidationReport};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::Path;
const BUNDLE_VERSION: &str = "1";
const QUARANTINE_VERSION: &str = "1";
const TOOL_NAME: &str = "hl7v2-server";
const REPLAY_COMMAND: &str = "hl7v2 replay . --format json";
const BUNDLE_ARTIFACT_SPECS: [(&str, &str); 9] = [
("message.redacted.hl7", "redacted_message"),
("validation-report.json", "validation_report"),
("field-paths.json", "field_path_trace"),
("profile.yaml", "profile"),
("redaction-receipt.json", "redaction_receipt"),
("environment.json", "environment"),
("replay.sh", "replay_shell_script"),
("replay.ps1", "replay_powershell_script"),
("README.md", "bundle_readme"),
];
#[derive(Debug)]
pub enum EvidenceBundleError {
InvalidRequest(String),
Conflict(String),
Io(String),
}
impl fmt::Display for EvidenceBundleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRequest(message) | Self::Conflict(message) | Self::Io(message) => {
f.write_str(message)
}
}
}
}
impl std::error::Error for EvidenceBundleError {}
pub struct EvidenceBundleWriteRequest<'a> {
pub root: &'a Path,
pub bundle_id: &'a str,
pub raw_input: &'a [u8],
pub profile_yaml: &'a str,
pub policy_text: &'a str,
pub redacted_message: &'a Message,
pub redacted_hl7: &'a str,
pub redaction_receipt: &'a RedactionReceipt,
pub validation_report: &'a ValidationReport,
}
pub struct QuarantineOutputWriteRequest<'a> {
pub root: &'a Path,
pub output_id: &'a str,
pub config: &'a QuarantineConfig,
pub raw_input: &'a [u8],
pub profile_yaml: &'a str,
pub policy_text: &'a str,
pub redacted_message: &'a Message,
pub redacted_hl7: &'a str,
pub redaction_receipt: &'a RedactionReceipt,
pub validation_report: &'a ValidationReport,
}
pub fn write_evidence_bundle(
request: EvidenceBundleWriteRequest<'_>,
) -> Result<EvidenceBundleSummary, EvidenceBundleError> {
let EvidenceBundleWriteRequest {
root,
bundle_id,
raw_input,
profile_yaml,
policy_text,
redacted_message,
redacted_hl7,
redaction_receipt,
validation_report,
} = request;
validate_bundle_id(bundle_id)?;
let bundle_dir = root.join(bundle_id);
fs::create_dir(&bundle_dir).map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
EvidenceBundleError::Conflict(format!(
"bundle output directory already exists for bundle_id '{bundle_id}'"
))
} else {
EvidenceBundleError::Io(format!(
"could not create bundle output directory for bundle_id '{bundle_id}': {error}"
))
}
})?;
let message_type = message_type(redacted_message);
let field_trace = build_field_path_trace(redacted_message, redaction_receipt);
let environment = EvidenceBundleEnvironment {
bundle_version: BUNDLE_VERSION.to_string(),
tool_name: TOOL_NAME.to_string(),
tool_version: env!("CARGO_PKG_VERSION").to_string(),
message_type: message_type.clone(),
input_sha256: compute_sha256_bytes(raw_input),
profile_sha256: compute_sha256(profile_yaml),
redaction_policy_sha256: compute_sha256(policy_text),
validation_valid: validation_report.valid,
validation_issue_count: validation_report.issue_count,
replay_command: REPLAY_COMMAND.to_string(),
};
fs::write(bundle_dir.join("message.redacted.hl7"), redacted_hl7).map_err(|error| {
EvidenceBundleError::Io(format!(
"could not write redacted message artifact: {error}"
))
})?;
fs::write(bundle_dir.join("profile.yaml"), profile_yaml).map_err(|error| {
EvidenceBundleError::Io(format!("could not write profile artifact: {error}"))
})?;
write_json_file(
&bundle_dir.join("validation-report.json"),
validation_report,
)?;
write_json_file(
&bundle_dir.join("redaction-receipt.json"),
redaction_receipt,
)?;
write_json_file(&bundle_dir.join("field-paths.json"), &field_trace)?;
write_json_file(&bundle_dir.join("environment.json"), &environment)?;
fs::write(bundle_dir.join("replay.sh"), replay_shell_script()).map_err(|error| {
EvidenceBundleError::Io(format!("could not write replay shell script: {error}"))
})?;
fs::write(bundle_dir.join("replay.ps1"), replay_powershell_script()).map_err(|error| {
EvidenceBundleError::Io(format!("could not write replay PowerShell script: {error}"))
})?;
fs::write(bundle_dir.join("README.md"), bundle_readme()).map_err(|error| {
EvidenceBundleError::Io(format!("could not write bundle README: {error}"))
})?;
let manifest = EvidenceBundleManifest {
bundle_version: BUNDLE_VERSION.to_string(),
tool_name: TOOL_NAME.to_string(),
tool_version: env!("CARGO_PKG_VERSION").to_string(),
artifacts: BUNDLE_ARTIFACT_SPECS
.iter()
.map(|(path, role)| bundle_manifest_artifact(&bundle_dir, path, role))
.collect::<Result<_, _>>()?,
};
write_json_file(&bundle_dir.join("manifest.json"), &manifest)?;
let mut artifacts = BUNDLE_ARTIFACT_SPECS
.iter()
.map(|(path, _role)| (*path).to_string())
.collect::<Vec<_>>();
artifacts.push("manifest.json".to_string());
Ok(EvidenceBundleSummary {
bundle_version: BUNDLE_VERSION.to_string(),
output_dir: bundle_id.to_string(),
message_type,
validation_valid: validation_report.valid,
validation_issue_count: validation_report.issue_count,
redaction_phi_removed: redaction_receipt.phi_removed,
artifacts,
})
}
pub fn write_quarantine_output(
request: QuarantineOutputWriteRequest<'_>,
) -> Result<QuarantineOutputSummary, EvidenceBundleError> {
let QuarantineOutputWriteRequest {
root,
output_id,
config,
raw_input,
profile_yaml,
policy_text,
redacted_message,
redacted_hl7,
redaction_receipt,
validation_report,
} = request;
if !config.write_bundle && !config.write_report && !config.write_redacted {
return Err(EvidenceBundleError::InvalidRequest(
"quarantine output must enable at least one artifact writer".to_string(),
));
}
if config.write_bundle {
let bundle = write_evidence_bundle(EvidenceBundleWriteRequest {
root,
bundle_id: output_id,
raw_input,
profile_yaml,
policy_text,
redacted_message,
redacted_hl7,
redaction_receipt,
validation_report,
})?;
return Ok(QuarantineOutputSummary {
quarantine_version: QUARANTINE_VERSION.to_string(),
output_dir: bundle.output_dir,
reason: QuarantineReason::ValidationError,
validation_issue_count: bundle.validation_issue_count,
artifacts: bundle.artifacts,
});
}
validate_bundle_id(output_id)?;
let output_dir = root.join(output_id);
fs::create_dir(&output_dir).map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
EvidenceBundleError::Conflict(format!(
"quarantine output directory already exists for output_id '{output_id}'"
))
} else {
EvidenceBundleError::Io(format!(
"could not create quarantine output directory for output_id '{output_id}': {error}"
))
}
})?;
let mut artifacts = Vec::new();
if config.write_report {
write_json_file(
&output_dir.join("validation-report.json"),
validation_report,
)?;
artifacts.push("validation-report.json".to_string());
}
if config.write_redacted {
fs::write(output_dir.join("message.redacted.hl7"), redacted_hl7).map_err(|error| {
EvidenceBundleError::Io(format!(
"could not write quarantine redacted message artifact: {error}"
))
})?;
write_json_file(
&output_dir.join("redaction-receipt.json"),
redaction_receipt,
)?;
artifacts.push("message.redacted.hl7".to_string());
artifacts.push("redaction-receipt.json".to_string());
}
Ok(QuarantineOutputSummary {
quarantine_version: QUARANTINE_VERSION.to_string(),
output_dir: output_id.to_string(),
reason: QuarantineReason::ValidationError,
validation_issue_count: validation_report.issue_count,
artifacts,
})
}
fn validate_bundle_id(bundle_id: &str) -> Result<(), EvidenceBundleError> {
let trimmed = bundle_id.trim();
if trimmed.is_empty() {
return Err(EvidenceBundleError::InvalidRequest(
"bundle_id must not be empty".to_string(),
));
}
if trimmed != bundle_id {
return Err(EvidenceBundleError::InvalidRequest(
"bundle_id must not include leading or trailing whitespace".to_string(),
));
}
if trimmed == "." || trimmed == ".." {
return Err(EvidenceBundleError::InvalidRequest(
"bundle_id must be a single safe path segment".to_string(),
));
}
if trimmed.len() > 128 {
return Err(EvidenceBundleError::InvalidRequest(
"bundle_id must be 128 characters or fewer".to_string(),
));
}
if !trimmed
.bytes()
.all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
{
return Err(EvidenceBundleError::InvalidRequest(
"bundle_id may contain only ASCII letters, numbers, '.', '-', and '_'".to_string(),
));
}
Ok(())
}
fn write_json_file<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), EvidenceBundleError> {
let bytes = serde_json::to_vec_pretty(value).map_err(|error| {
EvidenceBundleError::Io(format!("could not serialize bundle artifact JSON: {error}"))
})?;
fs::write(path, bytes).map_err(|error| {
EvidenceBundleError::Io(format!("could not write bundle JSON artifact: {error}"))
})
}
fn bundle_manifest_artifact(
bundle_dir: &Path,
path: &str,
role: &str,
) -> Result<EvidenceBundleManifestArtifact, EvidenceBundleError> {
let bytes = fs::read(bundle_dir.join(path)).map_err(|error| {
EvidenceBundleError::Io(format!(
"could not read bundle artifact for manifest: {error}"
))
})?;
Ok(EvidenceBundleManifestArtifact {
path: path.to_string(),
role: role.to_string(),
sha256: compute_sha256_bytes(&bytes),
})
}
fn build_field_path_trace(message: &Message, receipt: &RedactionReceipt) -> FieldPathTraceReport {
let redaction_actions: BTreeMap<&str, RedactionAction> = receipt
.actions
.iter()
.map(|action| (action.path.as_str(), action.action))
.collect();
let mut fields = Vec::new();
for (segment_position, segment) in message.segments.iter().enumerate() {
let segment_index = segment_position.saturating_add(1);
for (modeled_index, field) in segment.fields.iter().enumerate() {
let field_index = hl7_field_index(segment.id_str(), modeled_index);
let canonical_path = format!("{}.{}", segment.id_str(), field_index);
let field_text = field_to_text(field, &message.delims);
fields.push(FieldPathTrace {
path: format!("{}[{}].{}", segment.id_str(), segment_index, field_index),
canonical_path: canonical_path.clone(),
segment_index,
field_index,
present: !field_text.is_empty(),
value_shape: field_value_shape(&field_text),
redaction_action: redaction_actions.get(canonical_path.as_str()).copied(),
});
}
}
FieldPathTraceReport {
message_type: message_type(message),
field_count: fields.len(),
fields,
}
}
fn message_type(message: &Message) -> String {
joined_components(message, "MSH.9").unwrap_or_else(|| "unknown".to_string())
}
fn joined_components(message: &Message, path: &str) -> Option<String> {
let mut components = Vec::new();
for index in 1.. {
let component_path = format!("{}.{}", path, index);
match hl7v2::get(message, &component_path) {
Some(value) if !value.is_empty() => components.push(value.to_string()),
Some(_) => {}
None => break,
}
}
if components.is_empty() {
hl7v2::get(message, path).map(str::to_string)
} else {
Some(components.join("^"))
}
}
fn hl7_field_index(segment_id: &str, modeled_index: usize) -> usize {
if segment_id == "MSH" {
modeled_index.saturating_add(2)
} else {
modeled_index.saturating_add(1)
}
}
fn field_value_shape(field_text: &str) -> FieldValueShape {
if field_text.is_empty() {
FieldValueShape::Empty
} else if field_text.starts_with("hash:sha256:") {
FieldValueShape::HashedSha256
} else {
FieldValueShape::Present
}
}
fn field_to_text(field: &Field, delims: &hl7v2::Delims) -> String {
field
.reps
.iter()
.map(|rep| {
rep.comps
.iter()
.map(|comp| {
comp.subs
.iter()
.map(|atom| match atom {
Atom::Text(text) => text.as_str(),
Atom::Null => "\"\"",
})
.collect::<Vec<_>>()
.join(&delims.sub.to_string())
})
.collect::<Vec<_>>()
.join(&delims.comp.to_string())
})
.collect::<Vec<_>>()
.join(&delims.rep.to_string())
}
fn compute_sha256(value: &str) -> String {
compute_sha256_bytes(value.as_bytes())
}
fn compute_sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn replay_shell_script() -> &'static str {
"#!/usr/bin/env sh\nset -eu\ncd \"$(dirname \"$0\")\"\nhl7v2 replay . --format json > replay-report.json\n"
}
fn replay_powershell_script() -> &'static str {
"$ErrorActionPreference = 'Stop'\nSet-Location $PSScriptRoot\nhl7v2 replay . --format json > .\\replay-report.json\n"
}
fn bundle_readme() -> &'static str {
"# HL7v2 Evidence Bundle\n\n\
This directory contains a redacted, replayable evidence packet generated by `hl7v2-server`.\n\n\
## Contents\n\n\
- `message.redacted.hl7`: redacted HL7 message used for replay.\n\
- `validation-report.json`: validation report generated from the redacted message.\n\
- `field-paths.json`: field-path trace and redaction action metadata.\n\
- `profile.yaml`: profile used for replay validation.\n\
- `redaction-receipt.json`: receipt describing retained, hashed, dropped, or missing fields.\n\
- `environment.json`: tool version, bundle metadata, and input/profile/policy hashes.\n\
- `manifest.json`: bundle-relative artifact paths, roles, and SHA-256 hashes.\n\
- `replay.sh` and `replay.ps1`: shell helpers that replay the bundle.\n\n\
## Replay\n\n\
Run `hl7v2 replay . --format json` from this directory, or run the generated script for your shell.\n\n\
## Safety Notes\n\n\
This bundle is intended for support and debugging after safe-analysis redaction. It should not contain raw message PHI in reports, receipts, traces, manifests, or replay output. The profile is user-authored and included as supplied; review it before sharing. Redaction receipts prove configured actions were applied, but they are not a general PHI detector.\n"
}