use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use crypto::{Signer, load_signer, verify_payload_signature};
use heddle_core::redaction_signature_status;
use objects::{
object::{
ContentHash, LeafPolicy, Redaction, RedactionsBlob, StateId, StateSignature,
TreePathResolveError, resolve_tree_path,
},
worktree::should_ignore,
};
use oplog::OpLogRecorder;
use repo::{Repository, RepositoryCapability};
use serde::Serialize;
use super::advice::RecoveryAdvice;
use crate::{
cli::{
Cli, RedactApplyArgs, RedactCommands, RedactListArgs, RedactShowArgs, should_output_json,
},
config::UserConfig,
};
pub fn cmd_redact(cli: &Cli, command: RedactCommands) -> Result<()> {
if let RedactCommands::Purge(command) = command {
return super::purge::cmd_purge(cli, command);
}
let _user = UserConfig::load_default().unwrap_or_default();
let repo = cli.open_repo()?;
match command {
RedactCommands::Apply(args) => cmd_redact_apply(cli, &repo, args),
RedactCommands::List(args) => cmd_redact_list(cli, &repo, args),
RedactCommands::Show(args) => cmd_redact_show(cli, &repo, args),
RedactCommands::Purge(_) => unreachable!("handled before opening repo"),
}
}
#[derive(Serialize)]
struct RedactApplyOutput {
output_kind: &'static str,
redaction_id: String,
blob: String,
state: String,
path: String,
reason: String,
redactor: String,
redacted_at: String,
all_states: bool,
states_redacted: u32,
signed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
signature_algorithm: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ignore_hint: Option<IgnoreHint>,
}
fn cmd_redact_apply(cli: &Cli, repo: &Repository, args: RedactApplyArgs) -> Result<()> {
let state = resolve_state(repo, &args.state)?;
let principal = repo
.get_principal()
.with_context(|| "resolve current principal")?;
let blob = blob_at_path(repo, &state, &args.path)?;
let now = Utc::now();
let signer: Option<Box<dyn Signer>> = match &args.sign_with {
Some(path) => Some(
load_signer(path, args.sign_algo.as_deref())
.with_context(|| format!("load signer from '{}'", path.display()))?,
),
None => None,
};
let signature_algorithm = signer.as_ref().map(|s| s.algorithm().to_string());
let mut primary = Redaction {
redacted_blob: blob,
state,
path: args.path.clone(),
reason: args.reason.clone(),
redactor: principal.clone(),
redacted_at: now,
signature: None,
purge: None,
supersedes: None,
};
if let Some(signer) = &signer {
primary.signature = Some(sign_redaction(signer.as_ref(), &primary)?);
}
let primary_id = repo.put_redaction(primary)?;
let scope = repo.op_scope();
repo.oplog()
.record_redact(&primary_id, &blob, &state, &args.path, Some(&scope))?;
let mut states_redacted: u32 = 1;
let mut extra_oplog_entries: u32 = 0;
if args.all_states {
let reachable = repo
.reachable_states()
.with_context(|| "enumerate reachable states for --all-states")?;
for other_state in reachable {
if other_state == state {
continue;
}
let paths = repo
.paths_to_blob_in_state(&other_state, &blob)
.with_context(|| {
format!("scan state {} for blob occurrences", other_state.short())
})?;
if paths.is_empty() {
continue;
}
states_redacted += 1;
for path in paths {
let mut extra = Redaction {
redacted_blob: blob,
state: other_state,
path: path.clone(),
reason: args.reason.clone(),
redactor: principal.clone(),
redacted_at: now,
signature: None,
purge: None,
supersedes: Some(primary_id),
};
if let Some(signer) = &signer {
extra.signature = Some(sign_redaction(signer.as_ref(), &extra)?);
}
let extra_id = repo.put_redaction(extra)?;
repo.oplog()
.record_redact(&extra_id, &blob, &other_state, &path, Some(&scope))?;
extra_oplog_entries += 1;
}
}
}
let _ = extra_oplog_entries;
let ignore_hint = ignore_hint_for_path(repo, &args.path)?;
let output = RedactApplyOutput {
output_kind: "redact_apply",
redaction_id: primary_id.short(),
blob: blob.short(),
state: state.short(),
path: args.path,
reason: args.reason,
redactor: principal.to_string(),
redacted_at: now.to_rfc3339(),
all_states: args.all_states,
states_redacted,
signed: signer.is_some(),
signature_algorithm,
ignore_hint,
};
emit_apply(cli, &output)
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct IgnoreHint {
pub ignore_file: String,
pub already_exists: bool,
pub suggested_pattern: String,
pub message: String,
}
pub(crate) fn ignore_hint_for_path(repo: &Repository, path: &str) -> Result<Option<IgnoreHint>> {
let patterns = repo
.ignore_patterns()
.with_context(|| "load .heddleignore patterns for redact-hint coverage check")?;
if should_ignore(&PathBuf::from(path), &patterns) {
return Ok(None);
}
let ignore_file = match repo.capability() {
RepositoryCapability::GitOverlay => ".gitignore",
RepositoryCapability::NativeHeddle => ".heddleignore",
};
let exists = repo.root().join(ignore_file).is_file();
let message = if exists {
format!(
"hint: add `{path}` to {ignore_file} so the next `heddle capture` doesn't re-import the leaked bytes"
)
} else {
format!(
"hint: create {ignore_file} with `{path}` so the next `heddle capture` doesn't re-import the leaked bytes"
)
};
Ok(Some(IgnoreHint {
ignore_file: ignore_file.to_string(),
already_exists: exists,
suggested_pattern: path.to_string(),
message,
}))
}
fn sign_redaction(signer: &dyn Signer, redaction: &Redaction) -> Result<StateSignature> {
let payload = redaction.canonical_signing_payload();
let signature = signer
.sign(&payload)
.with_context(|| "sign redaction payload")?;
Ok(StateSignature {
algorithm: signer.algorithm().to_string(),
public_key: hex::encode(signer.public_key()),
signature: hex::encode(&signature),
})
}
pub(crate) fn verify_redaction_signature(redaction: &Redaction) -> Result<bool> {
let Some(signature) = &redaction.signature else {
return Ok(false);
};
let payload = redaction.canonical_signing_payload();
let public_key = hex::decode(&signature.public_key)
.with_context(|| "decode redaction signature public key")?;
let sig_bytes =
hex::decode(&signature.signature).with_context(|| "decode redaction signature bytes")?;
verify_payload_signature(&payload, &signature.algorithm, &public_key, &sig_bytes)
.with_context(|| "verify redaction signature")?;
Ok(true)
}
fn cmd_redact_list(cli: &Cli, repo: &Repository, _args: RedactListArgs) -> Result<()> {
let listing = repo.list_all_redactions()?;
#[derive(Serialize)]
struct Row {
redaction_id: String,
blob: String,
state: String,
path: String,
reason: String,
redactor: String,
redacted_at: String,
purged: bool,
purged_at: Option<String>,
}
#[derive(Serialize)]
struct Listing {
output_kind: &'static str,
redactions: Vec<Row>,
count: usize,
}
let mut rows: Vec<Row> = Vec::new();
for (blob, redactions_blob) in &listing {
for redaction in &redactions_blob.redactions {
let id = canonical_id_for(redaction)?;
rows.push(Row {
redaction_id: id.short(),
blob: blob.short(),
state: redaction.state.short(),
path: redaction.path.clone(),
reason: redaction.reason.clone(),
redactor: redaction.redactor.to_string(),
redacted_at: redaction.redacted_at.to_rfc3339(),
purged: redaction.is_purged(),
purged_at: redaction
.purge
.as_ref()
.map(|evidence| evidence.purged_at.to_rfc3339()),
});
}
}
let count = rows.len();
let payload = Listing {
output_kind: "redact_list",
redactions: rows,
count,
};
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(&payload)?);
} else if count == 0 {
println!("no redactions in repo");
} else {
println!("{} redaction(s):", count);
for row in &payload.redactions {
println!(
" {} blob={} state={} path={} {}",
row.redaction_id,
row.blob,
row.state,
row.path,
if row.purged {
"[purged]"
} else {
"[bytes on disk]"
}
);
}
}
Ok(())
}
fn cmd_redact_show(cli: &Cli, repo: &Repository, args: RedactShowArgs) -> Result<()> {
let id = resolve_redaction_id(repo, &args.redaction_id)?;
let (blob, redaction) = repo
.get_redaction(&id)?
.ok_or_else(|| anyhow!("redaction '{}' not found", args.redaction_id))?;
let signature_status = redaction_signature_status(
redaction.signature.is_some(),
verify_redaction_signature(&redaction).map_err(|_| ()),
);
let signature_algorithm = redaction.signature.as_ref().map(|s| s.algorithm.clone());
#[derive(Serialize)]
struct ShowOutput<'a> {
output_kind: &'static str,
redaction_id: String,
blob: String,
state: String,
path: &'a str,
reason: &'a str,
redactor: String,
redacted_at: String,
purged_at: Option<String>,
supersedes: Option<String>,
signed: bool,
signature_status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
signature_algorithm: Option<String>,
stub_preview: String,
}
let output = ShowOutput {
output_kind: "redact_show",
redaction_id: id.short(),
blob: blob.short(),
state: redaction.state.short(),
path: &redaction.path,
reason: &redaction.reason,
redactor: redaction.redactor.to_string(),
redacted_at: redaction.redacted_at.to_rfc3339(),
purged_at: redaction
.purge
.as_ref()
.map(|evidence| evidence.purged_at.to_rfc3339()),
supersedes: redaction.supersedes.map(|h| h.short()),
signed: redaction.signature.is_some(),
signature_status: signature_status.label(),
signature_algorithm,
stub_preview: redaction.stub_text(&id),
};
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(&output)?);
} else {
println!("redaction {}", output.redaction_id);
println!(" blob: {}", output.blob);
println!(" state: {}", output.state);
println!(" path: {}", output.path);
println!(" reason: {}", output.reason);
println!(" redactor: {}", output.redactor);
println!(" redacted-at: {}", output.redacted_at);
println!(
" purged-at: {}",
output.purged_at.as_deref().unwrap_or("(bytes on disk)")
);
println!(" signed: {}", output.signature_status);
if let Some(algo) = &output.signature_algorithm {
println!(" sig-algo: {}", algo);
}
if let Some(supersedes) = &output.supersedes {
println!(" supersedes: {}", supersedes);
}
println!();
println!("stub that readers see:");
println!("---");
for line in output.stub_preview.lines() {
println!("{}", line);
}
}
Ok(())
}
fn emit_apply(cli: &Cli, output: &RedactApplyOutput) -> Result<()> {
if should_output_json(cli, None) {
println!("{}", serde_json::to_string(output)?);
} else {
println!(
"redacted {} ({}) in {} (redaction {})",
output.path, output.blob, output.state, output.redaction_id,
);
if !output.reason.is_empty() {
println!(" reason: {}", output.reason);
}
if let Some(hint) = &output.ignore_hint {
println!(" {}", hint.message);
}
}
Ok(())
}
fn resolve_state(repo: &Repository, spec: &str) -> Result<StateId> {
repo::resolve_state_for_command(repo, spec, repo::ResolvePolicy::minimal())
.map(|resolved| resolved.state_id)
.map_err(|error| match error {
repo::StateResolveError::Repository(err) => err.into(),
repo::StateResolveError::Failure(repo::StateResolveFailure::NotFound { spec }) => {
anyhow!("state '{}' not found", spec)
}
repo::StateResolveError::Failure(other) => anyhow::Error::from(other),
})
.with_context(|| format!("resolve state '{}'", spec))
}
pub(crate) fn blob_at_path(repo: &Repository, state: &StateId, path: &str) -> Result<ContentHash> {
let tree = repo
.get_tree_for_state(state)
.with_context(|| format!("load tree for state {}", state.short()))?
.ok_or_else(|| anyhow!("state '{}' has no tree", state.short()))?;
let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
if parts.is_empty() {
return Err(anyhow!(RecoveryAdvice::invalid_usage(
"redact_path_empty",
"redact path must not be empty",
"Pass a repository-relative path with `--path <path>`.",
"heddle redact apply <state> --path <path>",
)));
}
let hash = match resolve_tree_path(
repo.store(),
&tree.hash(),
std::path::Path::new(path),
LeafPolicy::BlobOnly,
) {
Ok(Some(target)) => target.content_hash,
Ok(None) => None,
Err(TreePathResolveError::SubtreeMissing(hash)) => {
return Err(anyhow!("subtree {} missing from store", hash.short()));
}
Err(TreePathResolveError::Store { hash, source }) => {
return Err(anyhow::Error::from(*source))
.context(format!("load subtree {}", hash.short()));
}
}
.ok_or_else(|| anyhow!("path '{}' not in state {}", path, state.short()))?;
Ok(hash)
}
pub(crate) fn resolve_redaction_id(repo: &Repository, spec: &str) -> Result<ContentHash> {
let listing = repo.list_all_redactions()?;
let normalised = spec.trim_start_matches("hs-").to_ascii_lowercase();
let mut candidates: Vec<ContentHash> = Vec::new();
for (_blob, redactions_blob) in &listing {
for redaction in &redactions_blob.redactions {
let id = canonical_id_for(redaction)?;
if id.short() == spec {
return Ok(id);
}
let hex = hex_encode(id.as_bytes());
if hex.starts_with(&normalised) {
candidates.push(id);
}
}
}
match candidates.len() {
0 => Err(anyhow!("no redaction matches '{}'", spec)),
1 => Ok(candidates[0]),
n => Err(anyhow!(
"ambiguous redaction id '{}' matches {} redactions; provide a longer prefix",
spec,
n
)),
}
}
pub(crate) fn canonical_id_for(redaction: &Redaction) -> Result<ContentHash> {
let single = RedactionsBlob::new(vec![redaction.clone()]);
let bytes = single
.encode()
.with_context(|| "encode single-redaction for content addressing")?;
let digest = blake3::hash(&bytes);
Ok(ContentHash::from_bytes(*digest.as_bytes()))
}
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{:02x}", b);
}
out
}
#[cfg(test)]
mod tree_path_tests {
use objects::{
object::{Attribution, Blob, ContentHash, Principal, State, Tree, TreeEntry},
store::ObjectStore,
};
use repo::Repository;
use tempfile::TempDir;
use super::blob_at_path;
fn state_with_tree(repo: &Repository, root_hash: ContentHash) -> objects::object::StateId {
let state = State::new(
root_hash,
Vec::new(),
Attribution::human(Principal::new("tester", "tester@example.com")),
);
repo.store().put_state(&state).unwrap();
state.state_id
}
#[test]
fn blob_at_path_characterizes_blob_only_policy() {
let temp_dir = TempDir::new().unwrap();
let repo = Repository::init_default(temp_dir.path()).unwrap();
let store = repo.store();
let blob_hash = store.put_blob(&Blob::from_slice(b"blob content")).unwrap();
let symlink_hash = store.put_blob(&Blob::from_slice(b"target.txt")).unwrap();
let nested_blob_hash = store
.put_blob(&Blob::from_slice(b"nested content"))
.unwrap();
let missing_subtree_hash = ContentHash::compute(b"not-in-store");
let nested_tree = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("inner.txt", nested_blob_hash, false).unwrap(),
]))
.unwrap();
let missing_parent = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::directory("ghost".to_string(), missing_subtree_hash).unwrap(),
]))
.unwrap();
let root_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt", blob_hash, false).unwrap(),
TreeEntry::symlink("link".to_string(), symlink_hash).unwrap(),
TreeEntry::directory("dir".to_string(), nested_tree).unwrap(),
TreeEntry::directory("missing".to_string(), missing_parent).unwrap(),
]))
.unwrap();
let state = state_with_tree(&repo, root_hash);
assert_eq!(blob_at_path(&repo, &state, "file.txt").unwrap(), blob_hash);
assert_eq!(
blob_at_path(&repo, &state, "dir/inner.txt").unwrap(),
nested_blob_hash
);
let symlink_err = blob_at_path(&repo, &state, "link").unwrap_err();
assert!(symlink_err.to_string().contains("path 'link' not in state"));
let missing_err = blob_at_path(&repo, &state, "nope.txt").unwrap_err();
assert!(
missing_err
.to_string()
.contains("path 'nope.txt' not in state")
);
let subtree_err = blob_at_path(&repo, &state, "missing/ghost/inner.txt").unwrap_err();
assert!(
subtree_err.to_string().contains("subtree")
&& subtree_err.to_string().contains("missing from store")
);
}
}