1use std::collections::HashMap;
13use std::sync::Arc;
14
15use camino::Utf8PathBuf;
16use void_crypto::{EncryptedCommit, EncryptedMetadata, EncryptedShard, KeyVault, RepoSecret};
17
18use std::path::Path;
19
20use crate::shard::PaddingStrategy;
21use crate::store::{FsStore, ObjectStoreExt, RemoteStore};
22use crate::support::config::{Config, CoreConfig, IpfsConfig, RemoteConfig, TorConfig, UserConfig};
23use crate::support::util;
24use crate::support::util::to_utf8;
25use crate::Result;
26
27#[derive(Clone, Debug)]
36pub struct VoidContext {
37 pub paths: RepoPaths,
39 pub crypto: CryptoContext,
41 pub repo: RepoMeta,
43 pub seal: SealConfig,
45 pub network: NetworkConfig,
47 pub user: UserConfig,
49}
50
51#[derive(Clone, Debug)]
57pub struct RepoPaths {
58 pub root: Utf8PathBuf,
60 pub void_dir: Utf8PathBuf,
62 pub workspace_dir: Utf8PathBuf,
65}
66
67#[derive(Clone, Debug)]
69pub struct CryptoContext {
70 pub vault: Arc<KeyVault>,
72 pub epoch: u64,
74 pub signing_key: Option<Arc<ed25519_dalek::SigningKey>>,
77}
78
79#[derive(Clone, Debug)]
81pub struct RepoMeta {
82 pub id: Option<String>,
84 pub name: Option<String>,
86 pub secret: RepoSecret,
88}
89
90#[derive(Clone, Debug)]
94pub struct SealConfig {
95 pub compression_level: i32,
97 pub target_shard_size: u64,
99 pub max_shard_size: u64,
101 pub padding: PaddingStrategy,
103 pub mmap_threshold: u64,
105 pub max_split_rounds: u8,
107}
108
109#[derive(Clone, Debug)]
111pub struct NetworkConfig {
112 pub ipfs: Option<IpfsConfig>,
114 pub tor: Option<TorConfig>,
116 pub remotes: HashMap<String, RemoteConfig>,
118}
119
120impl Default for SealConfig {
125 fn default() -> Self {
126 Self {
127 compression_level: 3,
128 target_shard_size: 100_000,
129 max_shard_size: 150_000,
130 padding: PaddingStrategy::default(), mmap_threshold: 5_000_000,
132 max_split_rounds: 4,
133 }
134 }
135}
136
137impl Default for NetworkConfig {
138 fn default() -> Self {
139 Self {
140 ipfs: None,
141 tor: None,
142 remotes: HashMap::new(),
143 }
144 }
145}
146
147impl From<&CoreConfig> for SealConfig {
152 fn from(core: &CoreConfig) -> Self {
153 let target = core.target_shard_size as u64;
154 Self {
155 compression_level: core.compression_level as i32,
156 target_shard_size: target,
157 max_shard_size: if target > 0 {
159 target + target / 2
160 } else {
161 150_000
162 },
163 ..Self::default()
164 }
165 }
166}
167
168impl From<&Config> for SealConfig {
169 fn from(cfg: &Config) -> Self {
170 SealConfig::from(&cfg.core)
171 }
172}
173
174impl From<&Config> for NetworkConfig {
175 fn from(cfg: &Config) -> Self {
176 Self {
177 ipfs: cfg.ipfs.clone(),
178 tor: cfg.tor.clone(),
179 remotes: cfg.remote.clone(),
180 }
181 }
182}
183
184impl From<&Config> for RepoMeta {
185 fn from(cfg: &Config) -> Self {
191 let secret = cfg
192 .repo_secret
193 .as_deref()
194 .and_then(|hex| hex::decode(hex.trim()).ok())
195 .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok())
196 .map(RepoSecret::new)
197 .unwrap_or_else(|| RepoSecret::new([0u8; 32]));
198
199 Self {
200 id: cfg.repo_id.clone(),
201 name: cfg.repo_name.clone(),
202 secret,
203 }
204 }
205}
206
207impl VoidContext {
212 pub fn open_store(&self) -> Result<FsStore> {
214 util::open_store(&self.paths.void_dir)
215 }
216
217 pub fn fetch_object<B: void_crypto::EncryptedBlob>(
222 &self,
223 remote: &impl RemoteStore,
224 cid: &crate::cid::VoidCid,
225 ) -> Result<B> {
226 let store = self.open_store()?;
227 match store.get_blob::<B>(cid) {
228 Ok(blob) => Ok(blob),
229 Err(crate::VoidError::NotFound(_)) => {
230 let blob: B = remote.fetch(cid)?;
231 store.put_blob(&blob)?;
232 Ok(blob)
233 }
234 Err(err) => Err(err),
235 }
236 }
237
238 pub fn push_object<B: void_crypto::EncryptedBlob>(
242 &self,
243 remote: &impl RemoteStore,
244 blob: &B,
245 ) -> Result<crate::cid::VoidCid> {
246 remote.push(blob)
247 }
248
249 pub fn resolve_head(&self) -> Result<Option<void_crypto::CommitCid>> {
251 crate::refs::resolve_head_split(&self.paths.workspace_dir, &self.paths.void_dir)
252 }
253
254 pub fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
256 Ok(self.crypto.vault.decrypt_blob(ciphertext, aad)?)
257 }
258
259 pub fn decrypt_raw(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
261 Ok(self.crypto.vault.decrypt_blob_raw(ciphertext, aad)?)
262 }
263
264 pub fn open_commit(
266 &self,
267 blob: &EncryptedCommit,
268 ) -> Result<(crate::crypto::CommitPlaintext, crate::crypto::CommitReader)> {
269 crate::crypto::CommitReader::open_with_vault(&self.crypto.vault, blob)
270 }
271
272 pub fn open_and_parse_commit(
274 &self,
275 blob: &EncryptedCommit,
276 ) -> Result<(crate::metadata::Commit, crate::crypto::CommitReader)> {
277 let (plaintext, reader) = self.open_commit(blob)?;
278 let commit = plaintext.parse()?;
279 Ok((commit, reader))
280 }
281
282 pub fn load_commit(
287 &self,
288 store: &impl ObjectStoreExt,
289 commit_cid: &crate::cid::VoidCid,
290 ) -> Result<(crate::metadata::Commit, crate::crypto::CommitReader)> {
291 let encrypted: EncryptedCommit = store.get_blob(commit_cid)?;
292 self.open_and_parse_commit(&encrypted)
293 }
294
295 pub fn load_commit_with_metadata(
300 &self,
301 store: &impl ObjectStoreExt,
302 commit_cid: &crate::cid::VoidCid,
303 ) -> Result<(crate::metadata::Commit, crate::metadata::MetadataBundle, crate::crypto::CommitReader)> {
304 let (commit, reader) = self.load_commit(store, commit_cid)?;
305 let metadata_cid = crate::cid::VoidCid::from_bytes(commit.metadata_bundle.as_bytes())?;
306 let metadata_encrypted: EncryptedMetadata = store.get_blob(&metadata_cid)?;
307 let metadata: crate::metadata::MetadataBundle = reader.decrypt_metadata(&metadata_encrypted)?;
308 Ok((commit, metadata, reader))
309 }
310
311 pub fn load_manifest(
315 &self,
316 store: &impl ObjectStoreExt,
317 commit: &crate::metadata::Commit,
318 reader: &crate::crypto::CommitReader,
319 ) -> Result<Option<crate::metadata::manifest_tree::TreeManifest>> {
320 crate::metadata::manifest_tree::TreeManifest::from_commit(store, commit, reader)
321 }
322
323 pub fn read_file_from_commit(
328 &self,
329 store: &impl ObjectStoreExt,
330 commit: &crate::metadata::Commit,
331 reader: &crate::crypto::CommitReader,
332 ancestor_keys: &[void_crypto::ContentKey],
333 path: &str,
334 ) -> Result<crate::FileContent> {
335 let manifest = self.load_manifest(store, commit, reader)?
336 .ok_or_else(|| crate::VoidError::NotFound(
337 format!("no manifest in commit for file '{}'", path),
338 ))?;
339
340 let entry = manifest.lookup(path)?;
342
343 let shard_ref = manifest.shards().get(entry.shard_index as usize)
344 .ok_or_else(|| crate::VoidError::Shard(
345 format!("shard_index {} out of range", entry.shard_index),
346 ))?;
347
348 let shard_cid = crate::cid::VoidCid::from_bytes(shard_ref.cid.as_bytes())?;
349 let shard_encrypted: EncryptedShard = store.get_blob(&shard_cid)?;
350 let decrypted = reader.decrypt_shard(&shard_encrypted, shard_ref.wrapped_key.as_ref(), ancestor_keys)?;
351 let body = decrypted.decompress()?;
352 body.read_file(&entry)
353 }
354
355 pub fn decrypt_parse<T>(&self, ciphertext: &[u8], aad: &[u8]) -> Result<T>
357 where
358 T: serde::de::DeserializeOwned,
359 {
360 let plaintext = self.decrypt(ciphertext, aad)?;
361 ciborium::from_reader(&plaintext[..])
362 .map_err(|e| crate::VoidError::Serialization(format!("CBOR deserialization failed: {e}")))
363 }
364}
365
366impl VoidContext {
371 pub fn headless(
376 void_dir: impl AsRef<Path>,
377 vault: Arc<KeyVault>,
378 epoch: u64,
379 ) -> Result<Self> {
380 let void_dir = to_utf8(void_dir)?;
381 let root = void_dir
382 .parent()
383 .map(camino::Utf8Path::to_path_buf)
384 .unwrap_or_else(|| void_dir.clone());
385 let workspace_dir = void_dir.clone();
386
387 Ok(Self {
388 paths: RepoPaths {
389 root,
390 void_dir,
391 workspace_dir,
392 },
393 crypto: CryptoContext {
394 vault,
395 epoch,
396 signing_key: None,
397 },
398 repo: RepoMeta {
399 id: None,
400 name: None,
401 secret: RepoSecret::new([0u8; 32]),
402 },
403 seal: SealConfig::default(),
404 network: NetworkConfig::default(),
405 user: UserConfig::default(),
406 })
407 }
408
409 pub fn with_workspace(
416 root: impl AsRef<Path>,
417 void_dir: impl AsRef<Path>,
418 workspace_dir: impl AsRef<Path>,
419 vault: Arc<KeyVault>,
420 epoch: u64,
421 ) -> Result<Self> {
422 Ok(Self {
423 paths: RepoPaths {
424 root: to_utf8(root)?,
425 void_dir: to_utf8(void_dir)?,
426 workspace_dir: to_utf8(workspace_dir)?,
427 },
428 crypto: CryptoContext {
429 vault,
430 epoch,
431 signing_key: None,
432 },
433 repo: RepoMeta {
434 id: None,
435 name: None,
436 secret: RepoSecret::new([0u8; 32]),
437 },
438 seal: SealConfig::default(),
439 network: NetworkConfig::default(),
440 user: UserConfig::default(),
441 })
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use void_crypto::KeyVault;
449
450 fn test_context() -> VoidContext {
452 let vault = Arc::new(KeyVault::new([0x42u8; 32]).unwrap());
453 VoidContext {
454 paths: RepoPaths {
455 root: Utf8PathBuf::from("/tmp/repo"),
456 void_dir: Utf8PathBuf::from("/tmp/repo/.void"),
457 workspace_dir: Utf8PathBuf::from("/tmp/repo/.void"),
458 },
459 crypto: CryptoContext {
460 vault,
461 epoch: 0,
462 signing_key: None,
463 },
464 repo: RepoMeta {
465 id: Some("test-id".into()),
466 name: Some("test-repo".into()),
467 secret: RepoSecret::new([0xAA; 32]),
468 },
469 seal: SealConfig::default(),
470 network: NetworkConfig::default(),
471 user: UserConfig::default(),
472 }
473 }
474
475 #[test]
476 fn seal_config_default_values() {
477 let sc = SealConfig::default();
478 assert_eq!(sc.compression_level, 3);
479 assert_eq!(sc.target_shard_size, 100_000);
480 assert_eq!(sc.max_shard_size, 150_000);
481 assert_eq!(sc.mmap_threshold, 5_000_000);
482 assert_eq!(sc.max_split_rounds, 4);
483 }
484
485 #[test]
486 fn seal_config_from_core_config() {
487 let core = CoreConfig {
488 compression_level: 10,
489 target_shard_size: 200_000,
490 };
491 let sc = SealConfig::from(&core);
492 assert_eq!(sc.compression_level, 10);
493 assert_eq!(sc.target_shard_size, 200_000);
494 assert_eq!(sc.max_shard_size, 300_000); }
496
497 #[test]
498 fn network_config_from_config() {
499 let mut cfg = Config::default();
500 cfg.remote.insert(
501 "origin".into(),
502 RemoteConfig {
503 url: Some("https://example.com".into()),
504 ..Default::default()
505 },
506 );
507 let nc = NetworkConfig::from(&cfg);
508 assert!(nc.remotes.contains_key("origin"));
509 assert!(nc.ipfs.is_none());
510 }
511
512 #[test]
513 fn repo_meta_parses_hex_secret() {
514 let mut cfg = Config::default();
515 cfg.repo_secret = Some(hex::encode([0xBB; 32]));
516 cfg.repo_id = Some("my-repo-id".into());
517
518 let meta = RepoMeta::from(&cfg);
519 assert_eq!(meta.id.as_deref(), Some("my-repo-id"));
520 assert_eq!(*meta.secret.as_bytes(), [0xBB; 32]);
521 }
522
523 #[test]
524 fn repo_meta_falls_back_to_zeroed_secret() {
525 let cfg = Config::default();
526 let meta = RepoMeta::from(&cfg);
527 assert_eq!(*meta.secret.as_bytes(), [0u8; 32]);
528 }
529
530}