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}