1use std::collections::BTreeMap;
51use std::io::Write;
52
53use aes_gcm::aead::{AeadCore, AeadInPlace, KeyInit, OsRng, rand_core::RngCore};
54use aes_gcm::{Aes256Gcm, Key, Nonce, Tag};
55use zeroize::{Zeroize, Zeroizing};
56
57const MAGIC: [u8; 4] = *b"QVLT";
60const VERSION: u8 = 0x01;
61pub const SALT_LEN: usize = 16;
63const NONCE_LEN: usize = 12;
64const TAG_LEN: usize = 16;
65const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN + TAG_LEN; pub const ITERATIONS: u32 = 600_000;
69
70pub const MAX_KEY_LEN: usize = 256;
72
73pub const MAX_VALUE_LEN: usize = 65536;
75
76#[derive(Debug)]
80pub enum VaultError {
81 TooSmall,
83 BadMagic,
85 UnsupportedVersion(u8),
87 DecryptionFailed,
89 EncryptionFailed,
91 MalformedData,
93 UnsupportedKdf(u8),
95 UnsupportedFlags(u16),
97 UnknownScheme(u8),
99 NotFound,
101 Io(std::io::Error),
103}
104
105impl std::fmt::Display for VaultError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 match self {
108 Self::TooSmall => write!(f, "vault file too small"),
109 Self::BadMagic => write!(f, "invalid vault file (bad magic)"),
110 Self::UnsupportedVersion(v) => write!(f, "unsupported vault version: {v}"),
111 Self::DecryptionFailed => write!(f, "decryption failed (wrong passphrase?)"),
112 Self::EncryptionFailed => write!(f, "encryption failed"),
113 Self::MalformedData => write!(f, "malformed vault data"),
114 Self::UnsupportedKdf(k) => write!(f, "unsupported KDF id: {k}"),
115 Self::UnsupportedFlags(fl) => write!(f, "unsupported vault flags: {fl:#06x}"),
116 Self::UnknownScheme(s) => write!(f, "unknown entry key scheme: {s}"),
117 Self::NotFound => write!(f, "key not found"),
118 Self::Io(e) => write!(f, "I/O error: {e}"),
119 }
120 }
121}
122
123impl std::error::Error for VaultError {}
124
125impl From<std::io::Error> for VaultError {
126 fn from(e: std::io::Error) -> Self {
127 Self::Io(e)
128 }
129}
130
131#[derive(Debug, Clone)]
138pub struct Vault {
139 entries: BTreeMap<String, String>,
140}
141
142impl Vault {
143 pub fn new() -> Self {
145 Self {
146 entries: BTreeMap::new(),
147 }
148 }
149
150 pub fn from_map(entries: BTreeMap<String, String>) -> Self {
152 Self { entries }
153 }
154
155 pub fn get(&self, key: &str) -> Option<&str> {
157 self.entries.get(key).map(|s| s.as_str())
158 }
159
160 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<String> {
162 self.entries.insert(key.into(), value.into())
163 }
164
165 pub fn delete(&mut self, key: &str) -> Option<String> {
167 self.entries.remove(key)
168 }
169
170 pub fn keys(&self) -> impl Iterator<Item = &str> {
172 self.entries.keys().map(|s| s.as_str())
173 }
174
175 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
177 self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
178 }
179
180 pub fn len(&self) -> usize {
182 self.entries.len()
183 }
184
185 pub fn is_empty(&self) -> bool {
187 self.entries.is_empty()
188 }
189
190 pub fn entries_mut(&mut self) -> &mut BTreeMap<String, String> {
192 &mut self.entries
193 }
194
195 pub fn to_map(&self) -> BTreeMap<String, String> {
197 self.entries.clone()
198 }
199
200 pub fn encrypt(&self, passphrase: &str) -> Result<Vec<u8>, VaultError> {
207 let mut plaintext = serialize(&self.entries);
208
209 let mut salt = [0u8; SALT_LEN];
210 OsRng.fill_bytes(&mut salt);
211 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
212
213 let key = derive_key(passphrase, &salt);
214 let cipher = Aes256Gcm::new(&key);
215
216 let tag = cipher
217 .encrypt_in_place_detached(&nonce, b"", &mut plaintext)
218 .map_err(|_| VaultError::EncryptionFailed)?;
219
220 drop(cipher);
222
223 let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len());
224 out.write_all(&MAGIC)?;
225 out.write_all(&[VERSION])?;
226 out.write_all(&salt)?;
227 out.write_all(nonce.as_slice())?;
228 out.write_all(tag.as_slice())?;
229 out.write_all(&plaintext)?;
230
231 Ok(out)
232 }
233
234 pub fn decrypt(data: &[u8], passphrase: &str) -> Result<Self, VaultError> {
239 if data.len() < HEADER_LEN {
240 return Err(VaultError::TooSmall);
241 }
242 if data[0..4] != MAGIC {
243 return Err(VaultError::BadMagic);
244 }
245 if data[4] != VERSION {
246 return Err(VaultError::UnsupportedVersion(data[4]));
247 }
248
249 let salt = &data[5..5 + SALT_LEN];
250 let nonce_bytes = &data[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN];
251 let tag_bytes = &data[5 + SALT_LEN + NONCE_LEN..HEADER_LEN];
252 let ciphertext = &data[HEADER_LEN..];
253
254 let key = derive_key(passphrase, salt);
255 let cipher = Aes256Gcm::new(&key);
256 let nonce = Nonce::from_slice(nonce_bytes);
257 let tag = Tag::from_slice(tag_bytes);
258
259 let mut buf = ciphertext.to_vec();
260 cipher
261 .decrypt_in_place_detached(nonce, b"", &mut buf, tag)
262 .map_err(|_| VaultError::DecryptionFailed)?;
263
264 let entries = deserialize(&buf);
265
266 buf.zeroize();
268
269 Ok(Self { entries })
270 }
271
272 pub fn to_shell_exports(&self) -> String {
278 let mut out = String::new();
279 for (key, value) in &self.entries {
280 let escaped = value.replace('\'', "'\\''");
281 out.push_str(&format!("export {key}='{escaped}'\n"));
282 }
283 out
284 }
285
286 pub fn to_json(&self) -> String {
288 let mut out = String::from("{\n");
289 let len = self.entries.len();
290 for (i, (key, value)) in self.entries.iter().enumerate() {
291 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
292 out.push_str(&format!(" \"{key}\": \"{escaped}\""));
293 if i + 1 < len {
294 out.push(',');
295 }
296 out.push('\n');
297 }
298 out.push_str("}\n");
299 out
300 }
301}
302
303impl Default for Vault {
304 fn default() -> Self {
305 Self::new()
306 }
307}
308
309impl Drop for Vault {
310 fn drop(&mut self) {
311 for value in self.entries.values_mut() {
313 unsafe {
314 let bytes = value.as_bytes_mut();
315 bytes.zeroize();
316 }
317 }
318 }
319}
320
321pub fn is_valid_key(key: &str) -> bool {
329 !key.is_empty()
330 && key.len() <= MAX_KEY_LEN
331 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
332}
333
334pub fn is_valid_project(name: &str) -> bool {
338 !name.is_empty()
339 && name.len() <= MAX_KEY_LEN
340 && name
341 .chars()
342 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
343}
344
345pub fn parse_env_lines(input: &str) -> Vec<(String, String)> {
349 let mut pairs = Vec::new();
350 for line in input.lines() {
351 let trimmed = line.trim();
352 if trimmed.is_empty() || trimmed.starts_with('#') {
353 continue;
354 }
355 let kv = trimmed.strip_prefix("export ").unwrap_or(trimmed);
356 if let Some((key, value)) = kv.split_once('=') {
357 let key = key.trim();
358 let value = value
359 .trim()
360 .trim_start_matches(|c| c == '"' || c == '\'')
361 .trim_end_matches(|c| c == '"' || c == '\'');
362 if is_valid_key(key) && !value.is_empty() {
363 pairs.push((key.to_string(), value.to_string()));
364 }
365 }
366 }
367 pairs
368}
369
370pub fn random_bytes(n: usize) -> Vec<u8> {
373 let mut buf = vec![0u8; n];
374 OsRng.fill_bytes(&mut buf);
375 buf
376}
377
378pub fn random_salt() -> [u8; SALT_LEN] {
381 let mut salt = [0u8; SALT_LEN];
382 OsRng.fill_bytes(&mut salt);
383 salt
384}
385
386pub fn encrypt_blob(plaintext: &[u8], passphrase: &str) -> Result<Vec<u8>, VaultError> {
390 let mut buf = plaintext.to_vec();
391 let mut salt = [0u8; SALT_LEN];
392 OsRng.fill_bytes(&mut salt);
393 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
394 let key = derive_key(passphrase, &salt);
395 let cipher = Aes256Gcm::new(&key);
396 let tag = cipher
397 .encrypt_in_place_detached(&nonce, b"", &mut buf)
398 .map_err(|_| VaultError::EncryptionFailed)?;
399
400 let mut out = Vec::with_capacity(HEADER_LEN + buf.len());
401 out.write_all(&MAGIC)?;
402 out.write_all(&[VERSION])?;
403 out.write_all(&salt)?;
404 out.write_all(nonce.as_slice())?;
405 out.write_all(tag.as_slice())?;
406 out.write_all(&buf)?;
407 buf.zeroize();
408 Ok(out)
409}
410
411pub fn decrypt_blob(data: &[u8], passphrase: &str) -> Result<Vec<u8>, VaultError> {
414 if data.len() < HEADER_LEN {
415 return Err(VaultError::TooSmall);
416 }
417 if data[0..4] != MAGIC {
418 return Err(VaultError::BadMagic);
419 }
420 if data[4] != VERSION {
421 return Err(VaultError::UnsupportedVersion(data[4]));
422 }
423 let salt = &data[5..5 + SALT_LEN];
424 let nonce_bytes = &data[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN];
425 let tag_bytes = &data[5 + SALT_LEN + NONCE_LEN..HEADER_LEN];
426 let ciphertext = &data[HEADER_LEN..];
427
428 let key = derive_key(passphrase, salt);
429 let cipher = Aes256Gcm::new(&key);
430 let nonce = Nonce::from_slice(nonce_bytes);
431 let tag = Tag::from_slice(tag_bytes);
432
433 let mut buf = ciphertext.to_vec();
434 cipher
435 .decrypt_in_place_detached(nonce, b"", &mut buf, tag)
436 .map_err(|_| VaultError::DecryptionFailed)?;
437 Ok(buf)
438}
439
440fn serialize(entries: &BTreeMap<String, String>) -> Vec<u8> {
443 let mut buf = Vec::new();
444 for (key, value) in entries {
445 let klen = key.len();
446 buf.push((klen >> 8) as u8);
447 buf.push((klen & 0xFF) as u8);
448 buf.extend_from_slice(key.as_bytes());
449 let vlen = value.len();
450 buf.push((vlen >> 24) as u8);
451 buf.push(((vlen >> 16) & 0xFF) as u8);
452 buf.push(((vlen >> 8) & 0xFF) as u8);
453 buf.push((vlen & 0xFF) as u8);
454 buf.extend_from_slice(value.as_bytes());
455 }
456 buf.extend_from_slice(&[0x00, 0x00]);
457 buf
458}
459
460fn deserialize(data: &[u8]) -> BTreeMap<String, String> {
461 let mut entries = BTreeMap::new();
462 let mut pos = 0;
463 while pos + 2 <= data.len() {
464 let klen = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
465 pos += 2;
466 if klen == 0 {
467 break;
468 }
469 if klen > MAX_KEY_LEN || pos + klen > data.len() {
470 break;
471 }
472 let key = String::from_utf8_lossy(&data[pos..pos + klen]).to_string();
473 pos += klen;
474 if pos + 4 > data.len() {
475 break;
476 }
477 let vlen = ((data[pos] as usize) << 24)
478 | ((data[pos + 1] as usize) << 16)
479 | ((data[pos + 2] as usize) << 8)
480 | (data[pos + 3] as usize);
481 pos += 4;
482 if vlen > MAX_VALUE_LEN || pos + vlen > data.len() {
483 break;
484 }
485 let value = String::from_utf8_lossy(&data[pos..pos + vlen]).to_string();
486 pos += vlen;
487 entries.insert(key, value);
488 }
489 entries
490}
491
492fn derive_key(passphrase: &str, salt: &[u8]) -> Key<Aes256Gcm> {
495 let key =
496 pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(passphrase.as_bytes(), salt, ITERATIONS);
497 *Key::<Aes256Gcm>::from_slice(&key)
498}
499
500use hmac::Mac;
505
506fn new_hmac(key: &[u8]) -> HmacSha256 {
509 <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key length")
510}
511
512pub const V2_VERSION: u8 = 0x02;
514pub const KDF_PBKDF2: u8 = 0x01;
516pub const SCHEME_HKDF: u8 = 0x01;
518pub const V2_HEADER_LEN: usize = 4 + 1 + 1 + 2 + SALT_LEN + 4; pub const MAC_LEN: usize = 32;
522pub const PAD_BLOCK: usize = 32;
525pub const MAX_NAME_LEN: usize = 513;
527const MAX_PADDED: usize = (4 + MAX_VALUE_LEN).div_ceil(PAD_BLOCK) * PAD_BLOCK;
529
530type HmacSha256 = hmac::Hmac<sha2::Sha256>;
531
532pub fn is_valid_storage_name(name: &str) -> bool {
534 match name.split_once('/') {
535 Some((project, key)) => is_valid_project(project) && is_valid_key(key),
536 None => is_valid_key(name),
537 }
538}
539
540pub struct MasterSecret {
545 secret: Zeroizing<[u8; 32]>,
546 salt: [u8; SALT_LEN],
547}
548
549impl MasterSecret {
550 pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Self {
553 let secret = Zeroizing::new(pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(
554 passphrase.as_bytes(),
555 salt,
556 ITERATIONS,
557 ));
558 Self { secret, salt: *salt }
559 }
560
561 pub fn from_raw_key(key: &[u8; 32], salt: &[u8; SALT_LEN]) -> Self {
567 Self { secret: Zeroizing::new(*key), salt: *salt }
568 }
569
570 pub fn salt(&self) -> &[u8; SALT_LEN] {
571 &self.salt
572 }
573
574 fn hkdf(&self, info: &[u8]) -> Zeroizing<[u8; 32]> {
575 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&self.salt), self.secret.as_ref());
576 let mut okm = Zeroizing::new([0u8; 32]);
577 hk.expand(info, okm.as_mut())
578 .expect("32 bytes is a valid HKDF-SHA256 output length");
579 okm
580 }
581
582 fn entry_key(&self, name: &str) -> Zeroizing<[u8; 32]> {
585 let mut info = Vec::with_capacity(14 + name.len());
586 info.extend_from_slice(b"qvlt2:entry:");
587 info.extend_from_slice(&(name.len() as u16).to_be_bytes());
588 info.extend_from_slice(name.as_bytes());
589 self.hkdf(&info)
590 }
591
592 fn mac_key(&self) -> Zeroizing<[u8; 32]> {
593 self.hkdf(b"qvlt2:manifest")
594 }
595
596 pub fn registry_key(&self) -> Zeroizing<[u8; 32]> {
598 self.hkdf(b"qvlt2:registry")
599 }
600}
601
602pub fn is_v2(data: &[u8]) -> bool {
605 data.len() >= V2_HEADER_LEN && data[0..4] == MAGIC && data[4] == V2_VERSION
606}
607
608pub fn is_v1(data: &[u8]) -> bool {
610 data.len() >= HEADER_LEN && data[0..4] == MAGIC && data[4] == VERSION
611}
612
613pub fn v2_salt(data: &[u8]) -> Result<[u8; SALT_LEN], VaultError> {
617 if data.len() < V2_HEADER_LEN + MAC_LEN {
618 return Err(VaultError::TooSmall);
619 }
620 if data[0..4] != MAGIC {
621 return Err(VaultError::BadMagic);
622 }
623 if data[4] != V2_VERSION {
624 return Err(VaultError::UnsupportedVersion(data[4]));
625 }
626 if data[5] != KDF_PBKDF2 {
627 return Err(VaultError::UnsupportedKdf(data[5]));
628 }
629 let flags = u16::from_be_bytes([data[6], data[7]]);
630 if flags != 0 {
631 return Err(VaultError::UnsupportedFlags(flags));
632 }
633 let mut salt = [0u8; SALT_LEN];
634 salt.copy_from_slice(&data[8..8 + SALT_LEN]);
635 Ok(salt)
636}
637
638struct RecordMeta {
640 scheme: u8,
641 name: String,
642 rec_off: usize,
644 nonce_off: usize,
646 ct_len: usize,
647}
648
649impl RecordMeta {
650 fn tag_off(&self) -> usize {
651 self.nonce_off + NONCE_LEN
652 }
653 fn ct_off(&self) -> usize {
654 self.tag_off() + TAG_LEN + 4
655 }
656 fn end(&self) -> usize {
657 self.ct_off() + self.ct_len
658 }
659}
660
661pub struct VaultReader {
665 data: Vec<u8>,
666 salt: [u8; SALT_LEN],
667 records: Vec<RecordMeta>,
668}
669
670impl VaultReader {
671 pub fn open(data: Vec<u8>, master: &MasterSecret) -> Result<Self, VaultError> {
675 let salt = v2_salt(&data)?;
676 if salt != master.salt {
677 return Err(VaultError::DecryptionFailed);
679 }
680 let count = u32::from_be_bytes([data[24], data[25], data[26], data[27]]) as usize;
681 let body_end = data.len() - MAC_LEN;
682
683 let mut records = Vec::new();
684 let mut pos = V2_HEADER_LEN;
685 for _ in 0..count {
686 if pos + 3 > body_end {
689 return Err(VaultError::MalformedData);
690 }
691 let scheme = data[pos];
692 if scheme != SCHEME_HKDF {
693 return Err(VaultError::UnknownScheme(scheme));
696 }
697 let name_len = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
698 if name_len == 0 || name_len > MAX_NAME_LEN || pos + 3 + name_len + NONCE_LEN + TAG_LEN + 4 > body_end {
699 return Err(VaultError::MalformedData);
700 }
701 let name = std::str::from_utf8(&data[pos + 3..pos + 3 + name_len])
702 .map_err(|_| VaultError::MalformedData)?
703 .to_string();
704 if !is_valid_storage_name(&name) {
705 return Err(VaultError::MalformedData);
706 }
707 if let Some(prev) = records.last() {
708 let prev: &RecordMeta = prev;
709 if prev.name.as_bytes() >= name.as_bytes() {
710 return Err(VaultError::MalformedData);
712 }
713 }
714 let nonce_off = pos + 3 + name_len;
715 let ct_len_off = nonce_off + NONCE_LEN + TAG_LEN;
716 let ct_len = u32::from_be_bytes([
717 data[ct_len_off],
718 data[ct_len_off + 1],
719 data[ct_len_off + 2],
720 data[ct_len_off + 3],
721 ]) as usize;
722 if ct_len == 0
723 || ct_len % PAD_BLOCK != 0
724 || ct_len > MAX_PADDED
725 || ct_len_off + 4 + ct_len > body_end
726 {
727 return Err(VaultError::MalformedData);
728 }
729 records.push(RecordMeta { scheme, name, rec_off: pos, nonce_off, ct_len });
730 pos = ct_len_off + 4 + ct_len;
731 }
732 if pos != body_end {
733 return Err(VaultError::MalformedData);
735 }
736
737 let mac_key = master.mac_key();
738 let mut mac = new_hmac(mac_key.as_ref());
739 mac.update(&data[..body_end]);
740 mac.verify_slice(&data[body_end..])
742 .map_err(|_| VaultError::DecryptionFailed)?;
743
744 Ok(Self { data, salt, records })
745 }
746
747 pub fn names(&self) -> impl Iterator<Item = &str> {
749 self.records.iter().map(|r| r.name.as_str())
750 }
751
752 pub fn len(&self) -> usize {
753 self.records.len()
754 }
755
756 pub fn is_empty(&self) -> bool {
757 self.records.is_empty()
758 }
759
760 pub fn contains(&self, name: &str) -> bool {
761 self.find(name).is_some()
762 }
763
764 fn find(&self, name: &str) -> Option<&RecordMeta> {
765 self.records
766 .binary_search_by(|r| r.name.as_str().cmp(name))
767 .ok()
768 .map(|i| &self.records[i])
769 }
770
771 pub fn decrypt_one(
774 &self,
775 master: &MasterSecret,
776 name: &str,
777 ) -> Result<Zeroizing<Vec<u8>>, VaultError> {
778 let rec = self.find(name).ok_or(VaultError::NotFound)?;
779 let key = master.entry_key(name);
780 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
781 let nonce = Nonce::from_slice(&self.data[rec.nonce_off..rec.nonce_off + NONCE_LEN]);
782 let tag = Tag::from_slice(&self.data[rec.tag_off()..rec.tag_off() + TAG_LEN]);
783 let aad = record_aad(rec.scheme, name);
784
785 let mut body =
786 Zeroizing::new(self.data[rec.ct_off()..rec.ct_off() + rec.ct_len].to_vec());
787 cipher
788 .decrypt_in_place_detached(nonce, &aad, body.as_mut_slice(), tag)
789 .map_err(|_| VaultError::DecryptionFailed)?;
790
791 let true_len =
792 u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
793 if true_len > MAX_VALUE_LEN || 4 + true_len > body.len() {
794 return Err(VaultError::MalformedData);
795 }
796 Ok(Zeroizing::new(body[4..4 + true_len].to_vec()))
797 }
798
799 pub fn decrypt_all(&self, master: &MasterSecret) -> Result<Vault, VaultError> {
802 let mut entries = BTreeMap::new();
803 for r in &self.records {
804 let v = self.decrypt_one(master, &r.name)?;
805 let s = String::from_utf8_lossy(&v).into_owned();
806 entries.insert(r.name.clone(), s);
807 }
808 Ok(Vault { entries })
809 }
810
811 pub fn splice(
815 &self,
816 master: &MasterSecret,
817 upserts: &[(String, Zeroizing<Vec<u8>>)],
818 deletes: &[String],
819 ) -> Result<Vec<u8>, VaultError> {
820 enum Src<'a> {
821 Keep(&'a RecordMeta),
822 New(&'a str, &'a [u8]),
823 }
824 let mut merged: BTreeMap<&str, Src> = self
825 .records
826 .iter()
827 .map(|r| (r.name.as_str(), Src::Keep(r)))
828 .collect();
829 for name in deletes {
830 merged.remove(name.as_str());
831 }
832 for (name, value) in upserts {
833 if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
834 return Err(VaultError::MalformedData);
835 }
836 merged.insert(&name[..], Src::New(name, value));
837 }
838
839 let mut out = Vec::new();
840 write_v2_header(&mut out, &self.salt, merged.len() as u32)?;
841 for (name, src) in &merged {
842 match src {
843 Src::Keep(rec) => out.extend_from_slice(&self.data[rec.rec_off..rec.end()]),
844 Src::New(name, value) => write_record(&mut out, master, name, value)?,
845 }
846 let _ = name;
847 }
848 append_mac(&mut out, master);
849 Ok(out)
850 }
851}
852
853fn record_aad(scheme: u8, name: &str) -> Vec<u8> {
854 let mut aad = Vec::with_capacity(2 + name.len());
855 aad.push(V2_VERSION);
856 aad.push(scheme);
857 aad.extend_from_slice(name.as_bytes());
858 aad
859}
860
861fn write_v2_header(out: &mut Vec<u8>, salt: &[u8; SALT_LEN], count: u32) -> Result<(), VaultError> {
862 out.write_all(&MAGIC)?;
863 out.write_all(&[V2_VERSION, KDF_PBKDF2, 0, 0])?;
864 out.write_all(salt)?;
865 out.write_all(&count.to_be_bytes())?;
866 Ok(())
867}
868
869fn write_record(
870 out: &mut Vec<u8>,
871 master: &MasterSecret,
872 name: &str,
873 value: &[u8],
874) -> Result<(), VaultError> {
875 let padded = (4 + value.len()).div_ceil(PAD_BLOCK) * PAD_BLOCK;
877 let mut body = Zeroizing::new(vec![0u8; padded]);
878 body[..4].copy_from_slice(&(value.len() as u32).to_be_bytes());
879 body[4..4 + value.len()].copy_from_slice(value);
880
881 let key = master.entry_key(name);
882 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
883 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
884 let aad = record_aad(SCHEME_HKDF, name);
885 let tag = cipher
886 .encrypt_in_place_detached(&nonce, &aad, body.as_mut_slice())
887 .map_err(|_| VaultError::EncryptionFailed)?;
888
889 out.push(SCHEME_HKDF);
890 out.write_all(&(name.len() as u16).to_be_bytes())?;
891 out.write_all(name.as_bytes())?;
892 out.write_all(nonce.as_slice())?;
893 out.write_all(tag.as_slice())?;
894 out.write_all(&(padded as u32).to_be_bytes())?;
895 out.write_all(&body)?;
896 Ok(())
897}
898
899fn append_mac(out: &mut Vec<u8>, master: &MasterSecret) {
900 let mac_key = master.mac_key();
901 let mut mac = new_hmac(mac_key.as_ref());
902 mac.update(out);
903 out.extend_from_slice(&mac.finalize().into_bytes());
904}
905
906pub fn v2_create(
909 master: &MasterSecret,
910 entries: &[(String, Zeroizing<Vec<u8>>)],
911) -> Result<Vec<u8>, VaultError> {
912 let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
913 for (name, value) in entries {
914 if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
915 return Err(VaultError::MalformedData);
916 }
917 sorted.insert(&name[..], value);
918 }
919 let mut out = Vec::new();
920 write_v2_header(&mut out, &master.salt, sorted.len() as u32)?;
921 for (name, value) in &sorted {
922 write_record(&mut out, master, name, value)?;
923 }
924 append_mac(&mut out, master);
925 Ok(out)
926}
927
928const RAW_MAGIC: [u8; 4] = *b"QRG2";
935const RAW_AAD: &[u8] = b"qvlt2:registry";
936
937pub fn encrypt_raw_blob(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
938 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
939 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
940 let mut buf = plaintext.to_vec();
941 let tag = cipher
942 .encrypt_in_place_detached(&nonce, RAW_AAD, &mut buf)
943 .map_err(|_| VaultError::EncryptionFailed)?;
944 let mut out = Vec::with_capacity(4 + NONCE_LEN + TAG_LEN + buf.len());
945 out.write_all(&RAW_MAGIC)?;
946 out.write_all(nonce.as_slice())?;
947 out.write_all(tag.as_slice())?;
948 out.write_all(&buf)?;
949 buf.zeroize();
950 Ok(out)
951}
952
953pub fn decrypt_raw_blob(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
954 if data.len() < 4 + NONCE_LEN + TAG_LEN {
955 return Err(VaultError::TooSmall);
956 }
957 if data[0..4] != RAW_MAGIC {
958 return Err(VaultError::BadMagic);
959 }
960 let nonce = Nonce::from_slice(&data[4..4 + NONCE_LEN]);
961 let tag = Tag::from_slice(&data[4 + NONCE_LEN..4 + NONCE_LEN + TAG_LEN]);
962 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
963 let mut buf = data[4 + NONCE_LEN + TAG_LEN..].to_vec();
964 cipher
965 .decrypt_in_place_detached(nonce, RAW_AAD, &mut buf, tag)
966 .map_err(|_| VaultError::DecryptionFailed)?;
967 Ok(buf)
968}
969
970#[cfg(test)]
973mod tests {
974 use super::*;
975
976 #[test]
977 fn round_trip() {
978 let mut vault = Vault::new();
979 vault.set("API_KEY", "sk-secret-123");
980 vault.set("DB_URL", "postgres://localhost/mydb");
981
982 let encrypted = vault.encrypt("test-pass").unwrap();
983 let decrypted = Vault::decrypt(&encrypted, "test-pass").unwrap();
984
985 assert_eq!(decrypted.get("API_KEY"), Some("sk-secret-123"));
986 assert_eq!(decrypted.get("DB_URL"), Some("postgres://localhost/mydb"));
987 assert_eq!(decrypted.len(), 2);
988 }
989
990 #[test]
991 fn wrong_passphrase() {
992 let vault = Vault::new();
993 let encrypted = vault.encrypt("correct").unwrap();
994 assert!(matches!(
995 Vault::decrypt(&encrypted, "wrong"),
996 Err(VaultError::DecryptionFailed)
997 ));
998 }
999
1000 #[test]
1001 fn tamper_detection() {
1002 let mut vault = Vault::new();
1003 vault.set("KEY", "value");
1004 let mut encrypted = vault.encrypt("pass").unwrap();
1005 if let Some(last) = encrypted.last_mut() {
1007 *last ^= 0xFF;
1008 }
1009 assert!(Vault::decrypt(&encrypted, "pass").is_err());
1010 }
1011
1012 #[test]
1013 fn fresh_nonce_per_encrypt() {
1014 let vault = Vault::new();
1015 let a = vault.encrypt("pass").unwrap();
1016 let b = vault.encrypt("pass").unwrap();
1017 assert_ne!(a, b);
1019 }
1020
1021 #[test]
1022 fn shell_escaping() {
1023 let mut vault = Vault::new();
1024 vault.set("KEY", "it's a \"test\"");
1025 let exports = vault.to_shell_exports();
1026 assert!(exports.contains("'it'\\''s a \"test\"'"));
1027 }
1028
1029 #[test]
1030 fn valid_keys() {
1031 assert!(is_valid_key("API_KEY"));
1032 assert!(is_valid_key("key123"));
1033 assert!(is_valid_key("prod-db-password")); assert!(is_valid_key("metatron-enterprise-lock"));
1035 assert!(!is_valid_key(""));
1036 assert!(!is_valid_key("has space"));
1037 assert!(!is_valid_key("has.dot")); assert!(!is_valid_key("has/slash"));
1039 }
1040
1041 fn zv(s: &str) -> Zeroizing<Vec<u8>> {
1044 Zeroizing::new(s.as_bytes().to_vec())
1045 }
1046
1047 fn test_master() -> MasterSecret {
1048 MasterSecret::derive("test-pass", &[7u8; SALT_LEN])
1050 }
1051
1052 fn sample_file(master: &MasterSecret) -> Vec<u8> {
1053 v2_create(
1054 master,
1055 &[
1056 ("API_KEY".into(), zv("sk-secret-123")),
1057 ("DB_URL".into(), zv("postgres://localhost/mydb")),
1058 ("proj/TOKEN".into(), zv("t-42")),
1059 ],
1060 )
1061 .unwrap()
1062 }
1063
1064 #[test]
1065 fn v2_round_trip_one() {
1066 let m = test_master();
1067 let file = sample_file(&m);
1068 let r = VaultReader::open(file, &m).unwrap();
1069 assert_eq!(r.len(), 3);
1070 assert_eq!(&*r.decrypt_one(&m, "API_KEY").unwrap(), b"sk-secret-123");
1071 assert_eq!(&*r.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1072 assert!(matches!(r.decrypt_one(&m, "NOPE"), Err(VaultError::NotFound)));
1073 }
1074
1075 #[test]
1076 fn v2_wrong_passphrase() {
1077 let m = test_master();
1078 let file = sample_file(&m);
1079 let wrong = MasterSecret::derive("wrong", &[7u8; SALT_LEN]);
1080 assert!(matches!(
1082 VaultReader::open(file, &wrong),
1083 Err(VaultError::DecryptionFailed)
1084 ));
1085 }
1086
1087 #[test]
1088 fn v2_salt_mismatch_rejected() {
1089 let m = test_master();
1090 let file = sample_file(&m);
1091 let stale = MasterSecret::derive("test-pass", &[9u8; SALT_LEN]);
1093 assert!(matches!(
1094 VaultReader::open(file, &stale),
1095 Err(VaultError::DecryptionFailed)
1096 ));
1097 }
1098
1099 #[test]
1100 fn v2_ciphertext_tamper_detected() {
1101 let m = test_master();
1102 let mut file = sample_file(&m);
1103 let n = file.len();
1104 file[n - MAC_LEN - 1] ^= 0xFF; assert!(VaultReader::open(file, &m).is_err());
1106 }
1107
1108 #[test]
1109 fn v2_record_deletion_detected() {
1110 let m = test_master();
1111 let file = sample_file(&m);
1112 let r = VaultReader::open(file.clone(), &m).unwrap();
1114 let victim = r.records.iter().find(|r| r.name == "DB_URL").unwrap();
1115 let mut forged = Vec::new();
1116 forged.extend_from_slice(&file[..victim.rec_off]);
1117 forged.extend_from_slice(&file[victim.end()..]);
1118 forged[24..28].copy_from_slice(&2u32.to_be_bytes());
1119 assert!(VaultReader::open(forged, &m).is_err());
1120 }
1121
1122 #[test]
1123 fn v2_transplant_rejected_by_aad() {
1124 let m = test_master();
1130 let f2 = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1131 let r2 = VaultReader::open(f2.clone(), &m).unwrap();
1132 let ra = &r2.records[0];
1133 let rb = &r2.records[1];
1134 let mut forged = f2.clone();
1135 forged[ra.nonce_off..ra.end()].copy_from_slice(&f2[rb.nonce_off..rb.end()]);
1136 forged[rb.nonce_off..rb.end()].copy_from_slice(&f2[ra.nonce_off..ra.end()]);
1137 let body_end = forged.len() - MAC_LEN;
1140 let mk = m.mac_key();
1141 let mut mac = new_hmac(mk.as_ref());
1142 mac.update(&forged[..body_end]);
1143 let tag = mac.finalize().into_bytes();
1144 forged[body_end..].copy_from_slice(&tag);
1145 let rf = VaultReader::open(forged, &m).unwrap();
1146 assert!(rf.decrypt_one(&m, "AAA").is_err());
1147 assert!(rf.decrypt_one(&m, "BBB").is_err());
1148 }
1149
1150 #[test]
1151 fn v2_reorder_rejected() {
1152 let m = test_master();
1154 let f = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1155 let r = VaultReader::open(f.clone(), &m).unwrap();
1156 let (ra, rb) = (&r.records[0], &r.records[1]);
1157 let mut forged = f[..V2_HEADER_LEN].to_vec();
1158 forged.extend_from_slice(&f[rb.rec_off..rb.end()]);
1159 forged.extend_from_slice(&f[ra.rec_off..ra.end()]);
1160 let body_end = forged.len();
1161 let mk = m.mac_key();
1162 let mut mac = new_hmac(mk.as_ref());
1163 mac.update(&forged);
1164 forged.extend_from_slice(&mac.finalize().into_bytes());
1165 let _ = body_end;
1166 assert!(matches!(
1167 VaultReader::open(forged, &m),
1168 Err(VaultError::MalformedData)
1169 ));
1170 }
1171
1172 #[test]
1173 fn v2_truncation_and_bounds() {
1174 let m = test_master();
1175 let file = sample_file(&m);
1176 for cut in [0, 3, V2_HEADER_LEN - 1, V2_HEADER_LEN, V2_HEADER_LEN + 5, file.len() - 1] {
1178 assert!(VaultReader::open(file[..cut].to_vec(), &m).is_err());
1179 }
1180 let r = VaultReader::open(file.clone(), &m).unwrap();
1182 let rec = &r.records[0];
1183 let mut forged = file.clone();
1184 let off = rec.tag_off() + TAG_LEN;
1185 forged[off..off + 4].copy_from_slice(&u32::MAX.to_be_bytes());
1186 assert!(VaultReader::open(forged, &m).is_err());
1187 }
1188
1189 #[test]
1190 fn v2_unknown_scheme_and_flags_fail_closed() {
1191 let m = test_master();
1192 let file = sample_file(&m);
1193 let mut s = file.clone();
1194 s[V2_HEADER_LEN] = 0x02; assert!(matches!(
1196 VaultReader::open(s, &m),
1197 Err(VaultError::UnknownScheme(0x02))
1198 ));
1199 let mut fl = file.clone();
1200 fl[6] = 0x80; assert!(matches!(
1202 VaultReader::open(fl, &m),
1203 Err(VaultError::UnsupportedFlags(_))
1204 ));
1205 let mut kdf = file;
1206 kdf[5] = 0x02;
1207 assert!(matches!(
1208 VaultReader::open(kdf, &m),
1209 Err(VaultError::UnsupportedKdf(0x02))
1210 ));
1211 }
1212
1213 #[test]
1214 fn v2_padding_buckets_hide_length() {
1215 let m = test_master();
1216 let f1 = v2_create(&m, &[("K".into(), zv("a"))]).unwrap();
1219 let f2 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyza"))]).unwrap();
1220 assert_eq!(f1.len(), f2.len());
1221 let f3 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyzabc"))]).unwrap();
1223 assert_eq!(f3.len(), f1.len() + PAD_BLOCK);
1224 let r = VaultReader::open(f3, &m).unwrap();
1226 assert_eq!(&*r.decrypt_one(&m, "K").unwrap(), b"abcdefghijklmnopqrstuvwxyzabc");
1227 }
1228
1229 #[test]
1230 fn v2_splice_upsert_delete_without_reading() {
1231 let m = test_master();
1232 let file = sample_file(&m);
1233 let r = VaultReader::open(file, &m).unwrap();
1234 let out = r
1235 .splice(
1236 &m,
1237 &[("NEW_KEY".into(), zv("fresh")), ("API_KEY".into(), zv("rotated"))],
1238 &["DB_URL".to_string()],
1239 )
1240 .unwrap();
1241 let r2 = VaultReader::open(out, &m).unwrap();
1242 assert_eq!(
1243 r2.names().collect::<Vec<_>>(),
1244 vec!["API_KEY", "NEW_KEY", "proj/TOKEN"]
1245 );
1246 assert_eq!(&*r2.decrypt_one(&m, "API_KEY").unwrap(), b"rotated");
1247 assert_eq!(&*r2.decrypt_one(&m, "NEW_KEY").unwrap(), b"fresh");
1248 assert_eq!(&*r2.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1250 }
1251
1252 #[test]
1253 fn v2_duplicate_name_rejected() {
1254 let m = test_master();
1255 let f = v2_create(&m, &[("AAA".into(), zv("x")), ("AAB".into(), zv("x"))]).unwrap();
1256 let r = VaultReader::open(f.clone(), &m).unwrap();
1257 let ra = &r.records[0];
1258 let mut forged = f.clone();
1260 let rb = &r.records[1];
1261 forged[rb.rec_off + 3..rb.rec_off + 6].copy_from_slice(b"AAA");
1262 let _ = ra;
1263 assert!(matches!(
1264 VaultReader::open(forged, &m),
1265 Err(VaultError::MalformedData)
1266 ));
1267 }
1268
1269 #[test]
1270 fn v2_storage_name_grammar() {
1271 assert!(is_valid_storage_name("API_KEY"));
1272 assert!(is_valid_storage_name("proj/API_KEY"));
1273 assert!(is_valid_storage_name("my.proj/prod-db-password"));
1274 assert!(!is_valid_storage_name("a/b/c"));
1275 assert!(!is_valid_storage_name("/KEY"));
1276 assert!(!is_valid_storage_name("proj/"));
1277 assert!(!is_valid_storage_name(""));
1278 assert!(!is_valid_storage_name("has.dot")); }
1280
1281 fn hex(bytes: &[u8]) -> String {
1289 bytes.iter().map(|b| format!("{b:02x}")).collect()
1290 }
1291
1292 #[test]
1297 fn golden_file_kat() {
1298 let data = include_bytes!("../tests/golden/golden_v2.qvlt").to_vec();
1299 let salt = v2_salt(&data).unwrap();
1300 let m = MasterSecret::derive("golden-pass-do-not-change", &salt);
1301 let r = VaultReader::open(data, &m).unwrap();
1302 assert_eq!(
1303 r.names().collect::<Vec<_>>(),
1304 vec!["BIGKEY", "GREETING", "MULTILINE", "app1/API_KEY", "app2/API_KEY"]
1305 );
1306 assert_eq!(&*r.decrypt_one(&m, "GREETING").unwrap(), b"hello-world");
1307 assert_eq!(&*r.decrypt_one(&m, "MULTILINE").unwrap(), b"line1\nline2");
1308 assert_eq!(&*r.decrypt_one(&m, "app1/API_KEY").unwrap(), b"value-app1");
1309 assert_eq!(&*r.decrypt_one(&m, "app2/API_KEY").unwrap(), b"value-app2");
1310 assert_eq!(
1311 &*r.decrypt_one(&m, "BIGKEY").unwrap(),
1312 b"0123456789012345678901234567890123456789012345678901234567890123"
1313 );
1314 }
1315
1316 #[test]
1322 fn derivation_kats_cross_impl() {
1323 let salt: [u8; SALT_LEN] = core::array::from_fn(|i| i as u8);
1324 let m = MasterSecret::derive("golden-pass", &salt);
1325 assert_eq!(
1326 hex(m.secret.as_ref()),
1327 "15bd048606d475f651612dd37b8dcd2e8e11534c8b689d0a6a44d1b8819eff7d"
1328 );
1329 assert_eq!(
1330 hex(m.entry_key("API_KEY").as_ref()),
1331 "8c92f6013a1f708304488ce1d7237b1389d37c3194d3c05fa9dddc1350d05a5c"
1332 );
1333 assert_eq!(
1334 hex(m.mac_key().as_ref()),
1335 "ae55458d0fb8334b5642ff9db564921ecfd59454e31b8fe33d909e4d71ff0fb9"
1336 );
1337 assert_eq!(
1338 hex(m.registry_key().as_ref()),
1339 "eb12b87f7bb7e45749bc5f998d1be4f34a04ef238ca881ecb4eccdd71d6acf4d"
1340 );
1341 }
1342
1343 #[test]
1346 fn hkdf_rfc5869_a1() {
1347 let ikm = [0x0bu8; 22];
1348 let salt: Vec<u8> = (0x00..=0x0c).collect();
1349 let info: Vec<u8> = (0xf0..=0xf9).collect();
1350 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), &ikm);
1351 let mut okm = [0u8; 42];
1352 hk.expand(&info, &mut okm).unwrap();
1353 assert_eq!(
1354 hex(&okm),
1355 "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
1356 );
1357 }
1358
1359 #[test]
1362 fn hmac_rfc4231_case1() {
1363 let mut mac = new_hmac(&[0x0bu8; 20]);
1364 mac.update(b"Hi There");
1365 assert_eq!(
1366 hex(&mac.finalize().into_bytes()),
1367 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1368 );
1369 }
1370
1371 #[test]
1376 fn mutation_smoke_no_panic() {
1377 let m = test_master();
1378 let base = sample_file(&m);
1379 let mut s: u64 = 0x9E3779B97F4A7C15;
1381 let mut rng = move || {
1382 s ^= s >> 12;
1383 s ^= s << 25;
1384 s ^= s >> 27;
1385 s = s.wrapping_mul(0x2545F4914F6CDD1D);
1386 s
1387 };
1388 for i in 0..5000u64 {
1389 let mut d = base.clone();
1390 match i % 4 {
1391 0 => {
1392 for _ in 0..=(rng() % 3) {
1394 let idx = (rng() as usize) % d.len();
1395 d[idx] ^= (rng() as u8) | 1;
1396 }
1397 }
1398 1 => {
1399 d.truncate((rng() as usize) % (d.len() + 1));
1401 }
1402 2 => {
1403 let start = (rng() as usize) % d.len();
1405 let end = (start + 1 + (rng() as usize) % 64).min(d.len());
1406 for b in &mut d[start..end] {
1407 *b = rng() as u8;
1408 }
1409 }
1410 _ => {
1411 let n = (rng() as usize) % 600;
1413 d = (0..n).map(|_| rng() as u8).collect();
1414 }
1415 }
1416 if let Ok(r) = VaultReader::open(d, &m) {
1417 for name in r.names().map(String::from).collect::<Vec<_>>() {
1418 let _ = r.decrypt_one(&m, &name);
1419 }
1420 }
1421 }
1422 }
1423
1424 #[test]
1425 fn raw_key_master_round_trip() {
1426 let key = [0x5au8; 32];
1429 let salt = [3u8; SALT_LEN];
1430 let m = MasterSecret::from_raw_key(&key, &salt);
1431 let file = v2_create(
1432 &m,
1433 &[("DATABASE_URL".into(), zv("postgres://x")), ("TOKEN".into(), zv("t-1"))],
1434 )
1435 .unwrap();
1436 let r = VaultReader::open(file.clone(), &m).unwrap();
1437 assert_eq!(&*r.decrypt_one(&m, "DATABASE_URL").unwrap(), b"postgres://x");
1438 assert_eq!(&*r.decrypt_one(&m, "TOKEN").unwrap(), b"t-1");
1439
1440 let other = MasterSecret::from_raw_key(&[0xa5u8; 32], &salt);
1442 assert!(matches!(
1443 VaultReader::open(file, &other),
1444 Err(VaultError::DecryptionFailed)
1445 ));
1446 }
1447
1448 #[test]
1449 fn raw_blob_round_trip_and_tamper() {
1450 let key = [42u8; 32];
1451 let enc = encrypt_raw_blob(b"registry-json", &key).unwrap();
1452 assert_eq!(decrypt_raw_blob(&enc, &key).unwrap(), b"registry-json");
1453 assert!(decrypt_raw_blob(&enc, &[43u8; 32]).is_err());
1454 let mut t = enc.clone();
1455 let n = t.len();
1456 t[n - 1] ^= 1;
1457 assert!(decrypt_raw_blob(&t, &key).is_err());
1458 }
1459
1460 #[test]
1461 fn v1_still_readable() {
1462 let mut vault = Vault::new();
1464 vault.set("OLD", "value");
1465 let v1 = vault.encrypt("pass").unwrap();
1466 assert!(is_v1(&v1));
1467 assert!(!is_v2(&v1));
1468 let back = Vault::decrypt(&v1, "pass").unwrap();
1469 assert_eq!(back.get("OLD"), Some("value"));
1470 }
1471
1472 #[test]
1473 fn parse_env() {
1474 let input = r#"
1475export API_KEY="sk-123"
1476DB_URL=postgres://localhost
1477# comment
1478export EMPTY=
1479
1480BARE=value
1481"#;
1482 let pairs = parse_env_lines(input);
1483 assert_eq!(pairs.len(), 3);
1484 assert_eq!(pairs[0], ("API_KEY".into(), "sk-123".into()));
1486 assert_eq!(pairs[1], ("DB_URL".into(), "postgres://localhost".into()));
1487 assert_eq!(pairs[2], ("BARE".into(), "value".into()));
1488 }
1489}