use std::path::Path;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::AppError;
pub mod local;
#[cfg(any(feature = "blob-s3", feature = "blob-gcs"))]
pub mod object;
#[cfg(any(test, feature = "blob-presigned"))]
pub mod presigned;
#[cfg(any(test, feature = "test-support"))]
pub mod conformance;
#[cfg(all(
any(test, feature = "test-support"),
any(test, feature = "blob-presigned")
))]
pub mod mock_s3;
pub const MAX_BLOB_BYTES: u64 = 4_096 * 262_144;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct BlobKey(String);
impl BlobKey {
pub fn for_room(room_id: &str, blob_ref: &str) -> Result<Self, AppError> {
validate_blob_ref(blob_ref)?;
Ok(Self(format!("{}{blob_ref}", room_prefix(room_id))))
}
pub fn for_upload(
room_id: &str,
blob_ref: &str,
upload: &uuid::Uuid,
) -> Result<Self, AppError> {
validate_blob_ref(blob_ref)?;
Ok(Self(format!(
"{}{blob_ref}-{}",
room_prefix(room_id),
upload.simple()
)))
}
pub fn probe() -> Self {
let mut nonce = [0u8; 16];
rand::fill(&mut nonce);
let hex: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
Self(format!("{PROBE_PREFIX}{hex}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
pub const PROBE_PREFIX: &str = "probe/";
pub fn room_prefix(room_id: &str) -> String {
let digest = Sha256::digest(room_id.as_bytes());
let mut hex = String::with_capacity(32);
for b in &digest[..16] {
hex.push_str(&format!("{b:02x}"));
}
format!("rooms/{hex}/")
}
pub fn validate_blob_ref(blob_ref: &str) -> Result<(), AppError> {
let well_formed = blob_ref.len() <= 128
&& match blob_ref.as_bytes().first() {
Some(b'z') => blob_ref[1..]
.bytes()
.all(|c| c.is_ascii_alphanumeric() && !matches!(c, b'0' | b'O' | b'I' | b'l')),
Some(b'u') => blob_ref[1..]
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'),
_ => false,
};
if !well_formed || sha256_from_digest_multibase(blob_ref).is_none() {
return Err(AppError::Validation(format!(
"`{blob_ref}` is not a sha2-256 DigestMultibase"
)));
}
Ok(())
}
pub fn blob_ref_of_manifest(manifest: &serde_json::Value) -> Result<String, AppError> {
let canonical = serde_json_canonicalizer::to_vec(manifest)
.map_err(|e| AppError::Validation(format!("manifest cannot be canonicalised: {e}")))?;
Ok(sha256_digest_multibase(&Sha256::digest(&canonical).into()))
}
pub fn sha256_digest_multibase(digest: &[u8; 32]) -> String {
vta_sdk::protocols::backup_management::chunked::sha256_digest_multibase(digest)
}
pub fn sha256_from_digest_multibase(value: &str) -> Option<[u8; 32]> {
vta_sdk::protocols::backup_management::chunked::sha256_from_digest_multibase(value)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendRef {
pub kind: String,
pub key: BlobKey,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Deletion {
Deleted,
Lapses { at: chrono::DateTime<chrono::Utc> },
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Health {
pub ok: bool,
pub detail: Option<String>,
}
#[derive(Clone)]
pub struct PresignedRequest {
pub method: String,
pub url: String,
pub headers: std::collections::BTreeMap<String, String>,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
impl std::fmt::Debug for PresignedRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PresignedRequest")
.field("method", &self.method)
.field("url", &"<redacted>")
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageOp {
Put {
len: u64,
sha256: [u8; 32],
},
Get,
Head,
Delete,
CreateMultipart,
UploadPart {
part: u32,
len: u64,
sha256: [u8; 32],
},
ListParts {
marker: Option<u32>,
},
CompleteMultipart {
len: u64,
},
AbortMultipart,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DirectPart {
pub number: u32,
pub len: u64,
pub sha256: [u8; 32],
}
pub const MIN_MULTIPART_PART_BYTES: u64 = 5 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DirectFinish {
Stored(BackendRef),
Missing(u64),
Mismatch(String),
}
#[async_trait]
pub trait BlobStore: Send + Sync {
fn kind(&self) -> &'static str;
async fn put(&self, key: &BlobKey, staged: &Path, size: u64) -> Result<BackendRef, AppError>;
async fn get_range(&self, at: &BackendRef, start: u64, end: u64) -> Result<Vec<u8>, AppError>;
async fn delete(&self, at: &BackendRef) -> Result<Deletion, AppError>;
async fn health(&self) -> Health;
async fn read_target(&self, at: &BackendRef) -> Result<Option<PresignedRequest>, AppError> {
let _ = at;
Ok(None)
}
fn direct_uploads(&self) -> bool {
false
}
async fn direct_begin(
&self,
key: &BlobKey,
multipart: bool,
) -> Result<Option<String>, AppError> {
let _ = (key, multipart);
Err(no_direct())
}
async fn direct_targets(
&self,
key: &BlobKey,
upload_id: Option<&str>,
parts: &[DirectPart],
) -> Result<Vec<PresignedRequest>, AppError> {
let _ = (key, upload_id, parts);
Err(no_direct())
}
async fn direct_held(
&self,
key: &BlobKey,
upload_id: Option<&str>,
plan: &[DirectPart],
) -> Result<Vec<u32>, AppError> {
let _ = (key, upload_id, plan);
Err(no_direct())
}
async fn direct_finish(
&self,
key: &BlobKey,
upload_id: Option<&str>,
plan: &[DirectPart],
) -> Result<DirectFinish, AppError> {
let _ = (key, upload_id, plan);
Err(no_direct())
}
async fn direct_abort(&self, key: &BlobKey, upload_id: Option<&str>) -> Result<(), AppError> {
let _ = (key, upload_id);
Err(no_direct())
}
}
fn no_direct() -> AppError {
AppError::Internal("this store takes no direct uploads".into())
}
pub(crate) fn check_kind(store: &dyn BlobStore, at: &BackendRef) -> Result<(), AppError> {
if at.kind != store.kind() {
return Err(AppError::Internal(format!(
"blob `{}` was stored by a `{}` store, not this `{}` one",
at.key.as_str(),
at.kind,
store.kind()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn a_ref() -> String {
sha256_digest_multibase(&[7u8; 32])
}
#[test]
fn room_prefix_hides_the_room_and_is_stable() {
let p = room_prefix("did:webvh:QmRoom:example.com:room");
assert!(p.starts_with("rooms/") && p.ends_with('/'));
assert_eq!(p.len(), "rooms/".len() + 32 + 1);
assert!(!p.contains("example"));
assert_eq!(p, room_prefix("did:webvh:QmRoom:example.com:room"));
assert_ne!(p, room_prefix("did:webvh:QmOther:example.com:room"));
}
#[test]
fn a_key_is_built_only_from_a_real_digest() {
let key = BlobKey::for_room("did:key:zRoom", &a_ref()).unwrap();
assert!(key.as_str().ends_with(&a_ref()));
for bad in [
"",
"../etc/passwd",
"zQm/../x",
"sha256:abcd",
"z0OIl",
"uAAAA",
] {
assert!(BlobKey::for_room("did:key:zRoom", bad).is_err(), "{bad}");
}
}
#[test]
fn blob_ref_is_over_the_canonical_form() {
let a = serde_json::json!({"size": 5, "digest": "zX", "chunks": {"chunkSize": 16384}});
let b: serde_json::Value =
serde_json::from_str(r#"{"chunks":{"chunkSize":16384},"digest":"zX","size":5}"#)
.unwrap();
assert_eq!(
blob_ref_of_manifest(&a).unwrap(),
blob_ref_of_manifest(&b).unwrap()
);
validate_blob_ref(&blob_ref_of_manifest(&a).unwrap()).unwrap();
}
}