feat: implement sample and analysis store
This commit is contained in:
parent
170ca32f58
commit
ce641749d2
13 changed files with 3086 additions and 3 deletions
196
src/store/artifact.rs
Normal file
196
src/store/artifact.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue