evault-core 0.1.0

Core types, traits, and services for evault.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! [`RegistryService`] — the orchestration layer over the storage traits.
//!
//! `RegistryService` is the single entry point that the TUI and CLI use to
//! create, update, link, and delete variables. It composes a
//! [`MetadataStore`], a [`SecretStore`], an [`AuditSink`], a [`Clock`], and
//! an [`IdGenerator`] through generic type parameters — so the service is
//! testable with in-memory backends and deployable against `SQLCipher` + the
//! OS keyring without any code change.
//!
//! The business rules enforced here are:
//! - **One namespace per name**: creating a variable whose name already
//!   exists fails with [`MetadataError::DuplicateName`].
//! - **Two-tier value storage**: [`VarKind::Plain`] values go to the metadata
//!   store, [`VarKind::Secret`] values go to the secret store. The same
//!   service method (`update_value`) routes both cases.
//! - **Idempotent delete**: deleting an absent variable is `Ok(())`.
//! - **Audit-on-mutation**: every state-changing call emits an
//!   [`AuditEntry`] timestamped by the injected [`Clock`].

use crate::crypto::{ExposeSecret, SecretString};
use crate::error::{CoreError, MetadataError};
use crate::model::{
    AuditAction, AuditEntry, AuditId, Group, Profile, Project, ProjectId, ProjectVar, Var,
    VarFilter, VarId, VarKind,
};
use crate::traits::{
    AuditSink, Clock, IdGenerator, MetadataStore, SecretStore, SystemClock, UuidV4IdGenerator,
};

/// Composed registry orchestration.
///
/// Generic over the five infrastructure traits so tests can substitute fast,
/// deterministic backends; production binaries wire the `SQLCipher` /
/// `OsKeyring` / `SystemClock` / `UuidV4IdGenerator` quartet.
///
/// See `evault-store-memory/tests/registry_service.rs` for a full example
/// exercising the public API with the in-memory backends.
pub struct RegistryService<M, S, A, C, I>
where
    M: MetadataStore,
    S: SecretStore,
    A: AuditSink,
    C: Clock,
    I: IdGenerator,
{
    metadata: M,
    secrets: S,
    audit: A,
    clock: C,
    id_gen: I,
}

impl<M, S, A> RegistryService<M, S, A, SystemClock, UuidV4IdGenerator>
where
    M: MetadataStore,
    S: SecretStore,
    A: AuditSink,
{
    /// Construct a registry with the default real-time clock and v4 UUID
    /// generator.
    ///
    /// Convenience wrapper around [`Self::new`] for production wiring.
    pub const fn with_defaults(metadata: M, secrets: S, audit: A) -> Self {
        Self::new(metadata, secrets, audit, SystemClock, UuidV4IdGenerator)
    }
}

