2026-04-13 08:29:44 -04:00
|
|
|
//! Content-addressed blob store for attachments shared over mesh/federation.
|
|
|
|
|
//!
|
|
|
|
|
//! Blobs live at `${data_dir}/blobs/<cid>` where `cid` is the hex-encoded
|
|
|
|
|
//! SHA-256 of the content. A sibling `<cid>.meta` file holds JSON metadata
|
|
|
|
|
//! (mime, filename, size, created_at). Capability URLs are HMAC-signed tokens
|
|
|
|
|
//! scoped to a recipient pubkey and expiry, verified before serving.
|
|
|
|
|
|
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
|
|
|
use hmac::{Hmac, Mac};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use tokio::fs;
|
|
|
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
|
|
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
|
|
|
|
|
|
/// Default capability URL validity window.
|
|
|
|
|
pub const DEFAULT_CAP_TTL_SECS: u64 = 7 * 24 * 60 * 60;
|
|
|
|
|
|
|
|
|
|
/// Maximum blob size accepted by the store (64 MiB). Keep attachments
|
|
|
|
|
/// reasonable so /var/lib/archipelago doesn't balloon unnoticed.
|
|
|
|
|
pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct BlobMeta {
|
|
|
|
|
pub cid: String,
|
|
|
|
|
pub size: u64,
|
|
|
|
|
pub mime: String,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub filename: Option<String>,
|
|
|
|
|
pub created_at: String,
|
|
|
|
|
/// Optional raw thumbnail bytes (small — up to ~60 bytes is LoRa-safe).
|
|
|
|
|
/// Stored alongside meta so ContentRef senders don't re-fetch the blob.
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub thumb_bytes: Option<Vec<u8>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct BlobStore {
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
/// HMAC key used to sign capability URLs. Derived from node identity;
|
|
|
|
|
/// callers pass it in so we don't duplicate key management here.
|
|
|
|
|
cap_key: [u8; 32],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BlobStore {
|
|
|
|
|
/// Create (or open) a blob store rooted at `data_dir/blobs`.
|
|
|
|
|
pub async fn open(data_dir: &Path, cap_key: [u8; 32]) -> Result<Self> {
|
|
|
|
|
let root = data_dir.join("blobs");
|
chore(ci): rustfmt + clippy clean-up to unblock the Rust CI job
The .github/workflows/ci.yml Rust job runs cargo fmt --check, clippy
with -D warnings, and tests. All three were failing. This commit:
- Applies rustfmt across the tree (the bulk of the diff — untouched
since the last toolchain bump, so a wide sweep was unavoidable).
- Fixes the correctness-level clippy errors:
container/bitcoin_simulator.rs wildcard-in-or-pattern
container/manifest.rs from_str rename to parse (reserved name)
container/podman_client.rs .get(0) -> .first()
container/runtime.rs manual += collapse
archipelago/src/constants.rs doc-comment → module-doc
api/rpc/package/install.rs stray /// comment above a non-item
container/docker_packages.rs redundant field init
streaming/advertisement.rs missing Metric import in tests
tests/orchestration_tests.rs `vec!` in non-Vec contexts
mesh/listener/dispatch.rs unused store_plain_message import
api/rpc/tor/mod.rs and mesh/steganography.rs: push-after-new → vec!
- Quiets wide legacy surfaces with crate-level allows in main.rs for
stylistic lints (too_many_arguments, type_complexity, doc indent,
enum variant prefix, wildcard-in-or, assertions-on-constants,
drop_non_drop, unused_io_amount, ptr_arg) — these fired in dozens
of places with no correctness payoff and have been churning every
toolchain bump.
- Tags intentional-dead-code helpers: wallet/ and streaming/ modules
are WIP, mesh::send_chunked_payload and DM_V1_MARKER are kept for
rollback compatibility, vpn::get_nostr_vpn_status is surface-area
for a not-yet-landed RPC.
cargo fmt --check, cargo clippy --all-targets --all-features
-- -D warnings, and cargo test --all-features now all pass locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:23:46 -04:00
|
|
|
fs::create_dir_all(&root)
|
|
|
|
|
.await
|
|
|
|
|
.context("create blobs dir")?;
|
2026-04-13 08:29:44 -04:00
|
|
|
Ok(Self { root, cap_key })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn path_for(&self, cid: &str) -> PathBuf {
|
|
|
|
|
self.root.join(cid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn meta_path_for(&self, cid: &str) -> PathBuf {
|
|
|
|
|
self.root.join(format!("{}.meta", cid))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Write bytes to the store, returning the CID and metadata. Idempotent:
|
|
|
|
|
/// identical bytes produce the same CID and short-circuit re-writes.
|
|
|
|
|
pub async fn put(
|
|
|
|
|
&self,
|
|
|
|
|
bytes: &[u8],
|
|
|
|
|
mime: &str,
|
|
|
|
|
filename: Option<String>,
|
|
|
|
|
thumb_bytes: Option<Vec<u8>>,
|
|
|
|
|
) -> Result<BlobMeta> {
|
|
|
|
|
if bytes.len() as u64 > MAX_BLOB_SIZE {
|
chore(ci): rustfmt + clippy clean-up to unblock the Rust CI job
The .github/workflows/ci.yml Rust job runs cargo fmt --check, clippy
with -D warnings, and tests. All three were failing. This commit:
- Applies rustfmt across the tree (the bulk of the diff — untouched
since the last toolchain bump, so a wide sweep was unavoidable).
- Fixes the correctness-level clippy errors:
container/bitcoin_simulator.rs wildcard-in-or-pattern
container/manifest.rs from_str rename to parse (reserved name)
container/podman_client.rs .get(0) -> .first()
container/runtime.rs manual += collapse
archipelago/src/constants.rs doc-comment → module-doc
api/rpc/package/install.rs stray /// comment above a non-item
container/docker_packages.rs redundant field init
streaming/advertisement.rs missing Metric import in tests
tests/orchestration_tests.rs `vec!` in non-Vec contexts
mesh/listener/dispatch.rs unused store_plain_message import
api/rpc/tor/mod.rs and mesh/steganography.rs: push-after-new → vec!
- Quiets wide legacy surfaces with crate-level allows in main.rs for
stylistic lints (too_many_arguments, type_complexity, doc indent,
enum variant prefix, wildcard-in-or, assertions-on-constants,
drop_non_drop, unused_io_amount, ptr_arg) — these fired in dozens
of places with no correctness payoff and have been churning every
toolchain bump.
- Tags intentional-dead-code helpers: wallet/ and streaming/ modules
are WIP, mesh::send_chunked_payload and DM_V1_MARKER are kept for
rollback compatibility, vpn::get_nostr_vpn_status is surface-area
for a not-yet-landed RPC.
cargo fmt --check, cargo clippy --all-targets --all-features
-- -D warnings, and cargo test --all-features now all pass locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:23:46 -04:00
|
|
|
anyhow::bail!(
|
|
|
|
|
"Blob too large: {} bytes (max {})",
|
|
|
|
|
bytes.len(),
|
|
|
|
|
MAX_BLOB_SIZE
|
|
|
|
|
);
|
2026-04-13 08:29:44 -04:00
|
|
|
}
|
|
|
|
|
let mut hasher = Sha256::new();
|
|
|
|
|
hasher.update(bytes);
|
|
|
|
|
let cid = hex::encode(hasher.finalize());
|
|
|
|
|
let meta = BlobMeta {
|
|
|
|
|
cid: cid.clone(),
|
|
|
|
|
size: bytes.len() as u64,
|
|
|
|
|
mime: mime.to_string(),
|
|
|
|
|
filename,
|
|
|
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
|
|
|
|
thumb_bytes,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let blob_path = self.path_for(&cid);
|
|
|
|
|
if !blob_path.exists() {
|
|
|
|
|
let mut f = fs::File::create(&blob_path).await.context("create blob")?;
|
|
|
|
|
f.write_all(bytes).await.context("write blob")?;
|
|
|
|
|
f.sync_all().await.ok();
|
|
|
|
|
}
|
|
|
|
|
let meta_json = serde_json::to_vec(&meta)?;
|
|
|
|
|
fs::write(self.meta_path_for(&cid), meta_json)
|
|
|
|
|
.await
|
|
|
|
|
.context("write blob meta")?;
|
|
|
|
|
Ok(meta)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Read raw bytes for a CID. Errors if missing.
|
|
|
|
|
pub async fn get(&self, cid: &str) -> Result<Vec<u8>> {
|
|
|
|
|
let path = self.path_for(cid);
|
|
|
|
|
fs::read(&path)
|
|
|
|
|
.await
|
|
|
|
|
.with_context(|| format!("blob not found: {}", cid))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load metadata for a CID.
|
|
|
|
|
pub async fn meta(&self, cid: &str) -> Result<BlobMeta> {
|
|
|
|
|
let raw = fs::read(self.meta_path_for(cid))
|
|
|
|
|
.await
|
|
|
|
|
.with_context(|| format!("blob meta not found: {}", cid))?;
|
|
|
|
|
Ok(serde_json::from_slice(&raw)?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check whether a CID is held locally.
|
|
|
|
|
pub async fn has(&self, cid: &str) -> bool {
|
|
|
|
|
fs::try_exists(self.path_for(cid)).await.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Sign a capability token: HMAC-SHA256(cid || peer_pubkey || expiry).
|
|
|
|
|
/// Returned token is hex — callers append `?cap=<token>&exp=<epoch>` to
|
|
|
|
|
/// the blob URL sent to the peer.
|
|
|
|
|
pub fn issue_capability(&self, cid: &str, peer_pubkey_hex: &str, expiry_epoch: u64) -> String {
|
|
|
|
|
let mut mac = HmacSha256::new_from_slice(&self.cap_key).expect("hmac key");
|
|
|
|
|
mac.update(cid.as_bytes());
|
|
|
|
|
mac.update(b"|");
|
|
|
|
|
mac.update(peer_pubkey_hex.as_bytes());
|
|
|
|
|
mac.update(b"|");
|
|
|
|
|
mac.update(&expiry_epoch.to_be_bytes());
|
|
|
|
|
hex::encode(mac.finalize().into_bytes())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verify a capability token against (cid, peer_pubkey, expiry).
|
|
|
|
|
/// Returns Ok(()) on success, Err describing the failure otherwise.
|
|
|
|
|
/// Expired tokens fail even with a correct signature.
|
|
|
|
|
pub fn verify_capability(
|
|
|
|
|
&self,
|
|
|
|
|
cid: &str,
|
|
|
|
|
peer_pubkey_hex: &str,
|
|
|
|
|
expiry_epoch: u64,
|
|
|
|
|
token_hex: &str,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let now = chrono::Utc::now().timestamp() as u64;
|
|
|
|
|
if expiry_epoch < now {
|
|
|
|
|
return Err(anyhow!("capability expired"));
|
|
|
|
|
}
|
|
|
|
|
let expected = self.issue_capability(cid, peer_pubkey_hex, expiry_epoch);
|
|
|
|
|
// Constant-time compare via HMAC verify.
|
|
|
|
|
let token_bytes =
|
|
|
|
|
hex::decode(token_hex).map_err(|_| anyhow!("capability token not hex"))?;
|
|
|
|
|
let expected_bytes = hex::decode(&expected).unwrap();
|
|
|
|
|
if token_bytes.len() != expected_bytes.len() {
|
|
|
|
|
return Err(anyhow!("capability length mismatch"));
|
|
|
|
|
}
|
|
|
|
|
// hmac::Mac::verify is the idiomatic constant-time path, but we
|
|
|
|
|
// already computed `expected` so fall back to ct_eq via subtle.
|
|
|
|
|
let mut diff = 0u8;
|
|
|
|
|
for (a, b) in token_bytes.iter().zip(expected_bytes.iter()) {
|
|
|
|
|
diff |= a ^ b;
|
|
|
|
|
}
|
|
|
|
|
if diff == 0 {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(anyhow!("capability signature mismatch"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|