secrets-vault 2.4.1

AES-256-GCM encrypted key-value vault with PBKDF2 key derivation. Store API keys and tokens securely instead of plaintext dotfiles.
Documentation
//! Calendar windows for leases — "working secrets" (goal 32A45613).
//!
//! A lease TTL is a DURATION; an unattended duty cycle is a SCHEDULE. A window
//! turns the schedule itself into the boundary: one tap authorizes reads
//! *inside* the declared hours, and a read at 3am — a bug or an intruder,
//! nothing else legitimately reads then — fails exactly as if no lease existed.
//!
//! Evaluation happens in the NAMED zone at read time, never resolved to UTC at
//! creation: an `America/New_York` window crosses a DST transition most
//! quarters, and a UTC snapshot would drift an hour across it. jiff carries an
//! embedded tzdb, so an unattended box with no system tz files still evaluates
//! correctly.
//!
//! The declaration lives in the CENTRAL registry (`projects.toml`) beside the
//! project's key names, and is copied into the lease's keystore-anchored
//! metadata at create time — the tap approves the window, and nothing
//! agent-writable can widen it afterwards.

use serde::{Deserialize, Serialize};

use jiff::civil::{Date, Time, Weekday};
use jiff::tz::TimeZone;
use jiff::{Timestamp, ToSpan};

/// A recurring civil-time window, stored exactly as declared (raw strings) so
/// the keystore metadata stays human-auditable; parsed on every evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Window {
    /// "HH:MM-HH:MM", open strictly before close, both in `tz`'s civil time.
    pub hours: String,
    /// IANA zone name, e.g. "America/New_York".
    pub tz: String,
    /// "mon-fri", "mon,wed,fri", a single day — or absent for every day.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub days: Option<String>,
    /// Seconds of grace before the open. Load-bearing, not padding: a 09:00
    /// fire checking whether the market is open must read.
    #[serde(default)]
    pub grace_before_secs: u64,
    /// Seconds of grace after the close, for post-close reconcilers.
    #[serde(default)]
    pub grace_after_secs: u64,
    /// "YYYY-MM-DD" — last civil date (inclusive, in `tz`) the window opens.
    /// The authorisation must not outlive its reason.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub until: Option<String>,
}

/// What a window says about one instant.
pub enum State {
    /// Inside (grace included). `closes` = unix second this interval ends.
    Open { closes: i64 },
    /// Outside. `next_open` = unix second the window next opens (grace
    /// included), None when the `until` bound means it never will again.
    Closed { next_open: Option<i64> },
}

/// How far ahead `state` searches for the next opening. Covers any weekly
/// pattern plus an `until` far out; beyond this the window reports as never
/// opening again rather than scanning forever.
const HORIZON_DAYS: i64 = 400;

impl Window {
    /// Validate every field parses. Called at declaration-load and create time
    /// so a typo surfaces at the tap, not silently at 09:00 the next morning.
    pub fn validate(&self) -> Result<(), String> {
        let (open, close) = self.parse_hours()?;
        if open >= close {
            return Err(format!(
                "window hours '{}': open must be before close (overnight windows are not supported)",
                self.hours
            ));
        }
        self.parse_days()?;
        self.zone()?;
        if let Some(u) = &self.until {
            parse_date(u)?;
        }
        Ok(())
    }

    /// Evaluate at `now`. Fails only on a malformed window (which `validate`
    /// should have caught at create) — callers treat an error as CLOSED.
    pub fn state(&self, now_unix: u64) -> Result<State, String> {
        let (open, close) = self.parse_hours()?;
        let days = self.parse_days()?;
        let tz = self.zone()?;
        let until = self.until.as_deref().map(parse_date).transpose()?;

        let now_ts = Timestamp::from_second(now_unix as i64)
            .map_err(|e| format!("timestamp out of range: {e}"))?;
        let today = now_ts.to_zoned(tz.clone()).date();

        // A grace can pull yesterday's interval past midnight into today, so
        // the scan starts one day back. Forward, the first matching interval
        // wins — either it contains `now` (open) or it is the next opening.
        let mut date = today.yesterday().map_err(|e| format!("date range: {e}"))?;
        for _ in 0..HORIZON_DAYS {
            let in_days = days.is_empty() || days.contains(&date.weekday());
            let in_until = until.is_none_or(|u| date <= u);
            if in_days && in_until {
                let start = zoned_second(&tz, date, open)?
                    - i64::try_from(self.grace_before_secs).unwrap_or(0);
                let end = zoned_second(&tz, date, close)?
                    + i64::try_from(self.grace_after_secs).unwrap_or(0);
                let now = now_unix as i64;
                if now < start {
                    return Ok(State::Closed { next_open: Some(start) });
                }
                if now < end {
                    return Ok(State::Open { closes: end });
                }
            }
            date = date.tomorrow().map_err(|e| format!("date range: {e}"))?;
        }
        Ok(State::Closed { next_open: None })
    }

