Skip to main content

void_core/support/
void_context.rs

1//! Application-level dependency injection container.
2//!
3//! `VoidContext` groups all the state a CLI invocation needs into a single
4//! value created once at startup.  Subsystem structs (`RepoPaths`,
5//! `CryptoContext`, `RepoMeta`, `SealConfig`, `NetworkConfig`) keep related
6//! config together so consumers can accept only the slice they need.
7//!
8//! This module is purely additive — existing APIs and callers are unchanged.
9//! Future waves will migrate `*Options` structs to accept `&VoidContext`
10//! instead of `RepoContext` + ad-hoc config values.
11
12use 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// ---------------------------------------------------------------------------
28// Top-level container
29// ---------------------------------------------------------------------------
30
31/// Application-level DI container for a single CLI invocation.
32///
33/// Created once (via `build_void_context` in the CLI crate) and threaded
34/// through the call stack.  Subsystems can be borrowed independently.
35#[derive(Clone, Debug)]
36pub struct VoidContext {
37    /// Where the repository lives on disk.
38    pub paths: RepoPaths,
39    /// Encryption vault + identity keys.
40    pub crypto: CryptoContext,
41    /// Repository identity (id, name, secret).
42    pub repo: RepoMeta,
43    /// Compression / shard sizing settings.
44    pub seal: SealConfig,
45    /// Transport configuration (IPFS, Tor, remotes).
46    pub network: NetworkConfig,
47    /// Author identity (name, email).
48    pub user: UserConfig,
49}
50
51// ---------------------------------------------------------------------------
52// Subsystem structs
53// ---------------------------------------------------------------------------
54
55/// Filesystem paths for the repository.
56#[derive(Clone, Debug)]
57pub struct RepoPaths {
58    /// Working-tree root (parent of `.void`).
59    pub root: Utf8PathBuf,
60    /// Absolute path to the `.void` directory.
61    pub void_dir: Utf8PathBuf,
62    /// Per-workspace state dir.  Equals `void_dir` for the main workspace;
63    /// `.void/worktrees/{name}/` for linked worktrees.
64    pub workspace_dir: Utf8PathBuf,
65}
66
67/// Encryption state needed for repository operations.
68#[derive(Clone, Debug)]
69pub struct CryptoContext {
70    /// Opaque custodian of root key material.
71    pub vault: Arc<KeyVault>,
72    /// Key epoch (0 = legacy single-key, >0 = epoch-based).
73    pub epoch: u64,
74    /// Ed25519 signing key (optional — not all operations need it).
75    /// Wrapped in `Arc` because `ed25519_dalek::SigningKey` is `!Clone`.
76    pub signing_key: Option<Arc<ed25519_dalek::SigningKey>>,
77}
78
79/// Repository identity metadata.
80#[derive(Clone, Debug)]
81pub struct RepoMeta {
82    /// Unique repository identifier from config.
83    pub id: Option<String>,
84    /// Human-readable repository name.
85    pub name: Option<String>,
86    /// Deterministic secret used for shard assignment during push/pull.
87    pub secret: RepoSecret,
88}
89
90/// Shard sealing / compression settings.
91///
92/// Extracted from `CoreConfig` + hardcoded defaults.
93#[derive(Clone, Debug)]
94pub struct SealConfig {
95    /// Zstd compression level (0 = no compression, 22 = max).
96    pub compression_level: i32,
97    /// Target shard size in bytes before padding.
98    pub target_shard_size: u64,
99    /// Hard upper bound on shard size.
100    pub max_shard_size: u64,
101    /// Padding strategy to obfuscate content sizes.
102    pub padding: PaddingStrategy,
103    /// Files larger than this are memory-mapped instead of read into RAM.
104    pub mmap_threshold: u64,
105    /// Maximum number of recursive split rounds for oversized shards.
106    pub max_split_rounds: u8,
107}
108
109/// Network / transport configuration.
110#[derive(Clone, Debug)]
111pub struct NetworkConfig {
112    /// IPFS backend settings.
113    pub ipfs: Option<IpfsConfig>,
114    /// Tor proxy / hidden-service settings.
115    pub tor: Option<TorConfig>,
116    /// Named remote endpoints.
117    pub remotes: HashMap<String, RemoteConfig>,
118}
119
120// ---------------------------------------------------------------------------
121// Defaults
122// ---------------------------------------------------------------------------
123
124impl 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(), // PowerOfTwo
131            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
147// ---------------------------------------------------------------------------
148// Config → subsystem conversions  (Track E)
149// ---------------------------------------------------------------------------
150
151impl 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            // 1.5× target, floored to the default if target is 0
158            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    /// Build `RepoMeta` from config.
186    ///
187    /// `repo_secret` is parsed from hex if present; falls back to a zeroed
188    /// secret (callers that need a real secret should use
189    /// `config::load_repo_secret` which derives from the vault).
190    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
207// ---------------------------------------------------------------------------
208// VoidContext convenience methods
209// ---------------------------------------------------------------------------
210
211impl VoidContext {
212    /// Open the object store for this repository.
213    pub fn open_store(&self) -> Result<FsStore> {
214        util::open_store(&self.paths.void_dir)
215    }
216
217    /// Fetch a typed encrypted blob, local-first with remote fallback.
218    ///
219    /// Checks the local object store first. On miss, fetches from the remote
220    /// (which handles CID verification), stores locally, and returns.
221    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    /// Push a typed encrypted blob to a remote store.
239    ///
240    /// The remote handles CID verification internally.
241    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    /// Resolve HEAD to a commit CID (handles workspace-split correctly).
250    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    /// Decrypt a blob using the vault.
255    pub fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
256        Ok(self.crypto.vault.decrypt_blob(ciphertext, aad)?)
257    }
258
259    /// Decrypt a blob into raw bytes.
260    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    /// Decrypt a commit blob and return a `CommitReader` for child objects.
265    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    /// Decrypt a commit blob, then parse it into (Commit, CommitReader).
273    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    /// Load a commit by CID from the store, decrypt, and parse.
283    ///
284    /// Collapses the common `store.get → open_with_vault → parse_commit` chain
285    /// into a single call.
286    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    /// Load a commit and its metadata by CID from the store.
296    ///
297    /// Collapses the full `store.get → decrypt commit → parse → get metadata
298    /// → decrypt metadata` chain into a single call.
299    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    /// Load a TreeManifest from a commit.
312    ///
313    /// Returns `None` if the commit has no `manifest_cid` (older commits).
314    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    /// Read a file from a commit using the TreeManifest.
324    ///
325    /// Loads the manifest, looks up the file entry, fetches and decrypts the
326    /// shard, decompresses the body, and extracts the file content.
327    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        // O(log N) lookup via binary search
341        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    /// Decrypt and parse a CBOR-serialized type.
356    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
366// ---------------------------------------------------------------------------
367// Constructors
368// ---------------------------------------------------------------------------
369
370impl VoidContext {
371    /// Create a context for headless / network-only operations.
372    ///
373    /// Sets `root` to the parent of `void_dir` (or `void_dir` itself if no parent).
374    /// Use when no workspace root is available (clone, push, pull, repair, etc.).
375    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    /// Create a context for a linked worktree.
410    ///
411    /// `workspace_dir` is the per-workspace state directory (e.g.
412    /// `.void/worktrees/{name}/`) where HEAD, index, staged blobs and
413    /// merge state live.  Shared state (objects, refs, config, keys)
414    /// is still read from `void_dir`.
415    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    /// Helper: build a minimal VoidContext for testing.
451    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); // 1.5×
495    }
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}