196 lines
6.4 KiB
Rust
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());
|
|
}
|
|
}
|