    /// The final instant this window can serve: the `until` day's close plus
    /// grace, in the named zone — the natural lease expiry for a bounded
    /// window. None when there is no `until`.
    pub fn end_of_authorisation(&self) -> Result<Option<i64>, String> {
        let Some(u) = &self.until else { return Ok(None) };
        let (_, close) = self.parse_hours()?;
        let tz = self.zone()?;
        let end = zoned_second(&tz, parse_date(u)?, close)?
            + i64::try_from(self.grace_after_secs).unwrap_or(0);
        Ok(Some(end))
    }

    /// One line for the Touch ID sheet and status output — the human must be
    /// able to judge exactly what the tap authorizes.
    pub fn describe(&self) -> String {
        let mut s = String::new();
        if let Some(d) = &self.days {
            s.push_str(d);
            s.push(' ');
        }
        s.push_str(&self.hours);
        s.push(' ');
        s.push_str(&self.tz);
        if self.grace_before_secs > 0 || self.grace_after_secs > 0 {
            if self.grace_before_secs == self.grace_after_secs {
                s.push_str(&format!(" ±{}", crate::lease::human_secs(self.grace_before_secs)));
            } else {
                s.push_str(&format!(
                    " (-{}/+{})",
                    crate::lease::human_secs(self.grace_before_secs),
                    crate::lease::human_secs(self.grace_after_secs)
                ));
            }
        }
        if let Some(u) = &self.until {
            s.push_str(&format!(" until {u}"));
        }
        s
    }

    fn parse_hours(&self) -> Result<(Time, Time), String> {
        let (a, b) = self
            .hours
            .split_once('-')
            .ok_or_else(|| format!("window hours '{}': expected 'HH:MM-HH:MM'", self.hours))?;
        Ok((parse_hm(a.trim())?, parse_hm(b.trim())?))
    }

    fn parse_days(&self) -> Result<Vec<Weekday>, String> {
        let Some(spec) = &self.days else { return Ok(Vec::new()) };
        let mut out = Vec::new();
        for token in spec.split(',') {
            let token = token.trim();
            if let Some((a, b)) = token.split_once('-') {
                let (a, b) = (parse_day(a.trim())?, parse_day(b.trim())?);
                let (mut n, last) = (a.to_monday_one_offset(), b.to_monday_one_offset());
                if n > last {
                    return Err(format!(
                        "window days '{spec}': range '{token}' runs backwards (no wrap-around)"
                    ));
                }
                while n <= last {
                    out.push(Weekday::from_monday_one_offset(n).map_err(|e| e.to_string())?);
                    n += 1;
                }
            } else {
                out.push(parse_day(token)?);
            }
        }
        Ok(out)
    }

    fn zone(&self) -> Result<TimeZone, String> {
        TimeZone::get(&self.tz).map_err(|_| {
            format!("window tz '{}': not an IANA zone name (e.g. America/New_York)", self.tz)
        })
    }
}

/// A civil time on a civil date in a zone → unix second, evaluated AT THAT
/// MOMENT's rules. jiff's compatible disambiguation handles the DST edges: a
/// skipped local time (spring forward) lands after the gap, an ambiguous one
/// (fall back) takes the earlier instant — both deterministic.
fn zoned_second(tz: &TimeZone, date: Date, time: Time) -> Result<i64, String> {
    let dt = date.at(time.hour(), time.minute(), 0, 0);
    Ok(tz
        .to_ambiguous_zoned(dt)
        .compatible()
        .map_err(|e| format!("resolving {dt} in zone: {e}"))?
        .timestamp()
        .as_second())
}

fn parse_hm(s: &str) -> Result<Time, String> {
    let (h, m) = s
        .split_once(':')
        .ok_or_else(|| format!("time '{s}': expected HH:MM"))?;
    let h: i8 = h.parse().map_err(|_| format!("time '{s}': bad hour"))?;
    let m: i8 = m.parse().map_err(|_| format!("time '{s}': bad minute"))?;
    Time::new(h, m, 0, 0).map_err(|_| format!("time '{s}': out of range"))
}

fn parse_day(s: &str) -> Result<Weekday, String> {
    match s.to_ascii_lowercase().as_str() {
        "mon" | "monday" => Ok(Weekday::Monday),
        "tue" | "tues" | "tuesday" => Ok(Weekday::Tuesday),
        "wed" | "wednesday" => Ok(Weekday::Wednesday),
        "thu" | "thur" | "thurs" | "thursday" => Ok(Weekday::Thursday),
        "fri" | "friday" => Ok(Weekday::Friday),
        "sat" | "saturday" => Ok(Weekday::Saturday),
        "sun" | "sunday" => Ok(Weekday::Sunday),
        other => Err(format!("window days: unknown day '{other}'")),
    }
}

