1use std::collections::BTreeMap;
51use std::io::Write;
52
53use aes_gcm::aead::{AeadInOut, Generate, KeyInit};
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 getrandom::fill(&mut salt).expect("OS RNG failure");
211 let nonce = Nonce::generate();
212
213 let key = derive_key(passphrase, &salt);
214 let cipher = Aes256Gcm::new(&key);
215
216 let tag = cipher
217 .encrypt_inout_detached(&nonce, b"", plaintext.as_mut_slice().into())
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<_> = nonce_bytes.try_into().expect("nonce length is structural");
257 let tag: &Tag = tag_bytes.try_into().expect("tag length is structural");
258
259 let mut buf = ciphertext.to_vec();
260 cipher
261 .decrypt_inout_detached(nonce, b"", buf.as_mut_slice().into(), 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 getrandom::fill(&mut buf).expect("OS RNG failure");
375 buf
376}
377
378pub fn random_salt() -> [u8; SALT_LEN] {
381 let mut salt = [0u8; SALT_LEN];
382 getrandom::fill(&mut salt).expect("OS RNG failure");
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 getrandom::fill(&mut salt).expect("OS RNG failure");
393 let nonce = Nonce::generate();
394 let key = derive_key(passphrase, &salt);
395 let cipher = Aes256Gcm::new(&key);
396 let tag = cipher
397 .encrypt_inout_detached(&nonce, b"", buf.as_mut_slice().into())
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<_> = nonce_bytes.try_into().expect("nonce length is structural");
431 let tag: &Tag = tag_bytes.try_into().expect("tag length is structural");
432
433 let mut buf = ciphertext.to_vec();
434 cipher
435 .decrypt_inout_detached(nonce, b"", buf.as_mut_slice().into(), 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>::try_from(&key[..]).expect("PBKDF2 output is 32 bytes")
498}
499
500use hmac::Mac;
505
506fn new_hmac(key: &[u8]) -> HmacSha256 {
510 <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length")
511}
512
513pub const V2_VERSION: u8 = 0x02;
515pub const KDF_PBKDF2: u8 = 0x01;
517pub const SCHEME_HKDF: u8 = 0x01;
519pub const V2_HEADER_LEN: usize = 4 + 1 + 1 + 2 + SALT_LEN + 4; pub const MAC_LEN: usize = 32;
523pub const PAD_BLOCK: usize = 32;
526pub const MAX_NAME_LEN: usize = 513;
528const MAX_PADDED: usize = (4 + MAX_VALUE_LEN).div_ceil(PAD_BLOCK) * PAD_BLOCK;
530
531type HmacSha256 = hmac::Hmac<sha2::Sha256>;
532
533pub fn is_valid_storage_name(name: &str) -> bool {
535 match name.split_once('/') {
536 Some((project, key)) => is_valid_project(project) && is_valid_key(key),
537 None => is_valid_key(name),
538 }
539}
540
541pub struct MasterSecret {
546 secret: Zeroizing<[u8; 32]>,
547 salt: [u8; SALT_LEN],
548}
549
550impl MasterSecret {
551 pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Self {
554 let secret = Zeroizing::new(pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(
555 passphrase.as_bytes(),
556 salt,
557 ITERATIONS,
558 ));
559 Self { secret, salt: *salt }
560 }
561
562 pub fn from_raw_key(key: &[u8; 32], salt: &[u8; SALT_LEN]) -> Self {
568 Self { secret: Zeroizing::new(*key), salt: *salt }
569 }
570
571 pub fn salt(&self) -> &[u8; SALT_LEN] {
572 &self.salt
573 }
574
575 fn hkdf(&self, info: &[u8]) -> Zeroizing<[u8; 32]> {
576 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&self.salt), self.secret.as_ref());
577 let mut okm = Zeroizing::new([0u8; 32]);
578 hk.expand(info, okm.as_mut())
579 .expect("32 bytes is a valid HKDF-SHA256 output length");
580 okm
581 }
582
583 fn entry_key(&self, name: &str) -> Zeroizing<[u8; 32]> {
586 let mut info = Vec::with_capacity(14 + name.len());
587 info.extend_from_slice(b"qvlt2:entry:");
588 info.extend_from_slice(&(name.len() as u16).to_be_bytes());
589 info.extend_from_slice(name.as_bytes());
590 self.hkdf(&info)
591 }
592
593 fn mac_key(&self) -> Zeroizing<[u8; 32]> {
594 self.hkdf(b"qvlt2:manifest")
595 }
596
597 pub fn registry_key(&self) -> Zeroizing<[u8; 32]> {
599 self.hkdf(b"qvlt2:registry")
600 }
601}
602
603pub fn is_v2(data: &[u8]) -> bool {
606 data.len() >= V2_HEADER_LEN && data[0..4] == MAGIC && data[4] == V2_VERSION
607}
608
609pub fn is_v1(data: &[u8]) -> bool {
611 data.len() >= HEADER_LEN && data[0..4] == MAGIC && data[4] == VERSION
612}
613
614pub fn v2_salt(data: &[u8]) -> Result<[u8; SALT_LEN], VaultError> {
618 if data.len() < V2_HEADER_LEN + MAC_LEN {
619 return Err(VaultError::TooSmall);
620 }
621 if data[0..4] != MAGIC {
622 return Err(VaultError::BadMagic);
623 }
624 if data[4] != V2_VERSION {
625 return Err(VaultError::UnsupportedVersion(data[4]));
626 }
627 if data[5] != KDF_PBKDF2 {
628 return Err(VaultError::UnsupportedKdf(data[5]));
629 }
630 let flags = u16::from_be_bytes([data[6], data[7]]);
631 if flags != 0 {
632 return Err(VaultError::UnsupportedFlags(flags));
633 }
634 let mut salt = [0u8; SALT_LEN];
635 salt.copy_from_slice(&data[8..8 + SALT_LEN]);
636 Ok(salt)
637}
638
639struct RecordMeta {
641 scheme: u8,
642 name: String,
643 rec_off: usize,
645 nonce_off: usize,
647 ct_len: usize,
648}
649
650impl RecordMeta {
651 fn tag_off(&self) -> usize {
652 self.nonce_off + NONCE_LEN
653 }
654 fn ct_off(&self) -> usize {
655 self.tag_off() + TAG_LEN + 4
656 }
657 fn end(&self) -> usize {
658 self.ct_off() + self.ct_len
659 }
660}
661
662pub struct VaultReader {
666 data: Vec<u8>,
667 salt: [u8; SALT_LEN],
668 records: Vec<RecordMeta>,
669}
670
671impl VaultReader {
672 pub fn open(data: Vec<u8>, master: &MasterSecret) -> Result<Self, VaultError> {
676 let salt = v2_salt(&data)?;
677 if salt != master.salt {
678 return Err(VaultError::DecryptionFailed);
680 }
681 let count = u32::from_be_bytes([data[24], data[25], data[26], data[27]]) as usize;
682 let body_end = data.len() - MAC_LEN;
683
684 let mut records = Vec::new();
685 let mut pos = V2_HEADER_LEN;
686 for _ in 0..count {
687 if pos + 3 > body_end {
690 return Err(VaultError::MalformedData);
691 }
692 let scheme = data[pos];
693 if scheme != SCHEME_HKDF {
694 return Err(VaultError::UnknownScheme(scheme));
697 }
698 let name_len = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
699 if name_len == 0 || name_len > MAX_NAME_LEN || pos + 3 + name_len + NONCE_LEN + TAG_LEN + 4 > body_end {
700 return Err(VaultError::MalformedData);
701 }
702 let name = std::str::from_utf8(&data[pos + 3..pos + 3 + name_len])
703 .map_err(|_| VaultError::MalformedData)?
704 .to_string();
705 if !is_valid_storage_name(&name) {
706 return Err(VaultError::MalformedData);
707 }
708 if let Some(prev) = records.last() {
709 let prev: &RecordMeta = prev;
710 if prev.name.as_bytes() >= name.as_bytes() {
711 return Err(VaultError::MalformedData);
713 }
714 }
715 let nonce_off = pos + 3 + name_len;
716 let ct_len_off = nonce_off + NONCE_LEN + TAG_LEN;
717 let ct_len = u32::from_be_bytes([
718 data[ct_len_off],
719 data[ct_len_off + 1],
720 data[ct_len_off + 2],
721 data[ct_len_off + 3],
722 ]) as usize;
723 if ct_len == 0
724 || ct_len % PAD_BLOCK != 0
725 || ct_len > MAX_PADDED
726 || ct_len_off + 4 + ct_len > body_end
727 {
728 return Err(VaultError::MalformedData);
729 }
730 records.push(RecordMeta { scheme, name, rec_off: pos, nonce_off, ct_len });
731 pos = ct_len_off + 4 + ct_len;
732 }
733 if pos != body_end {
734 return Err(VaultError::MalformedData);
736 }
737
738 let mac_key = master.mac_key();
739 let mut mac = new_hmac(mac_key.as_ref());
740 mac.update(&data[..body_end]);
741 mac.verify_slice(&data[body_end..])
743 .map_err(|_| VaultError::DecryptionFailed)?;
744
745 Ok(Self { data, salt, records })
746 }
747
748 pub fn names(&self) -> impl Iterator<Item = &str> {
750 self.records.iter().map(|r| r.name.as_str())
751 }
752
753 pub fn len(&self) -> usize {
754 self.records.len()
755 }
756
757 pub fn is_empty(&self) -> bool {
758 self.records.is_empty()
759 }
760
761 pub fn contains(&self, name: &str) -> bool {
762 self.find(name).is_some()
763 }
764
765 fn find(&self, name: &str) -> Option<&RecordMeta> {
766 self.records
767 .binary_search_by(|r| r.name.as_str().cmp(name))
768 .ok()
769 .map(|i| &self.records[i])
770 }
771
772 pub fn decrypt_one(
775 &self,
776 master: &MasterSecret,
777 name: &str,
778 ) -> Result<Zeroizing<Vec<u8>>, VaultError> {
779 let rec = self.find(name).ok_or(VaultError::NotFound)?;
780 let key = master.entry_key(name);
781 let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("derived keys are 32 bytes"));
782 let nonce: &Nonce<_> = (&self.data[rec.nonce_off..rec.nonce_off + NONCE_LEN])
783 .try_into()
784 .expect("nonce length is structural");
785 let tag: &Tag = (&self.data[rec.tag_off()..rec.tag_off() + TAG_LEN])
786 .try_into()
787 .expect("tag length is structural");
788 let aad = record_aad(rec.scheme, name);
789
790 let mut body =
791 Zeroizing::new(self.data[rec.ct_off()..rec.ct_off() + rec.ct_len].to_vec());
792 cipher
793 .decrypt_inout_detached(nonce, &aad, (body.as_mut_slice()).into(), tag)
794 .map_err(|_| VaultError::DecryptionFailed)?;
795
796 let true_len =
797 u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
798 if true_len > MAX_VALUE_LEN || 4 + true_len > body.len() {
799 return Err(VaultError::MalformedData);
800 }
801 Ok(Zeroizing::new(body[4..4 + true_len].to_vec()))
802 }
803
804 pub fn decrypt_all(&self, master: &MasterSecret) -> Result<Vault, VaultError> {
807 let mut entries = BTreeMap::new();
808 for r in &self.records {
809 let v = self.decrypt_one(master, &r.name)?;
810 let s = String::from_utf8_lossy(&v).into_owned();
811 entries.insert(r.name.clone(), s);
812 }
813 Ok(Vault { entries })
814 }
815
816 pub fn splice(
820 &self,
821 master: &MasterSecret,
822 upserts: &[(String, Zeroizing<Vec<u8>>)],
823 deletes: &[String],
824 ) -> Result<Vec<u8>, VaultError> {
825 enum Src<'a> {
826 Keep(&'a RecordMeta),
827 New(&'a str, &'a [u8]),
828 }
829 let mut merged: BTreeMap<&str, Src> = self
830 .records
831 .iter()
832 .map(|r| (r.name.as_str(), Src::Keep(r)))
833 .collect();
834 for name in deletes {
835 merged.remove(name.as_str());
836 }
837 for (name, value) in upserts {
838 if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
839 return Err(VaultError::MalformedData);
840 }
841 merged.insert(&name[..], Src::New(name, value));
842 }
843
844 let mut out = Vec::new();
845 write_v2_header(&mut out, &self.salt, merged.len() as u32)?;
846 for (name, src) in &merged {
847 match src {
848 Src::Keep(rec) => out.extend_from_slice(&self.data[rec.rec_off..rec.end()]),
849 Src::New(name, value) => write_record(&mut out, master, name, value)?,
850 }
851 let _ = name;
852 }
853 append_mac(&mut out, master);
854 Ok(out)
855 }
856}
857
858fn record_aad(scheme: u8, name: &str) -> Vec<u8> {
859 let mut aad = Vec::with_capacity(2 + name.len());
860 aad.push(V2_VERSION);
861 aad.push(scheme);
862 aad.extend_from_slice(name.as_bytes());
863 aad
864}
865
866fn write_v2_header(out: &mut Vec<u8>, salt: &[u8; SALT_LEN], count: u32) -> Result<(), VaultError> {
867 out.write_all(&MAGIC)?;
868 out.write_all(&[V2_VERSION, KDF_PBKDF2, 0, 0])?;
869 out.write_all(salt)?;
870 out.write_all(&count.to_be_bytes())?;
871 Ok(())
872}
873
874fn write_record(
875 out: &mut Vec<u8>,
876 master: &MasterSecret,
877 name: &str,
878 value: &[u8],
879) -> Result<(), VaultError> {
880 let padded = (4 + value.len()).div_ceil(PAD_BLOCK) * PAD_BLOCK;
882 let mut body = Zeroizing::new(vec![0u8; padded]);
883 body[..4].copy_from_slice(&(value.len() as u32).to_be_bytes());
884 body[4..4 + value.len()].copy_from_slice(value);
885
886 let key = master.entry_key(name);
887 let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("derived keys are 32 bytes"));
888 let nonce = Nonce::generate();
889 let aad = record_aad(SCHEME_HKDF, name);
890 let tag = cipher
891 .encrypt_inout_detached(&nonce, &aad, (body.as_mut_slice()).into())
892 .map_err(|_| VaultError::EncryptionFailed)?;
893
894 out.push(SCHEME_HKDF);
895 out.write_all(&(name.len() as u16).to_be_bytes())?;
896 out.write_all(name.as_bytes())?;
897 out.write_all(nonce.as_slice())?;
898 out.write_all(tag.as_slice())?;
899 out.write_all(&(padded as u32).to_be_bytes())?;
900 out.write_all(&body)?;
901 Ok(())
902}
903
904fn append_mac(out: &mut Vec<u8>, master: &MasterSecret) {
905 let mac_key = master.mac_key();
906 let mut mac = new_hmac(mac_key.as_ref());
907 mac.update(out);
908 out.extend_from_slice(&mac.finalize().into_bytes());
909}
910
911pub fn v2_create(
914 master: &MasterSecret,
915 entries: &[(String, Zeroizing<Vec<u8>>)],
916) -> Result<Vec<u8>, VaultError> {
917 let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
918 for (name, value) in entries {
919 if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
920 return Err(VaultError::MalformedData);
921 }
922 sorted.insert(&name[..], value);
923 }
924 let mut out = Vec::new();
925 write_v2_header(&mut out, &master.salt, sorted.len() as u32)?;
926 for (name, value) in &sorted {
927 write_record(&mut out, master, name, value)?;
928 }
929 append_mac(&mut out, master);
930 Ok(out)
931}
932
933const RAW_MAGIC: [u8; 4] = *b"QRG2";
940const RAW_AAD: &[u8] = b"qvlt2:registry";
941
942pub fn encrypt_raw_blob(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
943 let cipher = Aes256Gcm::new(key.try_into().expect("derived keys are 32 bytes"));
944 let nonce = Nonce::generate();
945 let mut buf = plaintext.to_vec();
946 let tag = cipher
947 .encrypt_inout_detached(&nonce, RAW_AAD, buf.as_mut_slice().into())
948 .map_err(|_| VaultError::EncryptionFailed)?;
949 let mut out = Vec::with_capacity(4 + NONCE_LEN + TAG_LEN + buf.len());
950 out.write_all(&RAW_MAGIC)?;
951 out.write_all(nonce.as_slice())?;
952 out.write_all(tag.as_slice())?;
953 out.write_all(&buf)?;
954 buf.zeroize();
955 Ok(out)
956}
957
958pub fn decrypt_raw_blob(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
959 if data.len() < 4 + NONCE_LEN + TAG_LEN {
960 return Err(VaultError::TooSmall);
961 }
962 if data[0..4] != RAW_MAGIC {
963 return Err(VaultError::BadMagic);
964 }
965 let nonce: &Nonce<_> = (&data[4..4 + NONCE_LEN]).try_into().expect("nonce length is structural");
966 let tag: &Tag = (&data[4 + NONCE_LEN..4 + NONCE_LEN + TAG_LEN]).try_into().expect("tag length is structural");
967 let cipher = Aes256Gcm::new(key.try_into().expect("derived keys are 32 bytes"));
968 let mut buf = data[4 + NONCE_LEN + TAG_LEN..].to_vec();
969 cipher
970 .decrypt_inout_detached(nonce, RAW_AAD, buf.as_mut_slice().into(), tag)
971 .map_err(|_| VaultError::DecryptionFailed)?;
972 Ok(buf)
973}
974
975#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[test]
982 fn round_trip() {
983 let mut vault = Vault::new();
984 vault.set("API_KEY", "sk-secret-123");
985 vault.set("DB_URL", "postgres://localhost/mydb");
986
987 let encrypted = vault.encrypt("test-pass").unwrap();
988 let decrypted = Vault::decrypt(&encrypted, "test-pass").unwrap();
989
990 assert_eq!(decrypted.get("API_KEY"), Some("sk-secret-123"));
991 assert_eq!(decrypted.get("DB_URL"), Some("postgres://localhost/mydb"));
992 assert_eq!(decrypted.len(), 2);
993 }
994
995 #[test]
996 fn wrong_passphrase() {
997 let vault = Vault::new();
998 let encrypted = vault.encrypt("correct").unwrap();
999 assert!(matches!(
1000 Vault::decrypt(&encrypted, "wrong"),
1001 Err(VaultError::DecryptionFailed)
1002 ));
1003 }
1004
1005 #[test]
1006 fn tamper_detection() {
1007 let mut vault = Vault::new();
1008 vault.set("KEY", "value");
1009 let mut encrypted = vault.encrypt("pass").unwrap();
1010 if let Some(last) = encrypted.last_mut() {
1012 *last ^= 0xFF;
1013 }
1014 assert!(Vault::decrypt(&encrypted, "pass").is_err());
1015 }
1016
1017 #[test]
1018 fn fresh_nonce_per_encrypt() {
1019 let vault = Vault::new();
1020 let a = vault.encrypt("pass").unwrap();
1021 let b = vault.encrypt("pass").unwrap();
1022 assert_ne!(a, b);
1024 }
1025
1026 #[test]
1027 fn shell_escaping() {
1028 let mut vault = Vault::new();
1029 vault.set("KEY", "it's a \"test\"");
1030 let exports = vault.to_shell_exports();
1031 assert!(exports.contains("'it'\\''s a \"test\"'"));
1032 }
1033
1034 #[test]
1035 fn valid_keys() {
1036 assert!(is_valid_key("API_KEY"));
1037 assert!(is_valid_key("key123"));
1038 assert!(is_valid_key("prod-db-password")); assert!(is_valid_key("metatron-enterprise-lock"));
1040 assert!(!is_valid_key(""));
1041 assert!(!is_valid_key("has space"));
1042 assert!(!is_valid_key("has.dot")); assert!(!is_valid_key("has/slash"));
1044 }
1045
1046 fn zv(s: &str) -> Zeroizing<Vec<u8>> {
1049 Zeroizing::new(s.as_bytes().to_vec())
1050 }
1051
1052 fn test_master() -> MasterSecret {
1053 MasterSecret::derive("test-pass", &[7u8; SALT_LEN])
1055 }
1056
1057 fn sample_file(master: &MasterSecret) -> Vec<u8> {
1058 v2_create(
1059 master,
1060 &[
1061 ("API_KEY".into(), zv("sk-secret-123")),
1062 ("DB_URL".into(), zv("postgres://localhost/mydb")),
1063 ("proj/TOKEN".into(), zv("t-42")),
1064 ],
1065 )
1066 .unwrap()
1067 }
1068
1069 #[test]
1070 fn v2_round_trip_one() {
1071 let m = test_master();
1072 let file = sample_file(&m);
1073 let r = VaultReader::open(file, &m).unwrap();
1074 assert_eq!(r.len(), 3);
1075 assert_eq!(&*r.decrypt_one(&m, "API_KEY").unwrap(), b"sk-secret-123");
1076 assert_eq!(&*r.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1077 assert!(matches!(r.decrypt_one(&m, "NOPE"), Err(VaultError::NotFound)));
1078 }
1079
1080 #[test]
1081 fn v2_wrong_passphrase() {
1082 let m = test_master();
1083 let file = sample_file(&m);
1084 let wrong = MasterSecret::derive("wrong", &[7u8; SALT_LEN]);
1085 assert!(matches!(
1087 VaultReader::open(file, &wrong),
1088 Err(VaultError::DecryptionFailed)
1089 ));
1090 }
1091
1092 #[test]
1093 fn v2_salt_mismatch_rejected() {
1094 let m = test_master();
1095 let file = sample_file(&m);
1096 let stale = MasterSecret::derive("test-pass", &[9u8; SALT_LEN]);
1098 assert!(matches!(
1099 VaultReader::open(file, &stale),
1100 Err(VaultError::DecryptionFailed)
1101 ));
1102 }
1103
1104 #[test]
1105 fn v2_ciphertext_tamper_detected() {
1106 let m = test_master();
1107 let mut file = sample_file(&m);
1108 let n = file.len();
1109 file[n - MAC_LEN - 1] ^= 0xFF; assert!(VaultReader::open(file, &m).is_err());
1111 }
1112
1113 #[test]
1114 fn v2_record_deletion_detected() {
1115 let m = test_master();
1116 let file = sample_file(&m);
1117 let r = VaultReader::open(file.clone(), &m).unwrap();
1119 let victim = r.records.iter().find(|r| r.name == "DB_URL").unwrap();
1120 let mut forged = Vec::new();
1121 forged.extend_from_slice(&file[..victim.rec_off]);
1122 forged.extend_from_slice(&file[victim.end()..]);
1123 forged[24..28].copy_from_slice(&2u32.to_be_bytes());
1124 assert!(VaultReader::open(forged, &m).is_err());
1125 }
1126
1127 #[test]
1128 fn v2_transplant_rejected_by_aad() {
1129 let m = test_master();
1135 let f2 = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1136 let r2 = VaultReader::open(f2.clone(), &m).unwrap();
1137 let ra = &r2.records[0];
1138 let rb = &r2.records[1];
1139 let mut forged = f2.clone();
1140 forged[ra.nonce_off..ra.end()].copy_from_slice(&f2[rb.nonce_off..rb.end()]);
1141 forged[rb.nonce_off..rb.end()].copy_from_slice(&f2[ra.nonce_off..ra.end()]);
1142 let body_end = forged.len() - MAC_LEN;
1145 let mk = m.mac_key();
1146 let mut mac = new_hmac(mk.as_ref());
1147 mac.update(&forged[..body_end]);
1148 let tag = mac.finalize().into_bytes();
1149 forged[body_end..].copy_from_slice(&tag);
1150 let rf = VaultReader::open(forged, &m).unwrap();
1151 assert!(rf.decrypt_one(&m, "AAA").is_err());
1152 assert!(rf.decrypt_one(&m, "BBB").is_err());
1153 }
1154
1155 #[test]
1156 fn v2_reorder_rejected() {
1157 let m = test_master();
1159 let f = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1160 let r = VaultReader::open(f.clone(), &m).unwrap();
1161 let (ra, rb) = (&r.records[0], &r.records[1]);
1162 let mut forged = f[..V2_HEADER_LEN].to_vec();
1163 forged.extend_from_slice(&f[rb.rec_off..rb.end()]);
1164 forged.extend_from_slice(&f[ra.rec_off..ra.end()]);
1165 let body_end = forged.len();
1166 let mk = m.mac_key();
1167 let mut mac = new_hmac(mk.as_ref());
1168 mac.update(&forged);
1169 forged.extend_from_slice(&mac.finalize().into_bytes());
1170 let _ = body_end;
1171 assert!(matches!(
1172 VaultReader::open(forged, &m),
1173 Err(VaultError::MalformedData)
1174 ));
1175 }
1176
1177 #[test]
1178 fn v2_truncation_and_bounds() {
1179 let m = test_master();
1180 let file = sample_file(&m);
1181 for cut in [0, 3, V2_HEADER_LEN - 1, V2_HEADER_LEN, V2_HEADER_LEN + 5, file.len() - 1] {
1183 assert!(VaultReader::open(file[..cut].to_vec(), &m).is_err());
1184 }
1185 let r = VaultReader::open(file.clone(), &m).unwrap();
1187 let rec = &r.records[0];
1188 let mut forged = file.clone();
1189 let off = rec.tag_off() + TAG_LEN;
1190 forged[off..off + 4].copy_from_slice(&u32::MAX.to_be_bytes());
1191 assert!(VaultReader::open(forged, &m).is_err());
1192 }
1193
1194 #[test]
1195 fn v2_unknown_scheme_and_flags_fail_closed() {
1196 let m = test_master();
1197 let file = sample_file(&m);
1198 let mut s = file.clone();
1199 s[V2_HEADER_LEN] = 0x02; assert!(matches!(
1201 VaultReader::open(s, &m),
1202 Err(VaultError::UnknownScheme(0x02))
1203 ));
1204 let mut fl = file.clone();
1205 fl[6] = 0x80; assert!(matches!(
1207 VaultReader::open(fl, &m),
1208 Err(VaultError::UnsupportedFlags(_))
1209 ));
1210 let mut kdf = file;
1211 kdf[5] = 0x02;
1212 assert!(matches!(
1213 VaultReader::open(kdf, &m),
1214 Err(VaultError::UnsupportedKdf(0x02))
1215 ));
1216 }
1217
1218 #[test]
1219 fn v2_padding_buckets_hide_length() {
1220 let m = test_master();
1221 let f1 = v2_create(&m, &[("K".into(), zv("a"))]).unwrap();
1224 let f2 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyza"))]).unwrap();
1225 assert_eq!(f1.len(), f2.len());
1226 let f3 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyzabc"))]).unwrap();
1228 assert_eq!(f3.len(), f1.len() + PAD_BLOCK);
1229 let r = VaultReader::open(f3, &m).unwrap();
1231 assert_eq!(&*r.decrypt_one(&m, "K").unwrap(), b"abcdefghijklmnopqrstuvwxyzabc");
1232 }
1233
1234 #[test]
1235 fn v2_splice_upsert_delete_without_reading() {
1236 let m = test_master();
1237 let file = sample_file(&m);
1238 let r = VaultReader::open(file, &m).unwrap();
1239 let out = r
1240 .splice(
1241 &m,
1242 &[("NEW_KEY".into(), zv("fresh")), ("API_KEY".into(), zv("rotated"))],
1243 &["DB_URL".to_string()],
1244 )
1245 .unwrap();
1246 let r2 = VaultReader::open(out, &m).unwrap();
1247 assert_eq!(
1248 r2.names().collect::<Vec<_>>(),
1249 vec!["API_KEY", "NEW_KEY", "proj/TOKEN"]
1250 );
1251 assert_eq!(&*r2.decrypt_one(&m, "API_KEY").unwrap(), b"rotated");
1252 assert_eq!(&*r2.decrypt_one(&m, "NEW_KEY").unwrap(), b"fresh");
1253 assert_eq!(&*r2.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1255 }
1256
1257 #[test]
1258 fn v2_duplicate_name_rejected() {
1259 let m = test_master();
1260 let f = v2_create(&m, &[("AAA".into(), zv("x")), ("AAB".into(), zv("x"))]).unwrap();
1261 let r = VaultReader::open(f.clone(), &m).unwrap();
1262 let ra = &r.records[0];
1263 let mut forged = f.clone();
1265 let rb = &r.records[1];
1266 forged[rb.rec_off + 3..rb.rec_off + 6].copy_from_slice(b"AAA");
1267 let _ = ra;
1268 assert!(matches!(
1269 VaultReader::open(forged, &m),
1270 Err(VaultError::MalformedData)
1271 ));
1272 }
1273
1274 #[test]
1275 fn v2_storage_name_grammar() {
1276 assert!(is_valid_storage_name("API_KEY"));
1277 assert!(is_valid_storage_name("proj/API_KEY"));
1278 assert!(is_valid_storage_name("my.proj/prod-db-password"));
1279 assert!(!is_valid_storage_name("a/b/c"));
1280 assert!(!is_valid_storage_name("/KEY"));
1281 assert!(!is_valid_storage_name("proj/"));
1282 assert!(!is_valid_storage_name(""));
1283 assert!(!is_valid_storage_name("has.dot")); }
1285
1286 fn hex(bytes: &[u8]) -> String {
1294 bytes.iter().map(|b| format!("{b:02x}")).collect()
1295 }
1296
1297 #[test]
1302 fn golden_file_kat() {
1303 let data = include_bytes!("../tests/golden/golden_v2.qvlt").to_vec();
1304 let salt = v2_salt(&data).unwrap();
1305 let m = MasterSecret::derive("golden-pass-do-not-change", &salt);
1306 let r = VaultReader::open(data, &m).unwrap();
1307 assert_eq!(
1308 r.names().collect::<Vec<_>>(),
1309 vec!["BIGKEY", "GREETING", "MULTILINE", "app1/API_KEY", "app2/API_KEY"]
1310 );
1311 assert_eq!(&*r.decrypt_one(&m, "GREETING").unwrap(), b"hello-world");
1312 assert_eq!(&*r.decrypt_one(&m, "MULTILINE").unwrap(), b"line1\nline2");
1313 assert_eq!(&*r.decrypt_one(&m, "app1/API_KEY").unwrap(), b"value-app1");
1314 assert_eq!(&*r.decrypt_one(&m, "app2/API_KEY").unwrap(), b"value-app2");
1315 assert_eq!(
1316 &*r.decrypt_one(&m, "BIGKEY").unwrap(),
1317 b"0123456789012345678901234567890123456789012345678901234567890123"
1318 );
1319 }
1320
1321 #[test]
1327 fn derivation_kats_cross_impl() {
1328 let salt: [u8; SALT_LEN] = core::array::from_fn(|i| i as u8);
1329 let m = MasterSecret::derive("golden-pass", &salt);
1330 assert_eq!(
1331 hex(m.secret.as_ref()),
1332 "15bd048606d475f651612dd37b8dcd2e8e11534c8b689d0a6a44d1b8819eff7d"
1333 );
1334 assert_eq!(
1335 hex(m.entry_key("API_KEY").as_ref()),
1336 "8c92f6013a1f708304488ce1d7237b1389d37c3194d3c05fa9dddc1350d05a5c"
1337 );
1338 assert_eq!(
1339 hex(m.mac_key().as_ref()),
1340 "ae55458d0fb8334b5642ff9db564921ecfd59454e31b8fe33d909e4d71ff0fb9"
1341 );
1342 assert_eq!(
1343 hex(m.registry_key().as_ref()),
1344 "eb12b87f7bb7e45749bc5f998d1be4f34a04ef238ca881ecb4eccdd71d6acf4d"
1345 );
1346 }
1347
1348 #[test]
1351 fn hkdf_rfc5869_a1() {
1352 let ikm = [0x0bu8; 22];
1353 let salt: Vec<u8> = (0x00..=0x0c).collect();
1354 let info: Vec<u8> = (0xf0..=0xf9).collect();
1355 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), &ikm);
1356 let mut okm = [0u8; 42];
1357 hk.expand(&info, &mut okm).unwrap();
1358 assert_eq!(
1359 hex(&okm),
1360 "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
1361 );
1362 }
1363
1364 #[test]
1367 fn hmac_rfc4231_case1() {
1368 let mut mac = new_hmac(&[0x0bu8; 20]);
1369 mac.update(b"Hi There");
1370 assert_eq!(
1371 hex(&mac.finalize().into_bytes()),
1372 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1373 );
1374 }
1375
1376 #[test]
1381 fn mutation_smoke_no_panic() {
1382 let m = test_master();
1383 let base = sample_file(&m);
1384 let mut s: u64 = 0x9E3779B97F4A7C15;
1386 let mut rng = move || {
1387 s ^= s >> 12;
1388 s ^= s << 25;
1389 s ^= s >> 27;
1390 s = s.wrapping_mul(0x2545F4914F6CDD1D);
1391 s
1392 };
1393 for i in 0..5000u64 {
1394 let mut d = base.clone();
1395 match i % 4 {
1396 0 => {
1397 for _ in 0..=(rng() % 3) {
1399 let idx = (rng() as usize) % d.len();
1400 d[idx] ^= (rng() as u8) | 1;
1401 }
1402 }
1403 1 => {
1404 d.truncate((rng() as usize) % (d.len() + 1));
1406 }
1407 2 => {
1408 let start = (rng() as usize) % d.len();
1410 let end = (start + 1 + (rng() as usize) % 64).min(d.len());
1411 for b in &mut d[start..end] {
1412 *b = rng() as u8;
1413 }
1414 }
1415 _ => {
1416 let n = (rng() as usize) % 600;
1418 d = (0..n).map(|_| rng() as u8).collect();
1419 }
1420 }
1421 if let Ok(r) = VaultReader::open(d, &m) {
1422 for name in r.names().map(String::from).collect::<Vec<_>>() {
1423 let _ = r.decrypt_one(&m, &name);
1424 }
1425 }
1426 }
1427 }
1428
1429 #[test]
1430 fn raw_key_master_round_trip() {
1431 let key = [0x5au8; 32];
1434 let salt = [3u8; SALT_LEN];
1435 let m = MasterSecret::from_raw_key(&key, &salt);
1436 let file = v2_create(
1437 &m,
1438 &[("DATABASE_URL".into(), zv("postgres://x")), ("TOKEN".into(), zv("t-1"))],
1439 )
1440 .unwrap();
1441 let r = VaultReader::open(file.clone(), &m).unwrap();
1442 assert_eq!(&*r.decrypt_one(&m, "DATABASE_URL").unwrap(), b"postgres://x");
1443 assert_eq!(&*r.decrypt_one(&m, "TOKEN").unwrap(), b"t-1");
1444
1445 let other = MasterSecret::from_raw_key(&[0xa5u8; 32], &salt);
1447 assert!(matches!(
1448 VaultReader::open(file, &other),
1449 Err(VaultError::DecryptionFailed)
1450 ));
1451 }
1452
1453 #[test]
1454 fn raw_blob_round_trip_and_tamper() {
1455 let key = [42u8; 32];
1456 let enc = encrypt_raw_blob(b"registry-json", &key).unwrap();
1457 assert_eq!(decrypt_raw_blob(&enc, &key).unwrap(), b"registry-json");
1458 assert!(decrypt_raw_blob(&enc, &[43u8; 32]).is_err());
1459 let mut t = enc.clone();
1460 let n = t.len();
1461 t[n - 1] ^= 1;
1462 assert!(decrypt_raw_blob(&t, &key).is_err());
1463 }
1464
1465 #[test]
1466 fn v1_still_readable() {
1467 let mut vault = Vault::new();
1469 vault.set("OLD", "value");
1470 let v1 = vault.encrypt("pass").unwrap();
1471 assert!(is_v1(&v1));
1472 assert!(!is_v2(&v1));
1473 let back = Vault::decrypt(&v1, "pass").unwrap();
1474 assert_eq!(back.get("OLD"), Some("value"));
1475 }
1476
1477 #[test]
1478 fn parse_env() {
1479 let input = r#"
1480export API_KEY="sk-123"
1481DB_URL=postgres://localhost
1482# comment
1483export EMPTY=
1484
1485BARE=value
1486"#;
1487 let pairs = parse_env_lines(input);
1488 assert_eq!(pairs.len(), 3);
1489 assert_eq!(pairs[0], ("API_KEY".into(), "sk-123".into()));
1491 assert_eq!(pairs[1], ("DB_URL".into(), "postgres://localhost".into()));
1492 assert_eq!(pairs[2], ("BARE".into(), "value".into()));
1493 }
1494}