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    /// Nostr public key (optional — set when identity is loaded).
78    /// Stored here because the full Identity is consumed at load time,
79    /// but the daemon needs the nostr pubkey for bootstrap discovery.
80    pub nostr_pubkey: Option<void_crypto::NostrPubKey>,
81}
82
83/// Repository identity metadata.
84#[derive(Clone, Debug)]
85pub struct RepoMeta {
86    /// Unique repository identifier from config.
87    pub id: Option<String>,
88    /// Human-readable repository name.
89    pub name: Option<String>,
90    /// Deterministic secret used for shard assignment during push/pull.
91    pub secret: RepoSecret,
92}
93
94/// Shard sealing / compression settings.
95///
96/// Extracted from `CoreConfig` + hardcoded defaults.
97#[derive(Clone, Debug)]
98pub struct SealConfig {
99    /// Zstd compression level (0 = no compression, 22 = max).
100    pub compression_level: i32,
101    /// Target shard size in bytes before padding.
102    pub target_shard_size: u64,
103    /// Hard upper bound on shard size.
104    pub max_shard_size: u64,
105    /// Padding strategy to obfuscate content sizes.
106    pub padding: PaddingStrategy,
107    /// Files larger than this are memory-mapped instead of read into RAM.
108    pub mmap_threshold: u64,
109    /// Maximum number of recursive split rounds for oversized shards.
110    pub max_split_rounds: u8,
111}
112
113/// Network / transport configuration.
114#[derive(Clone, Debug)]
115pub struct NetworkConfig {
116    /// IPFS backend settings.
117    pub ipfs: Option<IpfsConfig>,
118    /// Tor proxy / hidden-service settings.
119    pub tor: Option<TorConfig>,
120    /// Named remote endpoints.
121    pub remotes: HashMap<String, RemoteConfig>,
122}
123
124// ---------------------------------------------------------------------------
125// Defaults
126// ---------------------------------------------------------------------------
127
128impl 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(), // PowerOfTwo
135            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
151// ---------------------------------------------------------------------------
152// Config → subsystem conversions  (Track E)
153// ---------------------------------------------------------------------------
154
155impl 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            // 1.5× target, floored to the default if target is 0
162            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    /// Build `RepoMeta` from config.
190    ///
191    /// `repo_secret` is parsed from hex if present; falls back to a zeroed
192    /// secret (callers that need a real secret should use
193    /// `config::load_repo_secret` which derives from the vault).
194    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
211// ---------------------------------------------------------------------------
212// VoidContext convenience methods
213// ---------------------------------------------------------------------------
214
215impl VoidContext {
216    /// Open the object store for this repository.
217    pub fn open_store(&self) -> Result<FsStore> {
218        util::open_store(&self.paths.void_dir)
219    }
220
221    /// Fetch a typed encrypted blob, local-first with remote fallback.
222    ///
223    /// Checks the local object store first. On miss, fetches raw bytes from
224    /// the remote (which handles CID verification), stores locally, and wraps.
225    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    /// Push a typed encrypted blob to a remote store.
244    ///
245    /// Extracts raw bytes, pushes via remote (which handles CID verification).
246    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    /// Check if a signing identity is loaded.
255    pub fn has_identity(&self) -> bool {
256        self.crypto.signing_key.is_some()
257    }
258
259    /// Get the nostr public key (if identity was loaded with one).
260    pub fn nostr_pubkey(&self) -> Option<&void_crypto::NostrPubKey> {
261        self.crypto.nostr_pubkey.as_ref()
262    }
263
264    /// Resolve HEAD to a commit CID (handles workspace-split correctly).
265    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    /// Decrypt a blob using the vault.
270    pub fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
271        Ok(self.crypto.vault.decrypt_blob(ciphertext, aad)?)
272    }
273
274    /// Decrypt a blob into raw bytes.
275    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    /// Decrypt a commit blob and return a `CommitReader` for child objects.
280    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    /// Decrypt a commit blob, then parse it into (Commit, CommitReader).
288    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    /// Load a commit by CID from the store, decrypt, and parse.
298    ///
299    /// Collapses the common `store.get → open_with_vault → parse_commit` chain
300    /// into a single call.
301    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    /// Load a commit and its metadata by CID from the store.
311    ///
312    /// Collapses the full `store.get → decrypt commit → parse → get metadata
313    /// → decrypt metadata` chain into a single call.
314    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    /// Load a TreeManifest from a commit.
327    ///
328    /// Returns `None` if the commit has no `manifest_cid` (older commits).
329    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    /// Read a file from a commit using the TreeManifest.
339    ///
340    /// Loads the manifest, looks up the file entry, fetches and decrypts the
341    /// shard, decompresses the body, and extracts the file content.
342    /// Handles chunked files (shard_count > 1) by fetching all chunk shards
343    /// and concatenating their content.
344    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        // O(log N) lookup via binary search
358        let entry = manifest.lookup(path)?;
359        let shards = manifest.shards();
360
361        if entry.shard_count > 1 {
362            // Chunked file: fetch all chunk shards and concatenate
363            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            // Single-shard file: existing path
383            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    /// Decrypt and parse a CBOR-serialized type.
397    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
407// ---------------------------------------------------------------------------
408// Constructors
409// ---------------------------------------------------------------------------
410
411impl VoidContext {
412    /// Create a context for headless / network-only operations.
413    ///
414    /// Sets `root` to the parent of `void_dir` (or `void_dir` itself if no parent).
415    /// Use when no workspace root is available (clone, push, pull, repair, etc.).
416    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    /// Create a context for a linked worktree.
452    ///
453    /// `workspace_dir` is the per-workspace state directory (e.g.
454    /// `.void/worktrees/{name}/`) where HEAD, index, staged blobs and
455    /// merge state live.  Shared state (objects, refs, config, keys)
456    /// is still read from `void_dir`.
457    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    /// Helper: build a minimal VoidContext for testing.
494    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); // 1.5×
539    }
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}