use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use transport::{BlobHash, BlobLease, BlobReadAuthorizer, BlobScope, BlobStore};
use crate::settings::{ResidentContentSettings, hex32, parse_hex32};
pub const PERSONAL_STAGE_LEASE: &str = "graphshell.transfer";
pub const PERSONAL_FETCH_LEASE: &str = "graphshell.fetch";
pub const LEGACY_PERSONAL_LEASE: &str = "legacy.personal";
pub const LEGACY_KNOT_LEASE: &str = "legacy.knot";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LegacyBlobMigration {
SourceAbsent,
AlreadyShared,
AlreadyComplete { blobs: usize },
Copied { blobs: usize },
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct MigrationMarker {
schema: String,
source: String,
scope: String,
namespace: String,
hashes: Vec<String>,
}
#[derive(Clone)]
pub struct ResidentBlobCustody {
root: PathBuf,
migration_root: PathBuf,
blobs: Arc<BlobStore>,
readers: BlobReadAuthorizer,
}
impl ResidentBlobCustody {
pub async fn open(
data_root: &Path,
settings: &ResidentContentSettings,
) -> Result<Self, String> {
if settings.gc_interval_seconds == 0 {
return Err("resident content gc_interval_seconds must be greater than zero".into());
}
let content_root = data_root.join("content");
let root = settings
.root
.clone()
.unwrap_or_else(|| content_root.join("blobs"));
let blobs =
BlobStore::open_collecting(&root, Duration::from_secs(settings.gc_interval_seconds))
.await
.map_err(|error| format!("could not open resident blob store: {error}"))?;
Ok(Self {
root,
migration_root: content_root.join("migrations"),
blobs: Arc::new(blobs),
readers: BlobReadAuthorizer::new(),
})
}
pub fn blobs(&self) -> Arc<BlobStore> {
Arc::clone(&self.blobs)
}
pub fn authorizer(&self) -> BlobReadAuthorizer {
self.readers.clone()
}
pub fn root(&self) -> &Path {
&self.root
}
pub async fn bind_scope(&self, scope: BlobScope) -> Result<usize, String> {
let hashes = self
.blobs
.leased_hashes(scope)
.await
.map_err(|error| format!("could not read resident blob leases: {error}"))?;
for hash in &hashes {
self.readers.retain(scope, *hash);
}
Ok(hashes.len())
}
pub async fn migrate_legacy_store(
&self,
source: &Path,
scope: BlobScope,
namespace: &str,
) -> Result<LegacyBlobMigration, String> {
if !source.exists() {
return Ok(LegacyBlobMigration::SourceAbsent);
}
if same_path(source, &self.root)? {
return Ok(LegacyBlobMigration::AlreadyShared);
}
std::fs::create_dir_all(&self.migration_root)
.map_err(|error| format!("could not create blob migration directory: {error}"))?;
let source_name = normalized_path(source)?;
let marker_path = self.marker_path(&source_name, scope, namespace);
if marker_path.exists() {
let marker = load_marker(&marker_path)?;
self.verify_marker(&marker, &source_name, scope, namespace)
.await?;
return Ok(LegacyBlobMigration::AlreadyComplete {
blobs: marker.hashes.len(),
});
}
let legacy = BlobStore::open(source)
.await
.map_err(|error| format!("could not open legacy blob store: {error}"))?;
let hashes = legacy
.retained_hashes()
.await
.map_err(|error| format!("could not list legacy blob store: {error}"))?;
let mut copied = Vec::with_capacity(hashes.len());
for hash in hashes {
let bytes = legacy.get_bytes(hash).await.map_err(|error| {
format!(
"could not read legacy blob {}: {error}",
hex32(hash.as_bytes())
)
})?;
let lease = BlobLease::new(scope, namespace, hash.as_bytes())
.map_err(|error| format!("could not name imported blob lease: {error}"))?;
let installed = self
.blobs
.put_bytes_leased(bytes, &lease)
.await
.map_err(|error| format!("could not import legacy blob: {error}"))?;
if installed != hash {
return Err(format!(
"legacy blob {} changed digest while importing",
hex32(hash.as_bytes())
));
}
copied.push(hash);
}
self.blobs
.flush()
.await
.map_err(|error| format!("could not flush imported blobs: {error}"))?;
legacy
.shutdown()
.await
.map_err(|error| format!("could not close legacy blob store: {error}"))?;
let marker = MigrationMarker {
schema: "mere.graphshell/blob-migration/v1".into(),
source: source_name,
scope: hex32(&scope.to_bytes()),
namespace: namespace.into(),
hashes: copied.iter().map(|hash| hex32(hash.as_bytes())).collect(),
};
save_marker(&marker_path, &marker)?;
self.bind_scope(scope).await?;
Ok(LegacyBlobMigration::Copied {
blobs: copied.len(),
})
}
async fn verify_marker(
&self,
marker: &MigrationMarker,
source: &str,
scope: BlobScope,
namespace: &str,
) -> Result<(), String> {
if marker.schema != "mere.graphshell/blob-migration/v1"
|| marker.source != source
|| marker.scope != hex32(&scope.to_bytes())
|| marker.namespace != namespace
{
return Err(
"resident blob migration marker does not match its source and scope".into(),
);
}
for value in &marker.hashes {
let hash = BlobHash::from_bytes(
parse_hex32(value).map_err(|error| format!("migration marker hash: {error}"))?,
);
let lease = BlobLease::new(scope, namespace, hash.as_bytes())
.map_err(|error| format!("could not name imported blob lease: {error}"))?;
if self
.blobs
.lease_hash(&lease)
.await
.map_err(|error| format!("could not verify imported blob lease: {error}"))?
!= Some(hash)
{
return Err(format!(
"resident blob migration marker claims a missing lease for {value}"
));
}
let bytes = self
.blobs
.get_bytes(hash)
.await
.map_err(|error| format!("could not verify imported blob {value}: {error}"))?;
if blake3::hash(&bytes).as_bytes() != hash.as_bytes() {
return Err(format!(
"resident blob migration marker claims corrupt blob {value}"
));
}
self.readers.retain(scope, hash);
}
Ok(())
}
fn marker_path(&self, source: &str, scope: BlobScope, namespace: &str) -> PathBuf {
let source_hash = blake3::hash(source.as_bytes()).to_hex().to_string();
let namespace = namespace.replace('.', "-");
self.migration_root.join(format!(
"{}-{namespace}-{}.json",
hex32(&scope.to_bytes()),
&source_hash[..16]
))
}
pub async fn shutdown(self) -> Result<(), String> {
let blobs = Arc::try_unwrap(self.blobs)
.map_err(|_| "resident blob store still has active borrowers".to_string())?;
blobs
.shutdown()
.await
.map_err(|error| format!("could not shut down resident blob store: {error}"))
}
}
fn normalized_path(path: &Path) -> Result<String, String> {
std::fs::canonicalize(path)
.map(|path| path.display().to_string())
.map_err(|error| format!("could not resolve blob store {}: {error}", path.display()))
}
fn same_path(left: &Path, right: &Path) -> Result<bool, String> {
Ok(normalized_path(left)?.eq_ignore_ascii_case(&normalized_path(right)?))
}
fn load_marker(path: &Path) -> Result<MigrationMarker, String> {
let bytes = std::fs::read(path)
.map_err(|error| format!("could not read blob migration marker: {error}"))?;
serde_json::from_slice(&bytes)
.map_err(|error| format!("could not decode blob migration marker: {error}"))
}
fn save_marker(path: &Path, marker: &MigrationMarker) -> Result<(), String> {
let mut bytes = serde_json::to_vec_pretty(marker)
.map_err(|error| format!("could not encode blob migration marker: {error}"))?;
bytes.push(b'\n');
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, bytes)
.map_err(|error| format!("could not write blob migration marker: {error}"))?;
std::fs::rename(&temporary, path)
.map_err(|error| format!("could not install blob migration marker: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn migration_is_verified_restart_safe_and_idempotent() {
let temp = tempfile::tempdir().unwrap();
let legacy_root = temp.path().join("old.blobs");
let legacy = BlobStore::open(&legacy_root).await.unwrap();
let first = legacy
.put_bytes_named(b"first legacy blob".to_vec(), b"old/first")
.await
.unwrap();
let second = legacy
.put_bytes_named(b"second legacy blob".to_vec(), b"old/second")
.await
.unwrap();
legacy.shutdown().await.unwrap();
let mut expected = vec![first, second];
expected.sort_unstable();
let scope = BlobScope::new([0x81; 32]);
let custody = ResidentBlobCustody::open(
&temp.path().join("data"),
&ResidentContentSettings {
gc_interval_seconds: 60,
..ResidentContentSettings::default()
},
)
.await
.unwrap();
assert_eq!(
custody
.migrate_legacy_store(&legacy_root, scope, LEGACY_PERSONAL_LEASE)
.await
.unwrap(),
LegacyBlobMigration::Copied { blobs: 2 }
);
assert_eq!(
custody.blobs().leased_hashes(scope).await.unwrap(),
expected
);
let marker = custody.marker_path(
&normalized_path(&legacy_root).unwrap(),
scope,
LEGACY_PERSONAL_LEASE,
);
std::fs::remove_file(marker).unwrap();
assert_eq!(
custody
.migrate_legacy_store(&legacy_root, scope, LEGACY_PERSONAL_LEASE)
.await
.unwrap(),
LegacyBlobMigration::Copied { blobs: 2 }
);
assert_eq!(
custody
.migrate_legacy_store(&legacy_root, scope, LEGACY_PERSONAL_LEASE)
.await
.unwrap(),
LegacyBlobMigration::AlreadyComplete { blobs: 2 }
);
drop(custody.blobs());
custody.shutdown().await.unwrap();
let reopened = ResidentBlobCustody::open(
&temp.path().join("data"),
&ResidentContentSettings {
gc_interval_seconds: 60,
..ResidentContentSettings::default()
},
)
.await
.unwrap();
assert_eq!(reopened.bind_scope(scope).await.unwrap(), 2);
assert_eq!(
reopened.blobs().leased_hashes(scope).await.unwrap(),
expected
);
drop(reopened.blobs());
reopened.shutdown().await.unwrap();
}
#[tokio::test]
async fn absent_source_does_not_claim_completion() {
let temp = tempfile::tempdir().unwrap();
let custody = ResidentBlobCustody::open(
&temp.path().join("data"),
&ResidentContentSettings::default(),
)
.await
.unwrap();
assert_eq!(
custody
.migrate_legacy_store(
&temp.path().join("missing"),
BlobScope::new([0x91; 32]),
LEGACY_KNOT_LEASE,
)
.await
.unwrap(),
LegacyBlobMigration::SourceAbsent
);
assert!(!custody.migration_root.exists());
custody.shutdown().await.unwrap();
}
}