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 pub nostr_pubkey: Option<void_crypto::NostrPubKey>,
81}
82
83#[derive(Clone, Debug)]
85pub struct RepoMeta {
86 pub id: Option<String>,
88 pub name: Option<String>,
90 pub secret: RepoSecret,
92}
93
94#[derive(Clone, Debug)]
98pub struct SealConfig {
99 pub compression_level: i32,
101 pub target_shard_size: u64,
103 pub max_shard_size: u64,
105 pub padding: PaddingStrategy,
107 pub mmap_threshold: u64,
109 pub max_split_rounds: u8,
111}
112
113#[derive(Clone, Debug)]
115pub struct NetworkConfig {
116 pub ipfs: Option<IpfsConfig>,
118 pub tor: Option<TorConfig>,
120 pub remotes: HashMap<String, RemoteConfig>,
122}
123
124impl Default for SealConfig {
129 fn default() -> Self {
130 Self {
131 compression_level: 3,
132 target_shard_size: 100_000,
133 max_shard_size: 150_000,
134 padding: PaddingStrategy::default(), mmap_threshold: 5_000_000,
136 max_split_rounds: 4,
137 }
138 }
139}
140
141impl Default for NetworkConfig {
142 fn default() -> Self {
143 Self {
144 ipfs: None,
145 tor: None,
146 remotes: HashMap::new(),
147 }
148 }
149}
150
151impl From<&CoreConfig> for SealConfig {
156 fn from(core: &CoreConfig) -> Self {
157 let target = core.target_shard_size as u64;
158 Self {
159 compression_level: core.compression_level as i32,
160 target_shard_size: target,
161 max_shard_size: if target > 0 {
163 target + target / 2
164 } else {
165 150_000
166 },
167 ..Self::default()
168 }
169 }
170}
171
172impl From<&Config> for SealConfig {
173 fn from(cfg: &Config) -> Self {
174 SealConfig::from(&cfg.core)
175 }
176}
177
178impl From<&Config> for NetworkConfig {
179 fn from(cfg: &Config) -> Self {
180 Self {
181 ipfs: cfg.ipfs.clone(),
182 tor: cfg.tor.clone(),
183 remotes: cfg.remote.clone(),
184 }
185 }
186}
187
188impl From<&Config> for RepoMeta {
189 fn from(cfg: &Config) -> Self {
195 let secret = cfg
196 .repo_secret
197 .as_deref()
198 .and_then(|hex| hex::decode(hex.trim()).ok())
199 .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok())
200 .map(RepoSecret::new)
201 .unwrap_or_else(|| RepoSecret::new([0u8; 32]));
202
203 Self {
204 id: cfg.repo_id.clone(),
205 name: cfg.repo_name.clone(),
206 secret,
207 }
208 }
209}
210
211impl VoidContext {
216 pub fn open_store(&self) -> Result<FsStore> {
218 util::open_store(&self.paths.void_dir)
219 }
220
221 pub fn fetch_object<B: void_crypto::EncryptedBlob>(
226 &self,
227 remote: &dyn RemoteStore,
228 cid: &crate::cid::VoidCid,
229 ) -> Result<B> {
230 let store = self.open_store()?;
231 match store.get_blob::<B>(cid) {
232 Ok(blob) => Ok(blob),
233 Err(crate::VoidError::NotFound(_)) => {
234 let data = remote.fetch_raw(cid)?;
235 let blob = B::from_bytes(data);
236 store.put_blob(&blob)?;
237 Ok(blob)
238 }
239 Err(err) => Err(err),
240 }
241 }
242
243 pub fn push_object<B: void_crypto::EncryptedBlob>(
247 &self,
248 remote: &dyn RemoteStore,
249 blob: &B,
250 ) -> Result<crate::cid::VoidCid> {
251 remote.push_raw(blob.as_bytes())
252 }
253
254 pub fn has_identity(&self) -> bool {
256 self.crypto.signing_key.is_some()
257 }
258
259 pub fn nostr_pubkey(&self) -> Option<&void_crypto::NostrPubKey> {
261 self.crypto.nostr_pubkey.as_ref()
262 }
263
264 pub fn resolve_head(&self) -> Result<Option<void_crypto::CommitCid>> {
266 crate::refs::resolve_head_split(&self.paths.workspace_dir, &self.paths.void_dir)
267 }
268
269 pub fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
271 Ok(self.crypto.vault.decrypt_blob(ciphertext, aad)?)
272 }
273
274 pub fn decrypt_raw(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
276 Ok(self.crypto.vault.decrypt_blob_raw(ciphertext, aad)?)
277 }
278
279 pub fn open_commit(
281 &self,
282 blob: &EncryptedCommit,
283 ) -> Result<(crate::crypto::CommitPlaintext, crate::crypto::CommitReader)> {
284 crate::crypto::CommitReader::open_with_vault(&self.crypto.vault, blob)
285 }
286
287 pub fn open_and_parse_commit(
289 &self,
290 blob: &EncryptedCommit,
291 ) -> Result<(crate::metadata::Commit, crate::crypto::CommitReader)> {
292 let (plaintext, reader) = self.open_commit(blob)?;
293 let commit = plaintext.parse()?;
294 Ok((commit, reader))
295 }
296
297 pub fn load_commit(
302 &self,
303 store: &impl ObjectStoreExt,
304 commit_cid: &crate::cid::VoidCid,
305 ) -> Result<(crate::metadata::Commit, crate::crypto::CommitReader)> {
306 let encrypted: EncryptedCommit = store.get_blob(commit_cid)?;
307 self.open_and_parse_commit(&encrypted)
308 }
309
310 pub fn load_commit_with_metadata(
315 &self,
316 store: &impl ObjectStoreExt,
317 commit_cid: &crate::cid::VoidCid,
318 ) -> Result<(crate::metadata::Commit, crate::metadata::MetadataBundle, crate::crypto::CommitReader)> {
319 let (commit, reader) = self.load_commit(store, commit_cid)?;
320 let metadata_cid = crate::cid::VoidCid::from_bytes(commit.metadata_bundle.as_bytes())?;
321 let metadata_encrypted: EncryptedMetadata = store.get_blob(&metadata_cid)?;
322 let metadata: crate::metadata::MetadataBundle = reader.decrypt_metadata(&metadata_encrypted)?;
323 Ok((commit, metadata, reader))
324 }
325
326 pub fn load_manifest(
330 &self,
331 store: &impl ObjectStoreExt,
332 commit: &crate::metadata::Commit,
333 reader: &crate::crypto::CommitReader,
334 ) -> Result<Option<crate::metadata::manifest_tree::TreeManifest>> {
335 crate::metadata::manifest_tree::TreeManifest::from_commit(store, commit, reader)
336 }
337
338 pub fn read_file_from_commit(
345 &self,
346 store: &impl ObjectStoreExt,
347 commit: &crate::metadata::Commit,
348 reader: &crate::crypto::CommitReader,
349 ancestor_keys: &[void_crypto::ContentKey],
350 path: &str,
351 ) -> Result<crate::FileContent> {
352 let manifest = self.load_manifest(store, commit, reader)?
353 .ok_or_else(|| crate::VoidError::NotFound(
354 format!("no manifest in commit for file '{}'", path),
355 ))?;
356
357 let entry = manifest.lookup(path)?;
359 let shards = manifest.shards();
360
361 if entry.shard_count > 1 {
362 let start = entry.shard_index as usize;
364 let end = start + entry.shard_count as usize;
365 let mut content = Vec::with_capacity(entry.length as usize);
366
367 for shard_idx in start..end {
368 let shard_ref = shards.get(shard_idx)
369 .ok_or_else(|| crate::VoidError::Shard(
370 format!("chunk shard index {} out of range for '{}'", shard_idx, path),
371 ))?;
372
373 let shard_cid = crate::cid::VoidCid::from_bytes(shard_ref.cid.as_bytes())?;
374 let shard_encrypted: EncryptedShard = store.get_blob(&shard_cid)?;
375 let decrypted = reader.decrypt_shard(&shard_encrypted, shard_ref.wrapped_key.as_ref(), ancestor_keys)?;
376 let body = decrypted.decompress()?;
377 content.extend_from_slice(body.as_bytes());
378 }
379
380 Ok(content)
381 } else {
382 let shard_ref = shards.get(entry.shard_index as usize)
384 .ok_or_else(|| crate::VoidError::Shard(
385 format!("shard_index {} out of range", entry.shard_index),
386 ))?;
387
388 let shard_cid = crate::cid::VoidCid::from_bytes(shard_ref.cid.as_bytes())?;
389 let shard_encrypted: EncryptedShard = store.get_blob(&shard_cid)?;
390 let decrypted = reader.decrypt_shard(&shard_encrypted, shard_ref.wrapped_key.as_ref(), ancestor_keys)?;
391 let body = decrypted.decompress()?;
392 body.read_file(&entry)
393 }
394 }
395
396 pub fn decrypt_parse<T>(&self, ciphertext: &[u8], aad: &[u8]) -> Result<T>
398 where
399 T: serde::de::DeserializeOwned,
400 {
401 let plaintext = self.decrypt(ciphertext, aad)?;
402 ciborium::from_reader(&plaintext[..])
403 .map_err(|e| crate::VoidError::Serialization(format!("CBOR deserialization failed: {e}")))
404 }
405}
406
407impl VoidContext {
412 pub fn headless(
417 void_dir: impl AsRef<Path>,
418 vault: Arc<KeyVault>,
419 epoch: u64,
420 ) -> Result<Self> {
421 let void_dir = to_utf8(void_dir)?;
422 let root = void_dir
423 .parent()
424 .map(camino::Utf8Path::to_path_buf)
425 .unwrap_or_else(|| void_dir.clone());
426 let workspace_dir = void_dir.clone();
427
428 Ok(Self {
429 paths: RepoPaths {
430 root,
431 void_dir,
432 workspace_dir,
433 },
434 crypto: CryptoContext {
435 vault,
436 epoch,
437 signing_key: None,
438 nostr_pubkey: None,
439 },
440 repo: RepoMeta {
441 id: None,
442 name: None,
443 secret: RepoSecret::new([0u8; 32]),
444 },
445 seal: SealConfig::default(),
446 network: NetworkConfig::default(),
447 user: UserConfig::default(),
448 })
449 }
450
451 pub fn with_workspace(
458 root: impl AsRef<Path>,
459 void_dir: impl AsRef<Path>,
460 workspace_dir: impl AsRef<Path>,
461 vault: Arc<KeyVault>,
462 epoch: u64,
463 ) -> Result<Self> {
464 Ok(Self {
465 paths: RepoPaths {
466 root: to_utf8(root)?,
467 void_dir: to_utf8(void_dir)?,
468 workspace_dir: to_utf8(workspace_dir)?,
469 },
470 crypto: CryptoContext {
471 vault,
472 epoch,
473 signing_key: None,
474 nostr_pubkey: None,
475 },
476 repo: RepoMeta {
477 id: None,
478 name: None,
479 secret: RepoSecret::new([0u8; 32]),
480 },
481 seal: SealConfig::default(),
482 network: NetworkConfig::default(),
483 user: UserConfig::default(),
484 })
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use void_crypto::KeyVault;
492
493 fn test_context() -> VoidContext {
495 let vault = Arc::new(KeyVault::new([0x42u8; 32]).unwrap());
496 VoidContext {
497 paths: RepoPaths {
498 root: Utf8PathBuf::from("/tmp/repo"),
499 void_dir: Utf8PathBuf::from("/tmp/repo/.void"),
500 workspace_dir: Utf8PathBuf::from("/tmp/repo/.void"),
501 },
502 crypto: CryptoContext {
503 vault,
504 epoch: 0,
505 signing_key: None,
506 nostr_pubkey: None,
507 },
508 repo: RepoMeta {
509 id: Some("test-id".into()),
510 name: Some("test-repo".into()),
511 secret: RepoSecret::new([0xAA; 32]),
512 },
513 seal: SealConfig::default(),
514 network: NetworkConfig::default(),
515 user: UserConfig::default(),
516 }
517 }
518
519 #[test]
520 fn seal_config_default_values() {
521 let sc = SealConfig::default();
522 assert_eq!(sc.compression_level, 3);
523 assert_eq!(sc.target_shard_size, 100_000);
524 assert_eq!(sc.max_shard_size, 150_000);
525 assert_eq!(sc.mmap_threshold, 5_000_000);
526 assert_eq!(sc.max_split_rounds, 4);
527 }
528
529 #[test]
530 fn seal_config_from_core_config() {
531 let core = CoreConfig {
532 compression_level: 10,
533 target_shard_size: 200_000,
534 };
535 let sc = SealConfig::from(&core);
536 assert_eq!(sc.compression_level, 10);
537 assert_eq!(sc.target_shard_size, 200_000);
538 assert_eq!(sc.max_shard_size, 300_000); }
540
541 #[test]
542 fn network_config_from_config() {
543 let mut cfg = Config::default();
544 cfg.remote.insert(
545 "origin".into(),
546 RemoteConfig {
547 url: Some("https://example.com".into()),
548 ..Default::default()
549 },
550 );
551 let nc = NetworkConfig::from(&cfg);
552 assert!(nc.remotes.contains_key("origin"));
553 assert!(nc.ipfs.is_none());
554 }
555
556 #[test]
557 fn repo_meta_parses_hex_secret() {
558 let mut cfg = Config::default();
559 cfg.repo_secret = Some(hex::encode([0xBB; 32]));
560 cfg.repo_id = Some("my-repo-id".into());
561
562 let meta = RepoMeta::from(&cfg);
563 assert_eq!(meta.id.as_deref(), Some("my-repo-id"));
564 assert_eq!(*meta.secret.as_bytes(), [0xBB; 32]);
565 }
566
567 #[test]
568 fn repo_meta_falls_back_to_zeroed_secret() {
569 let cfg = Config::default();
570 let meta = RepoMeta::from(&cfg);
571 assert_eq!(*meta.secret.as_bytes(), [0u8; 32]);
572 }
573
574}