use std::path::Path;
use serde::Serialize;
use void_core::cid;
use void_core::crypto::{
AAD_COMMIT, AAD_INDEX, AAD_MANIFEST, AAD_METADATA, AAD_SHARD, EncryptedCommit, KeyVault, MAGIC_V1,
};
use void_core::metadata::{parse_commit, MetadataBundle};
use void_core::store::{FsStore, ObjectStoreExt};
use crate::context::{open_repo, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug)]
pub struct InspectObjectArgs {
pub cid: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InspectObjectOutput {
pub cid: String,
pub raw_size: usize,
pub has_envelope: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub envelope_nonce: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detected_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aad_used: Option<String>,
pub fallback_level: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub technique: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub decrypted_size: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_summary: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub attempts: Vec<AttemptEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttemptEntry {
pub aad: String,
pub key_type: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
struct AadProbe {
name: &'static str,
aad: &'static [u8],
}
const AAD_PROBES: &[AadProbe] = &[
AadProbe {
name: "commit",
aad: AAD_COMMIT,
},
AadProbe {
name: "metadata",
aad: AAD_METADATA,
},
AadProbe {
name: "shard",
aad: AAD_SHARD,
},
AadProbe {
name: "index",
aad: AAD_INDEX,
},
AadProbe {
name: "manifest",
aad: AAD_MANIFEST,
},
];
fn classify_fallback(
has_envelope: bool,
nonce: &Option<void_core::crypto::KeyNonce>,
aad: &[u8],
vault: &KeyVault,
blob: &[u8],
) -> u32 {
if let Some(n) = nonce {
let scope = format!("commit:{}", hex::encode(n.as_bytes()));
if let Ok(derived_ck) = vault.derive_scoped_key(&scope) {
if blob.len() > 20 {
if void_core::crypto::decrypt(derived_ck.as_bytes(), &blob[20..], aad).is_ok() {
return 0; }
}
}
return 1; }
if has_envelope { 2 } else { 3 }
}
fn describe_technique(fallback_level: u32, aad_name: &str) -> String {
match fallback_level {
0 => format!("VD01 envelope, derived key, {} AAD", aad_name),
1 => format!("VD01 envelope, derived key, empty AAD (fallback)"),
2 => format!("legacy format, root key, {} AAD", aad_name),
3 => format!("legacy format, root key, empty AAD (fallback)"),
_ => "unknown technique".to_string(),
}
}
fn summarize_content(aad_name: &str, plaintext: &[u8], encrypted: &[u8], vault: &KeyVault) -> String {
match aad_name {
"commit" => summarize_commit(plaintext),
"metadata" => summarize_metadata(encrypted, vault),
"shard" => summarize_shard(plaintext),
_ => format!("Decrypted {} bytes", plaintext.len()),
}
}
fn summarize_commit(plaintext: &[u8]) -> String {
match parse_commit(plaintext) {
Ok(commit) => {
let schema = "commit";
let signed = if commit.is_signed() { ", signed" } else { "" };
format!(
"Commit ({}): \"{}\", {} parent(s), ts={}{signed}",
schema,
commit.message,
commit.parents.len(),
commit.timestamp,
)
}
Err(_) => format!(
"Decrypted {} bytes (commit AAD, parse failed)",
plaintext.len()
),
}
}
fn summarize_metadata(encrypted: &[u8], vault: &KeyVault) -> String {
if let Ok(aligned) = vault.decrypt_blob_raw(encrypted, AAD_METADATA) {
if let Ok(bundle) = ciborium::from_reader::<MetadataBundle, _>(&aligned[..]) {
let shard_count = bundle
.shard_map
.ranges
.iter()
.filter(|r| r.cid.is_some())
.count();
return format!(
"Metadata: version={}, {} ranges, {} shards with content",
bundle.version,
bundle.shard_map.ranges.len(),
shard_count,
);
}
}
"Metadata (decrypted, parse failed)".to_string()
}
fn summarize_shard(plaintext: &[u8]) -> String {
format!("Shard: {} bytes (compressed, opaque block)", plaintext.len())
}
pub fn run(cwd: &Path, args: InspectObjectArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("inspect-object", opts, |ctx| {
ctx.progress(format!("Inspecting object {}...", args.cid));
let repo = open_repo(cwd)?;
let vault = repo.vault();
let objects_dir = repo.void_dir().join("objects");
let store = FsStore::new(objects_dir).map_err(void_err_to_cli)?;
let parsed_cid = cid::parse(&args.cid)
.map_err(|e| CliError::invalid_args(format!("invalid CID: {}", e)))?;
let encrypted_blob: EncryptedCommit = store.get_blob(&parsed_cid).map_err(void_err_to_cli)?;
let encrypted = encrypted_blob.as_bytes();
let raw_size = encrypted.len();
let has_envelope = raw_size > 20 && encrypted.starts_with(MAGIC_V1);
let envelope_nonce = if has_envelope {
let nonce = void_core::crypto::KeyNonce::from_bytes(&encrypted[4..20])
.expect("slice is exactly 16 bytes");
Some(nonce.to_string())
} else {
None
};
ctx.progress(format!(
"Raw size: {} bytes, envelope: {}",
raw_size,
if has_envelope {
"VD01"
} else {
"none (legacy)"
}
));
let mut attempts = Vec::new();
let mut detected_type: Option<String> = None;
let mut format: Option<String> = None;
let mut key_type: Option<String> = None;
let mut aad_used: Option<String> = None;
let mut fallback_level: u32 = 0;
let mut technique: Option<String> = None;
let mut decrypted_size: Option<usize> = None;
let mut content_summary: Option<String> = None;
for probe in AAD_PROBES {
match vault.decrypt_blob(&encrypted, probe.aad) {
Ok(plaintext) => {
let nonce_opt = if has_envelope {
void_core::crypto::KeyNonce::from_bytes(&encrypted[4..20])
} else {
None
};
let level =
classify_fallback(has_envelope, &nonce_opt, probe.aad, vault, &encrypted);
detected_type = Some(probe.name.to_string());
format = Some("vd01".to_string());
key_type = Some("derived".to_string());
aad_used = Some(probe.name.to_string());
fallback_level = level;
technique = Some(describe_technique(level, probe.name));
decrypted_size = Some(plaintext.len());
content_summary =
Some(summarize_content(probe.name, &plaintext, &encrypted, vault));
attempts.push(AttemptEntry {
aad: probe.name.to_string(),
key_type: "derived".to_string(),
success: true,
error: None,
});
ctx.progress(format!(
"Decrypted as {} (vd01, {} bytes plaintext)",
probe.name,
plaintext.len()
));
break;
}
Err(e) => {
let inferred_key_type = if has_envelope { "derived" } else { "root" };
attempts.push(AttemptEntry {
aad: probe.name.to_string(),
key_type: inferred_key_type.to_string(),
success: false,
error: Some(e.to_string()),
});
}
}
}
if !ctx.use_json() {
ctx.info(format!("CID: {}", args.cid));
ctx.info(format!("Raw size: {} bytes", raw_size));
ctx.info(format!(
"Envelope: {}",
if has_envelope {
"VD01"
} else {
"none (legacy)"
}
));
if let Some(ref nonce) = envelope_nonce {
ctx.info(format!("Envelope nonce: {}", nonce));
}
ctx.info(String::new());
if let Some(ref dtype) = detected_type {
ctx.info(format!("Detected type: {}", dtype));
if let Some(ref t) = technique {
ctx.info(format!("Technique: {}", t));
}
if let Some(size) = decrypted_size {
ctx.info(format!("Decrypted size: {} bytes", size));
}
if let Some(ref summary) = content_summary {
ctx.info(format!("Content: {}", summary));
}
} else {
ctx.warn("Could not decrypt with any known AAD type.");
ctx.info(format!(
"Tried: {}",
AAD_PROBES
.iter()
.map(|p| p.name)
.collect::<Vec<_>>()
.join(", ")
));
}
}
Ok(InspectObjectOutput {
cid: args.cid,
raw_size,
has_envelope,
envelope_nonce,
detected_type,
format,
key_type,
aad_used,
fallback_level,
technique,
decrypted_size,
content_summary,
attempts,
})
})
}