Skip to main content

vault_core/
pool.rs

1//! The key-pool state file: `pools.json`.
2//!
3//! A key pool is several interchangeable credentials for one service. Which one
4//! to hand out next, which are cooling after a rate limit, and how often each
5//! has been used are **per-machine** facts, so they are kept here rather than in
6//! the vault. Three reasons, worst failure first:
7//!
8//! 1. [`save_vault`](crate::save_vault) appends a `vault_audit` row on every
9//!    update. A CI loop calling `unv exec` would grow the hash-chained log
10//!    without bound - the same reason read events stopped being audited.
11//! 2. `save_vault` is a compare-and-swap. Concurrent reads against one vault
12//!    would collide and start returning conflicts for *reads*.
13//! 3. A vault is shared; a rotation cursor is not. Two CI runners pulling from
14//!    one remote vault want independent cursors.
15//!
16//! # Why this lives in vault-core
17//!
18//! Two programs read this file: `unv` and the desktop app. The app's
19//! `app_data_dir` and the CLI's `dirs::data_dir()/io.unenverse` resolve to the
20//! same directory, so one vault produces the same [`local_vault_key`] in both -
21//! report a key rate limited from CI and the desktop shows it cooling.
22//!
23//! That only holds while both agree on the file's shape, its path, and the
24//! cooldown arithmetic. This project has been bitten by two implementations of
25//! one format drifting apart often enough to have a convention about it, so
26//! there is exactly one implementation and both callers use it.
27//!
28//! # Shape
29//!
30//! ```json
31//! {
32//!   "version": 1,
33//!   "vaults": {
34//!     "local:/home/me/.local/share/io.unenverse/vault.db": {
35//!       "github-ci": {
36//!         "cursor": 1,
37//!         "members": {
38//!           "<entry_ck>": {
39//!             "uses": 12,
40//!             "last_used_at": "2026-08-25T11:02:31Z",
41//!             "cooling_until": "2026-08-25T11:17:31Z"
42//!           }
43//!         }
44//!       }
45//!     }
46//!   }
47//! }
48//! ```
49//!
50//! Members are keyed by [`entry_ck`](crate::entry_ck) - the same stable identity
51//! version history and audit attribution use - so renaming an entry keeps its
52//! pool state, and two entries sharing a provider never collapse into one.
53
54use serde_json::{json, Value};
55use std::path::PathBuf;
56
57/// Where `pools.json` lives.
58///
59/// `$XDG_STATE_HOME/envv/` on Unix, `%LOCALAPPDATA%\envv\` on Windows, falling
60/// back to `~/.local/state/unv/`. The same directory as the CLI's
61/// `sessions.json`.
62///
63/// Returns `None` rather than guessing when there is no home directory to work
64/// from. There is deliberately no `.` fallback: a per-directory rotation cursor
65/// is its own quiet bug, where the same pool restarts at the first key every
66/// time you change directory.
67pub fn state_path() -> Option<PathBuf> {
68    if let Some(explicit) = std::env::var_os("UNV_POOL_FILE") {
69        return Some(PathBuf::from(explicit));
70    }
71    #[cfg(windows)]
72    {
73        // %LOCALAPPDATA% is per-user and excluded from roaming profiles, so a
74        // machine-local cursor cannot be synced onto another machine by a
75        // domain profile nobody thinks about.
76        if let Some(dir) = dirs::data_local_dir() {
77            return Some(dir.join("unv").join("pools.json"));
78        }
79    }
80    if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
81        return Some(PathBuf::from(dir).join("unv").join("pools.json"));
82    }
83    dirs::home_dir().map(|h| {
84        h.join(".local")
85            .join("state")
86            .join("unv")
87            .join("pools.json")
88    })
89}
90
91/// An empty state document.
92fn empty() -> Value {
93    json!({ "version": 1, "vaults": {} })
94}
95
96/// Read the state file, or an empty document.
97///
98/// A missing, unreadable or malformed file all yield the empty document rather
99/// than an error. This file is bookkeeping: refusing to work because it is
100/// corrupt would turn a cosmetic problem into an outage, and the next write
101/// repairs it.
102pub fn load() -> Value {
103    state_path()
104        .and_then(|p| std::fs::read_to_string(p).ok())
105        .and_then(|raw| serde_json::from_str(&raw).ok())
106        .unwrap_or_else(empty)
107}
108
109/// Write the state file, creating its directory and restricting it to its owner.
110pub fn save(state: &Value) -> Result<(), String> {
111    let path = state_path().ok_or_else(|| {
112        "Cannot determine a home directory for per-user state (no $HOME on Unix, \
113         no %USERPROFILE% on Windows). Set UNV_POOL_FILE to choose the location."
114            .to_string()
115    })?;
116    if let Some(parent) = path.parent() {
117        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
118    }
119    std::fs::write(
120        &path,
121        serde_json::to_string_pretty(state).unwrap_or_default(),
122    )
123    .map_err(|e| format!("Cannot write {}: {e}", path.display()))?;
124    restrict(&path)
125}
126
127/// Restrict the state file to its owner.
128///
129/// It holds no secret, but it records which credentials this machine uses and
130/// how often - a usage map of the vault, which is the kind of thing the audit
131/// design already treats as sensitive. Same 0600 treatment as the session file.
132///
133/// The body is `#[cfg(unix)]`, so on Windows `path` genuinely is unused, and the
134/// workspace builds with warnings denied. Silenced only on the target where that
135/// is true, so a real unused binding added here later still fails on Linux.
136#[cfg_attr(not(unix), allow(unused_variables))]
137fn restrict(path: &std::path::Path) -> Result<(), String> {
138    #[cfg(unix)]
139    {
140        use std::os::unix::fs::PermissionsExt;
141        let mut perms = std::fs::metadata(path)
142            .map_err(|e| e.to_string())?
143            .permissions();
144        perms.set_mode(0o600);
145        std::fs::set_permissions(path, perms).map_err(|e| e.to_string())?;
146    }
147    Ok(())
148}
149
150/// State is filed per vault, so connecting to a different server does not
151/// inherit the last one's cursor - the same reasoning as resetting view state on
152/// a vault switch in the app.
153pub fn local_vault_key(db_path: &std::path::Path) -> String {
154    format!("local:{}", db_path.display())
155}
156
157/// The remote counterpart of [`local_vault_key`].
158pub fn remote_vault_key(base_url: &str) -> String {
159    format!("remote:{base_url}")
160}
161
162/// One member's stored state.
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
164pub struct MemberState {
165    pub uses: u64,
166    pub last_used_at: Option<String>,
167    pub cooling_until: Option<String>,
168}
169
170/// Read one member's state out of a loaded document.
171pub fn member_state(state: &Value, vault_key: &str, pool: &str, ck: &str) -> MemberState {
172    let slot = &state["vaults"][vault_key][pool]["members"][ck];
173    MemberState {
174        uses: slot["uses"].as_u64().unwrap_or(0),
175        last_used_at: slot["last_used_at"].as_str().map(str::to_string),
176        cooling_until: slot["cooling_until"].as_str().map(str::to_string),
177    }
178}
179
180/// The cursor for a pool, or 0.
181pub fn cursor(state: &Value, vault_key: &str, pool: &str) -> usize {
182    state["vaults"][vault_key][pool]["cursor"]
183        .as_u64()
184        .unwrap_or(0) as usize
185}
186
187/// Ensure `vaults.<key>.<pool>` exists so the writers below can index into it.
188fn ensure(state: &mut Value, vault_key: &str, pool: &str) {
189    if !state["vaults"][vault_key][pool].is_object() {
190        state["vaults"][vault_key][pool] = json!({ "cursor": 0, "members": {} });
191    }
192}
193
194/// Record that a member was handed out, and where the cursor moves to.
195pub fn record_use(
196    state: &mut Value,
197    vault_key: &str,
198    pool: &str,
199    ck: &str,
200    next_cursor: usize,
201    now: i64,
202) {
203    ensure(state, vault_key, pool);
204    let uses = member_state(state, vault_key, pool, ck).uses;
205    state["vaults"][vault_key][pool]["cursor"] = json!(next_cursor as u64);
206    state["vaults"][vault_key][pool]["members"][ck]["uses"] = json!(uses + 1);
207    state["vaults"][vault_key][pool]["members"][ck]["last_used_at"] = json!(iso_at(now));
208}
209
210/// Put a member on cooldown until `until_ts`, or clear its cooldown with `None`.
211pub fn set_cooldown(
212    state: &mut Value,
213    vault_key: &str,
214    pool: &str,
215    ck: &str,
216    until_ts: Option<i64>,
217) {
218    ensure(state, vault_key, pool);
219    let slot = &mut state["vaults"][vault_key][pool]["members"][ck];
220    match until_ts {
221        Some(ts) => slot["cooling_until"] = json!(iso_at(ts)),
222        None => {
223            if let Some(obj) = slot.as_object_mut() {
224                obj.remove("cooling_until");
225            }
226        }
227    }
228}
229
230/// Round-robin over the members that are not cooling, starting at `cursor`.
231///
232/// The cursor indexes the **full** member list rather than a filtered one, so
233/// a member going on cooldown does not shift every other member's position
234/// and make the next call skip an unrelated key. `None` when every member is
235/// cooling. Shared by `unv pool next`/`unv get --pool` (`unv-cli/src/pool.rs`)
236/// and the desktop card's Copy button (`pool_next` in `src-tauri`) — two
237/// callers picking a member by two different rules is exactly the shape that
238/// hands one caller a key the other just put on cooldown.
239pub fn pick_index(cooling: &[bool], cursor: usize) -> Option<usize> {
240    let n = cooling.len();
241    if n == 0 {
242        return None;
243    }
244    (0..n).map(|off| (cursor + off) % n).find(|i| !cooling[*i])
245}
246
247/// Forget a pool's cursor, cooldowns and counts for one vault.
248pub fn forget(state: &mut Value, vault_key: &str, pool: &str) {
249    if let Some(obj) = state["vaults"][vault_key].as_object_mut() {
250        obj.remove(pool);
251    }
252}
253
254/// Is this member cooling at `now`?
255///
256/// A `cooling_until` that is not a timestamp is treated as **not** cooling.
257/// `pools.json` is a plain file a user can edit and a half-written one is a real
258/// state; reading garbage as "cooling forever" would take a key out of service
259/// with nothing on screen explaining why.
260pub fn is_cooling(cooling_until: Option<&str>, now: i64) -> bool {
261    cooling_until
262        .and_then(parse_rfc3339)
263        .is_some_and(|t| t > now)
264}
265
266/// Seconds since the epoch, now.
267pub fn now_ts() -> i64 {
268    time::OffsetDateTime::now_utc().unix_timestamp()
269}
270
271/// RFC 3339 for an arbitrary instant.
272///
273/// Hand-formatted rather than `OffsetDateTime::format`, because the workspace
274/// pins `time` without its `formatting` feature. [`iso_now`](crate::iso_now)
275/// does the same for "now"; keeping the shape identical matters because these
276/// strings sit beside `last_rotated_at` and friends written by that function.
277pub fn iso_at(ts: i64) -> String {
278    let Ok(t) = time::OffsetDateTime::from_unix_timestamp(ts) else {
279        return String::new();
280    };
281    format!(
282        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
283        t.year(),
284        t.month() as u8,
285        t.day(),
286        t.hour(),
287        t.minute(),
288        t.second()
289    )
290}
291
292/// Read one of the timestamps [`iso_at`] writes: exactly `YYYY-MM-DDTHH:MM:SSZ`.
293///
294/// Hand-parsed rather than via `OffsetDateTime::parse`, which needs the `time`
295/// crate's `parsing` feature that vault-core deliberately does not enable — the
296/// same reasoning as [`iso_at`] not using `format`. Nothing is lost by being
297/// strict here: this only ever reads back a string this module wrote, and
298/// anything else must be rejected anyway, because `pools.json` is a plain file
299/// a user can edit and [`is_cooling`] has to treat garbage as "not cooling"
300/// rather than "cooling forever".
301pub fn parse_rfc3339(s: &str) -> Option<i64> {
302    let b = s.as_bytes();
303    if b.len() != 20
304        || b[4] != b'-'
305        || b[7] != b'-'
306        || b[10] != b'T'
307        || b[13] != b':'
308        || b[16] != b':'
309        || b[19] != b'Z'
310    {
311        return None;
312    }
313    let num = |from: usize, to: usize| s.get(from..to)?.parse::<u32>().ok();
314    let year = num(0, 4)? as i32;
315    let month = time::Month::try_from(num(5, 7)? as u8).ok()?;
316    let day = num(8, 10)? as u8;
317    let (h, m, sec) = (num(11, 13)? as u8, num(14, 16)? as u8, num(17, 19)? as u8);
318
319    let date = time::Date::from_calendar_date(year, month, day).ok()?;
320    let time_of_day = time::Time::from_hms(h, m, sec).ok()?;
321    Some(
322        time::PrimitiveDateTime::new(date, time_of_day)
323            .assume_utc()
324            .unix_timestamp(),
325    )
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    const NOW: i64 = 1_700_000_000;
333    const A: &str = "id-aaa";
334    const B: &str = "id-bbb";
335
336    #[test]
337    fn cooling_reads_a_future_timestamp() {
338        assert!(is_cooling(Some(&iso_at(NOW + 60)), NOW));
339    }
340
341    #[test]
342    fn an_expired_cooldown_is_not_a_cooldown() {
343        assert!(!is_cooling(Some(&iso_at(NOW - 1)), NOW));
344    }
345
346    #[test]
347    fn garbage_is_not_a_cooldown() {
348        // A hand-edited or half-written pools.json must not take a key out of
349        // service with nothing on screen explaining why.
350        assert!(!is_cooling(Some("not a date"), NOW));
351        assert!(!is_cooling(Some(""), NOW));
352        assert!(!is_cooling(None, NOW));
353    }
354
355    #[test]
356    fn iso_at_round_trips_and_matches_iso_now_shape() {
357        let s = iso_at(NOW);
358        assert_eq!(s.len(), 20, "YYYY-MM-DDTHH:MM:SSZ");
359        assert!(s.ends_with('Z'));
360        assert_eq!(parse_rfc3339(&s), Some(NOW));
361    }
362
363    #[test]
364    fn use_and_cooldown_accumulate_on_the_right_member() {
365        let mut st = empty();
366        let vk = "local:/tmp/v.db";
367        record_use(&mut st, vk, "p", A, 1, NOW);
368        record_use(&mut st, vk, "p", A, 0, NOW);
369        record_use(&mut st, vk, "p", B, 1, NOW);
370
371        assert_eq!(member_state(&st, vk, "p", A).uses, 2);
372        assert_eq!(member_state(&st, vk, "p", B).uses, 1);
373        assert_eq!(cursor(&st, vk, "p"), 1);
374
375        set_cooldown(&mut st, vk, "p", A, Some(NOW + 900));
376        assert!(is_cooling(
377            member_state(&st, vk, "p", A).cooling_until.as_deref(),
378            NOW
379        ));
380        assert!(
381            !is_cooling(member_state(&st, vk, "p", B).cooling_until.as_deref(), NOW),
382            "cooling one member must not cool another"
383        );
384
385        set_cooldown(&mut st, vk, "p", A, None);
386        assert_eq!(
387            member_state(&st, vk, "p", A).cooling_until,
388            None,
389            "clearing removes the key rather than writing a past timestamp"
390        );
391        assert_eq!(
392            member_state(&st, vk, "p", A).uses,
393            2,
394            "clearing a cooldown must not reset the use count"
395        );
396    }
397
398    #[test]
399    fn state_is_partitioned_by_vault() {
400        // Connecting to a different vault must not inherit the last one's
401        // cursor - the same reasoning as resetting view state on a switch.
402        let mut st = empty();
403        record_use(&mut st, "local:/a.db", "p", A, 3, NOW);
404        assert_eq!(cursor(&st, "local:/a.db", "p"), 3);
405        assert_eq!(cursor(&st, "remote:https://host", "p"), 0);
406        assert_eq!(member_state(&st, "remote:https://host", "p", A).uses, 0);
407    }
408
409    #[test]
410    fn forget_clears_one_pool_and_leaves_the_others() {
411        let mut st = empty();
412        let vk = "local:/a.db";
413        record_use(&mut st, vk, "keep", A, 1, NOW);
414        record_use(&mut st, vk, "drop", B, 1, NOW);
415        forget(&mut st, vk, "drop");
416        assert_eq!(member_state(&st, vk, "drop", B).uses, 0);
417        assert_eq!(member_state(&st, vk, "keep", A).uses, 1);
418    }
419}