Skip to main content

evault_core/model/
var.rs

1//! The [`Var`] entity, the central record of the registry.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::error::MetadataError;
10
11/// Stable identifier of a [`Var`].
12///
13/// IDs are version-4 UUIDs. The wrapper exists so that callers cannot
14/// accidentally confuse a [`VarId`] with a [`crate::model::ProjectId`].
15///
16/// # Examples
17/// ```
18/// use evault_core::model::VarId;
19///
20/// let a = VarId::new_v4();
21/// let b = VarId::new_v4();
22/// assert_ne!(a, b);
23/// ```
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct VarId(Uuid);
26
27impl VarId {
28    /// Generate a fresh identifier using [`Uuid::new_v4`].
29    #[must_use]
30    pub fn new_v4() -> Self {
31        Self(Uuid::new_v4())
32    }
33
34    /// Wrap an existing [`Uuid`] without generating a new one.
35    ///
36    /// Use this when reconstructing a record from a backend that already has
37    /// the id (e.g. a database row).
38    #[must_use]
39    pub const fn from_uuid(id: Uuid) -> Self {
40        Self(id)
41    }
42
43    /// Borrow the inner [`Uuid`].
44    #[must_use]
45    pub const fn as_uuid(&self) -> &Uuid {
46        &self.0
47    }
48}
49
50impl fmt::Display for VarId {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        fmt::Display::fmt(&self.0, f)
53    }
54}
55
56/// Logical grouping of a variable.
57///
58/// Groups are a coarse classification displayed prominently in the dashboard.
59/// Finer-grained categorisation is achieved through [`Var`] tags.
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61pub enum Group {
62    /// Conventional user-scope (personal projects, one-off secrets).
63    User,
64    /// Conventional system-scope (machine-wide credentials, infrastructure).
65    System,
66    /// Conventional project-scope (used by a single project).
67    Project,
68    /// Custom group name supplied by the user.
69    Custom(String),
70}
71
72impl Group {
73    /// Returns the canonical short name used for display and serialization.
74    ///
75    /// # Examples
76    /// ```
77    /// use evault_core::model::Group;
78    /// assert_eq!(Group::User.as_str(), "user");
79    /// assert_eq!(Group::Custom("aws".into()).as_str(), "aws");
80    /// ```
81    #[must_use]
82    pub const fn as_str(&self) -> &str {
83        match self {
84            Self::User => "user",
85            Self::System => "system",
86            Self::Project => "project",
87            Self::Custom(s) => s.as_str(),
88        }
89    }
90}
91
92/// How a variable stores its value.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
94pub enum VarKind {
95    /// The value lives in the secret store (OS keyring or encrypted fallback)
96    /// and is never written to the metadata store.
97    Secret,
98    /// The value lives in the metadata store next to the [`Var`] record.
99    /// Suitable for non-sensitive values like `NODE_ENV=production`.
100    Plain,
101}
102
103/// A managed environment variable.
104///
105/// `Var` carries only metadata. The actual value is stored separately:
106///
107/// - For [`VarKind::Secret`], the value lives in the
108///   [`SecretStore`](crate::traits::SecretStore).
109/// - For [`VarKind::Plain`], the value lives in the
110///   [`MetadataStore`](crate::traits::MetadataStore) and is fetched on demand
111///   so we do not keep it in memory across operations.
112///
113/// # Examples
114/// ```
115/// use evault_core::model::{Var, Group, VarKind};
116///
117/// let v = Var::new("DATABASE_URL", Group::User, VarKind::Secret);
118/// assert_eq!(v.name(), "DATABASE_URL");
119/// assert_eq!(v.kind(), VarKind::Secret);
120/// ```
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct Var {
123    id: VarId,
124    name: String,
125    group: Group,
126    kind: VarKind,
127    tags: Vec<String>,
128    length: usize,
129    #[serde(with = "time::serde::rfc3339")]
130    created_at: OffsetDateTime,
131    #[serde(with = "time::serde::rfc3339")]
132    updated_at: OffsetDateTime,
133}
134
135impl Var {
136    /// Create a new [`Var`] from already-validated input.
137    ///
138    /// This constructor does **not** call [`Self::validate_name`]; it is
139    /// intended for tests and code paths that have already validated the
140    /// name upstream. **Never** call this directly with user-supplied input:
141    /// use [`Self::try_new`] instead.
142    pub fn new(name: impl Into<String>, group: Group, kind: VarKind) -> Self {
143        let now = OffsetDateTime::now_utc();
144        Self {
145            id: VarId::new_v4(),
146            name: name.into(),
147            group,
148            kind,
149            tags: Vec::new(),
150            length: 0,
151            created_at: now,
152            updated_at: now,
153        }
154    }
155
156    /// Create a new [`Var`] from possibly-untrusted input, validating the
157    /// name through [`Self::validate_name`].
158    ///
159    /// This is the constructor that CLI/TUI surfaces and any other path
160    /// that accepts user input should use.
161    ///
162    /// # Errors
163    /// Returns [`MetadataError::Invalid`] if `name` does not satisfy
164    /// [`Self::validate_name`].
165    ///
166    /// # Examples
167    /// ```
168    /// use evault_core::model::{Var, Group, VarKind};
169    ///
170    /// assert!(Var::try_new("DATABASE_URL", Group::User, VarKind::Plain).is_ok());
171    /// assert!(Var::try_new("1BAD", Group::User, VarKind::Plain).is_err());
172    /// ```
173    pub fn try_new(
174        name: impl Into<String>,
175        group: Group,
176        kind: VarKind,
177    ) -> Result<Self, MetadataError> {
178        let name = name.into();
179        Self::validate_name(&name)?;
180        Ok(Self::new(name, group, kind))
181    }
182
183    /// Reconstruct a [`Var`] from already-stored fields **without re-validating**.
184    ///
185    /// This bypasses [`Self::validate_name`]. Reach for it only from a code path
186    /// where the data has demonstrably been validated upstream — e.g. tests or
187    /// in-process serialization. Backends that rehydrate from external storage
188    /// (`SQLite`, files, …) must call [`Self::try_from_parts`] instead so that a
189    /// corrupted or tampered row cannot inject malformed names into the rest of
190    /// the system.
191    #[must_use]
192    #[allow(clippy::too_many_arguments)]
193    pub const fn from_trusted_parts(
194        id: VarId,
195        name: String,
196        group: Group,
197        kind: VarKind,
198        tags: Vec<String>,
199        length: usize,
200        created_at: OffsetDateTime,
201        updated_at: OffsetDateTime,
202    ) -> Self {
203        Self {
204            id,
205            name,
206            group,
207            kind,
208            tags,
209            length,
210            created_at,
211            updated_at,
212        }
213    }
214
215    /// Reconstruct a [`Var`] from already-stored fields, re-validating the
216    /// name through [`Self::validate_name`].
217    ///
218    /// This is the entry point that storage backends (`SQLCipher`,
219    /// in-memory, …) should use when rehydrating rows: it ensures a tampered
220    /// or corrupted database cannot smuggle in a malformed variable name.
221    ///
222    /// # Errors
223    /// Returns [`MetadataError::Invalid`] if `name` does not satisfy
224    /// [`Self::validate_name`].
225    #[allow(clippy::too_many_arguments)]
226    pub fn try_from_parts(
227        id: VarId,
228        name: String,
229        group: Group,
230        kind: VarKind,
231        tags: Vec<String>,
232        length: usize,
233        created_at: OffsetDateTime,
234        updated_at: OffsetDateTime,
235    ) -> Result<Self, MetadataError> {
236        Self::validate_name(&name)?;
237        Ok(Self::from_trusted_parts(
238            id, name, group, kind, tags, length, created_at, updated_at,
239        ))
240    }
241
242    /// Validate that `candidate` is acceptable as a variable name.
243    ///
244    /// Accepted names follow the conventional environment-variable shape:
245    /// - non-empty
246    /// - 64 characters or fewer
247    /// - first character is an ASCII letter or underscore
248    /// - subsequent characters are ASCII alphanumerics or underscores
249    ///
250    /// # Errors
251    /// Returns [`MetadataError::Invalid`] if any rule is violated.
252    ///
253    /// # Examples
254    /// ```
255    /// use evault_core::model::Var;
256    /// assert!(Var::validate_name("DATABASE_URL").is_ok());
257    /// assert!(Var::validate_name("").is_err());
258    /// assert!(Var::validate_name("1BAD").is_err());
259    /// ```
260    pub fn validate_name(candidate: &str) -> Result<&str, MetadataError> {
261        if candidate.is_empty() {
262            return Err(MetadataError::Invalid("name is empty".into()));
263        }
264        if candidate.len() > 64 {
265            return Err(MetadataError::Invalid(
266                "name is longer than 64 characters".into(),
267            ));
268        }
269        let bytes = candidate.as_bytes();
270        // `is_empty` was already checked, so `bytes[0]` is safe to read; the
271        // structural alternative would re-check via `bytes.first()`.
272        let first = bytes[0];
273        if !(first.is_ascii_alphabetic() || first == b'_') {
274            return Err(MetadataError::Invalid(
275                "name must start with an ASCII letter or underscore".into(),
276            ));
277        }
278        for (offset, &b) in bytes.iter().enumerate().skip(1) {
279            if !(b.is_ascii_alphanumeric() || b == b'_') {
280                // Deliberately do NOT echo the offending byte: in CLI/TUI
281                // surfaces a user might paste a secret value into the name
282                // field, and a single byte from the input is enough to narrow
283                // entropy in logs. The byte offset is enough for debuggability.
284                return Err(MetadataError::Invalid(format!(
285                    "name contains an invalid character at byte offset {offset}"
286                )));
287            }
288        }
289        Ok(candidate)
290    }
291
292    /// Returns the variable's stable identifier.
293    #[must_use]
294    pub const fn id(&self) -> VarId {
295        self.id
296    }
297
298    /// Returns the variable's name.
299    #[must_use]
300    pub fn name(&self) -> &str {
301        &self.name
302    }
303
304    /// Returns the variable's group.
305    #[must_use]
306    pub const fn group(&self) -> &Group {
307        &self.group
308    }
309
310    /// Returns the variable's storage kind.
311    #[must_use]
312    pub const fn kind(&self) -> VarKind {
313        self.kind
314    }
315
316    /// Returns the tag list.
317    #[must_use]
318    pub fn tags(&self) -> &[String] {
319        &self.tags
320    }
321
322    /// Replaces the tag list.
323    ///
324    /// Tags are not deduplicated nor sorted; callers should apply their own
325    /// normalization where it matters.
326    pub fn set_tags(&mut self, tags: Vec<String>) {
327        self.tags = tags;
328        self.touch();
329    }
330
331    /// Returns the length of the value (without revealing it).
332    ///
333    /// The length is captured at write-time by the registry and is intended
334    /// for display only.
335    #[must_use]
336    pub const fn length(&self) -> usize {
337        self.length
338    }
339
340    /// Sets the recorded value length and bumps `updated_at`.
341    pub fn set_length(&mut self, length: usize) {
342        self.length = length;
343        self.touch();
344    }
345
346    /// Returns when the variable was created (UTC).
347    #[must_use]
348    pub const fn created_at(&self) -> OffsetDateTime {
349        self.created_at
350    }
351
352    /// Returns when the variable was last modified (UTC).
353    #[must_use]
354    pub const fn updated_at(&self) -> OffsetDateTime {
355        self.updated_at
356    }
357
358    /// Marks the record as modified by bumping `updated_at` to "now".
359    fn touch(&mut self) {
360        self.updated_at = OffsetDateTime::now_utc();
361    }
362}
363
364/// Filter applied when listing variables from a
365/// [`MetadataStore`](crate::traits::MetadataStore).
366///
367/// Fields are additive: every supplied filter must match. A filter with all
368/// fields `None` matches every variable.
369#[derive(Debug, Clone, Default, PartialEq, Eq)]
370pub struct VarFilter {
371    /// Match only variables in this group (canonical name).
372    pub group: Option<Group>,
373    /// Match only variables whose name contains this substring (case-insensitive).
374    pub name_contains: Option<String>,
375    /// Match only variables with this storage kind.
376    pub kind: Option<VarKind>,
377    /// Match only variables that contain all of these tags.
378    pub tags_all: Vec<String>,
379}
380
381impl VarFilter {
382    /// Construct an empty filter (matches everything).
383    #[must_use]
384    pub fn new() -> Self {
385        Self::default()
386    }
387
388    /// Returns `true` if the supplied [`Var`] satisfies every active criterion.
389    #[must_use]
390    pub fn matches(&self, var: &Var) -> bool {
391        if let Some(group) = &self.group {
392            if var.group() != group {
393                return false;
394            }
395        }
396        if let Some(needle) = &self.name_contains {
397            let hay = var.name().to_ascii_lowercase();
398            let needle = needle.to_ascii_lowercase();
399            if !hay.contains(&needle) {
400                return false;
401            }
402        }
403        if let Some(kind) = self.kind {
404            if var.kind() != kind {
405                return false;
406            }
407        }
408        for tag in &self.tags_all {
409            if !var.tags().iter().any(|t| t == tag) {
410                return false;
411            }
412        }
413        true
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn new_var_has_fresh_id_and_equal_timestamps() {
423        let v = Var::new("FOO", Group::User, VarKind::Plain);
424        assert_eq!(v.name(), "FOO");
425        assert_eq!(v.created_at(), v.updated_at());
426    }
427
428    #[test]
429    fn touch_bumps_updated_at() {
430        let mut v = Var::new("FOO", Group::User, VarKind::Plain);
431        let original = v.updated_at();
432        std::thread::sleep(std::time::Duration::from_millis(2));
433        v.set_length(5);
434        assert!(v.updated_at() > original);
435    }
436
437    #[test]
438    fn validate_name_accepts_typical_names() {
439        for name in ["FOO", "_FOO", "DB_URL_2", "x"] {
440            assert!(Var::validate_name(name).is_ok(), "expected {name} valid");
441        }
442    }
443
444    #[test]
445    fn validate_name_rejects_invalid_inputs() {
446        let bad = ["", "1FOO", "FOO-BAR", "FOO BAR", &"A".repeat(65)];
447        for name in bad {
448            assert!(
449                Var::validate_name(name).is_err(),
450                "expected {name:?} invalid"
451            );
452        }
453    }
454
455    #[test]
456    fn try_from_parts_validates_name() {
457        let now = time::OffsetDateTime::now_utc();
458        let bad = Var::try_from_parts(
459            VarId::new_v4(),
460            "1BAD".to_owned(),
461            Group::User,
462            VarKind::Plain,
463            Vec::new(),
464            0,
465            now,
466            now,
467        );
468        assert!(bad.is_err(), "try_from_parts should reject invalid names");
469    }
470
471    #[test]
472    fn try_from_parts_round_trips_a_valid_var() {
473        let now = time::OffsetDateTime::now_utc();
474        let id = VarId::new_v4();
475        let var = Var::try_from_parts(
476            id,
477            "DATABASE_URL".to_owned(),
478            Group::Project,
479            VarKind::Secret,
480            vec!["db".into()],
481            12,
482            now,
483            now,
484        )
485        .expect("valid var");
486        assert_eq!(var.id(), id);
487        assert_eq!(var.name(), "DATABASE_URL");
488        assert_eq!(var.tags(), &["db".to_owned()]);
489        assert_eq!(var.length(), 12);
490    }
491
492    #[test]
493    fn var_filter_default_matches_everything() {
494        let f = VarFilter::new();
495        let v = Var::new("FOO", Group::Project, VarKind::Secret);
496        assert!(f.matches(&v));
497    }
498
499    #[test]
500    fn var_filter_combines_criteria() {
501        let v = {
502            let mut v = Var::new("DATABASE_URL", Group::Project, VarKind::Secret);
503            v.set_tags(vec!["db".into(), "prod".into()]);
504            v
505        };
506
507        let f = VarFilter {
508            group: Some(Group::Project),
509            name_contains: Some("data".into()),
510            kind: Some(VarKind::Secret),
511            tags_all: vec!["db".into()],
512        };
513        assert!(f.matches(&v));
514
515        let f = VarFilter {
516            kind: Some(VarKind::Plain),
517            ..VarFilter::default()
518        };
519        assert!(!f.matches(&v));
520    }
521
522    #[test]
523    fn group_as_str_uses_canonical_names() {
524        assert_eq!(Group::User.as_str(), "user");
525        assert_eq!(Group::System.as_str(), "system");
526        assert_eq!(Group::Project.as_str(), "project");
527        assert_eq!(Group::Custom("aws".into()).as_str(), "aws");
528    }
529
530    #[test]
531    fn var_id_display_matches_uuid() {
532        let id = VarId::new_v4();
533        assert_eq!(id.to_string(), id.as_uuid().to_string());
534    }
535}