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 { 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::(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 { 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()); } }