fn parse_date(s: &str) -> Result<Date, String> {
    s.parse::<Date>()
        .map_err(|_| format!("window until '{s}': expected YYYY-MM-DD"))
}

// The unused import lint fires without this: ToSpan is pulled in for future
// span arithmetic on Date; drop it if a refactor removes the need.
#[allow(unused_imports)]
use ToSpan as _;

#[cfg(test)]
mod tests {
    use super::*;

    fn market() -> Window {
        Window {
            hours: "09:30-16:00".into(),
            tz: "America/New_York".into(),
            days: Some("mon-fri".into()),
            grace_before_secs: 1800,
            grace_after_secs: 1800,
            until: None,
        }
    }

    fn is_open(w: &Window, t: u64) -> bool {
        matches!(w.state(t).unwrap(), State::Open { .. })
    }

    #[test]
    fn validates() {
        market().validate().unwrap();
        let mut w = market();
        w.hours = "16:00-09:30".into();
        assert!(w.validate().is_err(), "overnight must be rejected");
        w = market();
        w.tz = "Mars/Olympus".into();
        assert!(w.validate().is_err());
        w = market();
        w.days = Some("fri-mon".into());
        assert!(w.validate().is_err(), "backwards range must be rejected");
        w = market();
        w.until = Some("someday".into());
        assert!(w.validate().is_err());
    }

    #[test]
    fn grace_edges_est() {
        let w = market();
        // Thu 2026-01-15 09:00 EST — exactly open minus 30m grace: must read.
        assert!(is_open(&w, 1768485600));
        // One minute earlier: closed.
        assert!(!is_open(&w, 1768485540));
        // 16:31 EDT in July (close 16:00 + 30m ends at 16:30): closed.
        assert!(!is_open(&w, 1784147460));
    }

    #[test]
    fn weekday_gate() {
        let w = market();
        // Sat 2026-07-18 12:00 EDT — midday, but Saturday: closed.
        assert!(!is_open(&w, 1784390400));
        // Wed 2026-07-15 10:00 EDT: open.
        assert!(is_open(&w, 1784124000));
        // Wed 2026-07-15 03:00 EDT — the 3am read: closed.
        assert!(!is_open(&w, 1784098800));
    }

    #[test]
    fn dst_transitions_evaluate_in_zone() {
        let w = market();
        // Mon 2026-03-09 10:00 EDT, the day after spring-forward. A UTC
        // snapshot taken in EST would call this 09:00 and still-graced; the
        // zone-at-read-time answer is simply "open".
        assert!(is_open(&w, 1773064800));
        // Mon 2026-11-02 10:00 EST, the day after fall-back: open.
        assert!(is_open(&w, 1793631600));
        // And the same UTC clock 10:00-in-zone reads differ by an hour across
        // the transition — proof the zone rules were applied per-instant.
        let summer_open = match w.state(1784098800).unwrap() {
            State::Closed { next_open: Some(t) } => t,
            _ => panic!("3am should be closed with a next open"),
        };
        // Next open on Wed 2026-07-15 = 09:00 EDT = 13:00Z.
        assert_eq!(summer_open % 86400, 13 * 3600);
    }

    #[test]
    fn until_bound_ends_authorisation() {
        let mut w = market();
        w.until = Some("2026-09-04".into());
        // Fri 2026-09-04 15:00 EDT — last covered day: open.
        assert!(is_open(&w, 1788548400));
        // Mon 2026-09-07 12:00 EDT — past the bound: closed, never opens again.
        match w.state(1788796800).unwrap() {
            State::Closed { next_open: None } => {}
            _ => panic!("past `until` must report no next open"),
        }
        // end_of_authorisation = Fri close 16:00 EDT + 30m = 16:30 EDT = 20:30Z.
        let end = w.end_of_authorisation().unwrap().unwrap();
        assert_eq!(end % 86400, 20 * 3600 + 1800);
    }

    #[test]
    fn every_day_when_days_absent() {
        let mut w = market();
        w.days = None;
        // Saturday midday now opens.
        assert!(is_open(&w, 1784390400));
    }

    #[test]
    fn meta_roundtrip_and_absent_field_compat() {
        // Old metadata without a window must deserialize (None); new windows
        // round-trip through JSON unchanged.
        let w = market();
        let j = serde_json::to_string(&w).unwrap();
        assert_eq!(serde_json::from_str::<Window>(&j).unwrap(), w);
        let old: Option<Window> = serde_json::from_str("null").unwrap();
        assert!(old.is_none());
    }
}