impl<M, S, A, C, I> RegistryService<M, S, A, C, I>
where
    M: MetadataStore,
    S: SecretStore,
    A: AuditSink,
    C: Clock,
    I: IdGenerator,
{
    /// Construct a registry from its trait dependencies.
    pub const fn new(metadata: M, secrets: S, audit: A, clock: C, id_gen: I) -> Self {
        Self {
            metadata,
            secrets,
            audit,
            clock,
            id_gen,
        }
    }

    // -------------------------------------------------------------------
    // Variables
    // -------------------------------------------------------------------

    /// Create a new variable, route its value to the correct storage tier,
    /// and emit an audit entry.
    ///
    /// The name is validated, then uniqueness is checked. If both succeed,
    /// the variable's id is generated through the injected
    /// [`IdGenerator`] and its timestamps through the injected [`Clock`].
    ///
    /// # Atomicity (v1)
    /// The implementation performs a best-effort compensation on
    /// value-tier failure: if the metadata row was written and the value
    /// write then fails, the metadata row is rolled back so the name is
    /// not permanently reserved. A failure of the audit append after a
    /// successful create is **not** compensated — the variable exists,
    /// only the audit row is missing — and surfaces as
    /// [`CoreError::Metadata`]. Full cross-tier atomic commit is on the
    /// roadmap for the `SQLCipher` backend.
    ///
    /// # Errors
    /// Returns [`CoreError::Metadata`] for validation, uniqueness, or
    /// storage failures; [`CoreError::Secret`] if writing to the secret
    /// store fails. Empty `value` is rejected with
    /// [`MetadataError::Invalid`].
    pub fn create_var(
        &self,
        name: &str,
        group: Group,
        kind: VarKind,
        value: SecretString,
    ) -> Result<VarId, CoreError> {
        Var::validate_name(name)?;
        if value.expose_secret().is_empty() {
            return Err(MetadataError::Invalid("value is empty".into()).into());
        }
        if self.metadata.find_var_by_name(name)?.is_some() {
            return Err(MetadataError::DuplicateName(name.to_owned()).into());
        }

        let id = VarId::from_uuid(self.id_gen.next());
        let now = self.clock.now();
        let length = value.expose_secret().len();
        let var = Var::from_trusted_parts(
            id,
            name.to_owned(),
            group,
            kind,
            Vec::new(),
            length,
            now,
            now,
        );
        self.metadata.upsert_var(&var)?;

        // Route the value to the correct tier. On failure, roll back the
        // metadata row we just wrote so the name does not get permanently
        // reserved on a half-created variable.
        let value_write: Result<(), CoreError> = match kind {
            VarKind::Plain => self
                .metadata
                .set_plain_value(id, value.expose_secret())
                .map_err(Into::into),
            VarKind::Secret => self.secrets.put(id, value).map_err(Into::into),
        };
        if let Err(e) = value_write {
            // Best-effort rollback. The metadata.delete_var call also
            // cascades plain values + links, which is harmless here.
            let _ = self.metadata.delete_var(id);
            return Err(e);
        }

        self.audit_var(id, AuditAction::Created)?;
        Ok(id)
    }

    /// Update the value of an existing variable.
    ///
    /// Routes the new value to the same storage tier the variable was
    /// created with. The metadata record's `length` is refreshed but the
    /// `kind` is preserved.
    ///
    /// The value-tier write happens **before** the metadata length update
    /// so that a partial failure cannot leave metadata advertising a
    /// length that no value in storage actually has.
    ///
    /// # Atomicity (v1)
    /// The kind on the metadata record is trusted to route the write. If
    /// the metadata is corrupted with a kind that disagrees with the
    /// actual stored value, this method will route to the corrupted
    /// kind's tier and may leak the prior value to the opposite tier.
    /// Subsequent `get_value` calls then surface
    /// [`CoreError::TierMismatch`]. Hardening this path (probe-and-clear
    /// the opposite tier defensively) is scheduled for the v1.1 `SQLCipher`
    /// landing.
    ///
    /// A failure of the audit append after a successful value write is
    /// reported as an error but the value is persisted; same v1 limitation
    /// as documented on [`Self::create_var`].
    ///
    /// # Errors
    /// Returns [`MetadataError::VarNotFound`] if the variable does not
    /// exist, [`MetadataError::Invalid`] if the value is empty, or any
    /// error from the underlying storage tier.
    pub fn update_value(&self, id: VarId, value: SecretString) -> Result<(), CoreError> {
        if value.expose_secret().is_empty() {
            return Err(MetadataError::Invalid("value is empty".into()).into());
        }
        let Some(mut var) = self.metadata.get_var(id)? else {
            return Err(MetadataError::VarNotFound(id).into());
        };
        let length = value.expose_secret().len();

        // Write the value FIRST. If this fails, no metadata change has
        // landed and the stored state still describes the prior value.
        match var.kind() {
            VarKind::Plain => self.metadata.set_plain_value(id, value.expose_secret())?,
            VarKind::Secret => self.secrets.put(id, value)?,
        }
        // Now refresh the length and persist.
        var.set_length(length);
        self.metadata.upsert_var(&var)?;
        self.audit_var(id, AuditAction::Updated)?;
        Ok(())
    }

    /// Retrieve the value of a variable regardless of storage tier.
    ///
    /// If the metadata record claims one [`VarKind`] but the value is
    /// found in the opposite tier, this method returns
    /// [`CoreError::TierMismatch`] rather than silently treating the
    /// situation as "value missing". This surfaces corruption that
    /// bypassed the service layer's normal routing.
    ///
    /// # Errors
    /// Returns any propagated storage error.
    /// Returns [`CoreError::TierMismatch`] when the variable's metadata
    /// kind disagrees with where the value actually lives (corruption
    /// detection). Returns `Ok(None)` if the variable does not exist or
    /// has no value in either tier.
    pub fn get_value(&self, id: VarId) -> Result<Option<SecretString>, CoreError> {
        let Some(var) = self.metadata.get_var(id)? else {
            return Ok(None);
        };
        let kind = var.kind();
        match kind {
            VarKind::Plain => {
                if let Some(plain) = self.metadata.get_plain_value(id)? {
                    return Ok(Some(SecretString::from(plain)));
                }
                // Plain-tier miss: probe the opposite tier to surface
                // corruption before returning the indistinguishable None.
                if self.secrets.get(id)?.is_some() {
                    return Err(CoreError::TierMismatch {
                        id,
                        expected: VarKind::Plain,
                        found: VarKind::Secret,
                    });
                }
                Ok(None)
            }
            VarKind::Secret => {
                if let Some(value) = self.secrets.get(id)? {
                    return Ok(Some(value));
                }
                if self.metadata.get_plain_value(id)?.is_some() {
                    return Err(CoreError::TierMismatch {
                        id,
                        expected: VarKind::Secret,
                        found: VarKind::Plain,
                    });
                }
                Ok(None)
            }
        }
    }

    /// Fetch a variable by id.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn get_var(&self, id: VarId) -> Result<Option<Var>, CoreError> {
        Ok(self.metadata.get_var(id)?)
    }

    /// Fetch a variable by name.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn find_var_by_name(&self, name: &str) -> Result<Option<Var>, CoreError> {
        Ok(self.metadata.find_var_by_name(name)?)
    }

    /// List every variable matching the supplied filter.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn list_vars(&self, filter: &VarFilter) -> Result<Vec<Var>, CoreError> {
        Ok(self.metadata.list_vars(filter)?)
    }

    /// Delete a variable and every value stored for it across all tiers.
    /// Idempotent: deleting an absent variable is a successful no-op.
    ///
    /// Order of operations: metadata first (cascading plain values + links),
    /// then a defensive `secrets.delete` for *both* kinds. The metadata-first
    /// order guarantees that a transient secret-tier failure cannot leave a
    /// "zombie" variable that the user can see but never read; the
    /// defensive secret delete on `Plain` kind closes a corruption-recovery
    /// path. A secret-tier failure after metadata has been deleted is
    /// returned to the caller but the variable is, from any reader's
    /// perspective, gone.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn delete_var(&self, id: VarId) -> Result<(), CoreError> {
        if self.metadata.get_var(id)?.is_none() {
            return Ok(());
        }
        // Metadata first: cascades plain values + links atomically (per
        // backend contract) and removes the variable from every read path.
        self.metadata.delete_var(id)?;
        // Defensive: try the secret tier even when the kind was Plain. The
        // call is idempotent per the trait contract.
        self.secrets.delete(id)?;
        self.audit_var(id, AuditAction::Deleted)?;
        Ok(())
    }

    // -------------------------------------------------------------------
    // Projects
    // -------------------------------------------------------------------

    /// Create a new project.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn create_project(
        &self,
        name: impl Into<String>,
        path: std::path::PathBuf,
    ) -> Result<ProjectId, CoreError> {
        let id = ProjectId::from_uuid(self.id_gen.next());
        let project = Project::from_parts(id, name.into(), path);
        self.metadata.upsert_project(&project)?;
        self.audit_project(id, None, AuditAction::Created)?;
        Ok(id)
    }

    /// Fetch a project by id.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn get_project(&self, id: ProjectId) -> Result<Option<Project>, CoreError> {
        Ok(self.metadata.get_project(id)?)
    }

    /// List every project, sorted by the backend's contract (usually by
    /// name for deterministic output).
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn list_projects(&self) -> Result<Vec<Project>, CoreError> {
        Ok(self.metadata.list_projects()?)
    }

    /// Delete a project and every linkage it owns.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn delete_project(&self, id: ProjectId) -> Result<(), CoreError> {
        if self.metadata.get_project(id)?.is_none() {
            return Ok(());
        }
        self.metadata.delete_project(id)?;
        self.audit_project(id, None, AuditAction::Deleted)?;
        Ok(())
    }

    // -------------------------------------------------------------------
    // Linkage
    // -------------------------------------------------------------------

    /// Link a variable to a project under the given profile, optionally
    /// renaming it for the project's context.
    ///
    /// # Errors
    /// Returns [`MetadataError::ProjectNotFound`] or
    /// [`MetadataError::VarNotFound`] if either side is missing.
    pub fn link_var(
        &self,
        project_id: ProjectId,
        var_id: VarId,
        profile: Profile,
        alias: Option<String>,
    ) -> Result<(), CoreError> {
        let link = ProjectVar {
            project_id,
            var_id,
            alias,
            profile,
        };
        self.metadata.upsert_link(&link)?;
        self.audit_project(project_id, Some(var_id), AuditAction::Linked)?;
        Ok(())
    }

    /// Remove a linkage. Idempotent: removing an absent link is `Ok(())`.
    ///
    /// Emits an [`AuditAction::Unlinked`] entry **only when a linkage was
    /// actually removed**. A call on an absent triple is a successful
    /// no-op and is not audited (avoids audit-log pollution that would
    /// degrade forensic value).
    ///
    /// # Atomicity (v1)
    /// A failure of the audit append after a successful linkage removal
    /// is surfaced as an error but the linkage is gone. Callers should
    /// not retry, since a retry of `unlink_var` would return `Ok(())`
    /// (the triple is now absent). Same v1 limitation as
    /// [`Self::create_var`]; a dedicated `AuditWriteFailed` variant is
    /// scheduled for v1.1.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn unlink_var(
        &self,
        project_id: ProjectId,
        var_id: VarId,
        profile: &Profile,
    ) -> Result<(), CoreError> {
        let removed = self.metadata.delete_link(project_id, var_id, profile)?;
        if removed {
            self.audit_project(project_id, Some(var_id), AuditAction::Unlinked)?;
        }
        Ok(())
    }

    /// Every linkage owned by a project.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn links_for_project(&self, project_id: ProjectId) -> Result<Vec<ProjectVar>, CoreError> {
        Ok(self.metadata.list_links_for_project(project_id)?)
    }

    /// Every linkage that references a given variable.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn links_for_var(&self, var_id: VarId) -> Result<Vec<ProjectVar>, CoreError> {
        Ok(self.metadata.list_links_for_var(var_id)?)
    }

    // -------------------------------------------------------------------
    // Audit
    // -------------------------------------------------------------------

    /// Return the newest `limit` audit entries.
    ///
    /// # Errors
    /// Propagates storage failures.
    pub fn recent_audit(&self, limit: usize) -> Result<Vec<AuditEntry>, CoreError> {
        Ok(self.audit.list(limit)?)
    }

    // -------------------------------------------------------------------
    // Internal helpers
    // -------------------------------------------------------------------

    fn audit_var(&self, var_id: VarId, action: AuditAction) -> Result<(), MetadataError> {
        let entry = AuditEntry::from_parts(
            AuditId::from_uuid(self.id_gen.next()),
            action,
            Some(var_id),
            None,
            None,
            self.clock.now(),
        );
        self.audit.append(&entry)
    }

    fn audit_project(
        &self,
        project_id: ProjectId,
        var_id: Option<VarId>,
        action: AuditAction,
    ) -> Result<(), MetadataError> {
        let entry = AuditEntry::from_parts(
            AuditId::from_uuid(self.id_gen.next()),
            action,
            var_id,
            Some(project_id),
            None,
            self.clock.now(),
        );
        self.audit.append(&entry)
    }
}