ghidra-cli/src/store/artifact.rs
2026-07-28 20:14:06 +00:00

196 lines
6.4 KiB
Rust

use std::{
fs,
io::Write as _,
path::{Path, PathBuf},
};
use sha2::{Digest as _, Sha256};
use crate::{
domain::{Digest, TaggedPath},
operation::ArtifactDescriptor,
};
use super::{
AnalysisStore, StoreError, StoreErrorKind,
analysis::{digest_bytes, hash_file},
filesystem::{
create_private_directory, open_new_private_file, random_id, set_file_mode, sync_directory,
},
};
/// Published immutable complete-success Artifact.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Artifact {
path: PathBuf,
bytes: u64,
digest: Digest,
}
impl Artifact {
/// Absolute published JSON path.
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
/// Exact bytes including the required trailing LF.
#[must_use]
pub const fn bytes(&self) -> u64 {
self.bytes
}
/// SHA-256 of the exact published bytes.
#[must_use]
pub const fn digest(&self) -> &Digest {
&self.digest
}
/// Builds the fixed public spill descriptor for this Artifact.
#[must_use]
pub fn descriptor(&self) -> ArtifactDescriptor {
ArtifactDescriptor {
path: TaggedPath::from_path(&self.path),
bytes: self.bytes,
sha256: self.digest.clone(),
media_type: "application/json".to_owned(),
contains: "complete_success_response".to_owned(),
}
}
}
/// Atomically publishes an exact complete JSON success document by content digest.
pub fn publish_artifact(
store: &AnalysisStore,
sample: &Digest,
profile: &Digest,
exact_bytes: &[u8],
) -> Result<Artifact, StoreError> {
validate_artifact_bytes(exact_bytes)?;
let digest = digest_bytes(&Sha256::digest(exact_bytes))?;
let directory = store
.path("artifacts")
.join(sample.as_str())
.join(profile.as_str());
create_private_directory(&directory)?;
let destination = directory.join(format!("{}.json", digest.as_str()));
if let Ok(metadata) = fs::symlink_metadata(&destination) {
if !metadata.is_file()
|| metadata.file_type().is_symlink()
|| hash_file(&destination)? != digest
{
return Err(StoreError::at(
StoreErrorKind::ImmutableConflict,
&destination,
"Artifact identity conflicts with existing storage",
));
}
return artifact(destination, exact_bytes.len(), digest);
}
let temporary = directory.join(format!("artifact-{}.tmp", random_id()?));
let result = (|| {
let mut file = open_new_private_file(&temporary)?;
file.write_all(exact_bytes)
.map_err(|error| StoreError::io(&temporary, error))?;
file.sync_all()
.map_err(|error| StoreError::io(&temporary, error))?;
set_file_mode(&temporary, 0o400)?;
match fs::hard_link(&temporary, &destination) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if hash_file(&destination)? != digest {
return Err(StoreError::at(
StoreErrorKind::ImmutableConflict,
&destination,
"concurrent Artifact conflict",
));
}
}
Err(error) => return Err(StoreError::io(&destination, error)),
}
fs::remove_file(&temporary).map_err(|error| StoreError::io(&temporary, error))?;
sync_directory(&directory)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result?;
artifact(destination, exact_bytes.len(), digest)
}
fn validate_artifact_bytes(bytes: &[u8]) -> Result<(), StoreError> {
if bytes.is_empty() || !bytes.ends_with(b"\n") || bytes.ends_with(b"\n\n") {
return Err(StoreError::new(
StoreErrorKind::Io,
"Artifact must have exactly one trailing LF",
));
}
let json = &bytes[..bytes.len() - 1];
if json.starts_with(&[0xef, 0xbb, 0xbf])
|| std::str::from_utf8(json).is_err()
|| serde_json::from_slice::<serde_json::Value>(json).is_err()
{
return Err(StoreError::new(
StoreErrorKind::Io,
"Artifact must be BOM-free valid UTF-8 JSON",
));
}
Ok(())
}
fn artifact(path: PathBuf, length: usize, digest: Digest) -> Result<Artifact, StoreError> {
Ok(Artifact {
path,
bytes: u64::try_from(length)
.map_err(|_| StoreError::new(StoreErrorKind::Io, "Artifact length overflow"))?,
digest,
})
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::publish_artifact;
use crate::{
domain::Digest,
store::{AnalysisStore, StoreEnvironment, resolve_store},
};
use std::{ffi::OsString, fs, str::FromStr as _};
use tempfile::TempDir;
#[test]
fn identical_exact_bytes_publish_one_immutable_identity() {
let temp = TempDir::new().expect("temp");
let environment = StoreEnvironment {
ghidr_store: Some(OsString::from(temp.path().join("store"))),
..StoreEnvironment::default()
};
let store = AnalysisStore::initialize(resolve_store(None, &environment).expect("resolve"))
.expect("store");
let sample = Digest::from_str(&"a".repeat(64)).expect("digest");
let profile = Digest::from_str(&"b".repeat(64)).expect("digest");
let first = publish_artifact(&store, &sample, &profile, b"{\"kind\":\"inspection\"}\n")
.expect("publish");
let second = publish_artifact(&store, &sample, &profile, b"{\"kind\":\"inspection\"}\n")
.expect("reuse");
assert_eq!(first, second);
assert_eq!(
fs::read(first.path()).expect("read"),
b"{\"kind\":\"inspection\"}\n"
);
}
#[test]
fn refuses_partial_or_non_json_artifacts() {
let temp = TempDir::new().expect("temp");
let environment = StoreEnvironment {
ghidr_store: Some(OsString::from(temp.path().join("store"))),
..StoreEnvironment::default()
};
let store = AnalysisStore::initialize(resolve_store(None, &environment).expect("resolve"))
.expect("store");
let digest = Digest::from_str(&"a".repeat(64)).expect("digest");
assert!(publish_artifact(&store, &digest, &digest, b"{}").is_err());
}
}