Skip to main content

dove_core/
request_ledger.rs

1//! A local record of the requests this machine created — the sibling of
2//! `ledger.rs`, but for `dove request` instead of `dove share`. The gate only
3//! ever sees the request id and ciphertext; it can't tell you what you asked
4//! for or how to decrypt it. So dove keeps a private map here
5//! (`~/.config/dove/requests.json`) holding the description **and the
6//! fragment** — the decryption key — so `dove requests` and `dove requests
7//! get` can list and later collect what comes in. Because this file holds a
8//! secret (the share ledger doesn't), it's locked to 0600 after every write,
9//! mirroring `secrets.rs`.
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct RequestRecord {
17    pub id: String,
18    /// The decryption key (hex): kept locally so the requester can decrypt
19    /// whatever the other side uploads. Never sent to the server.
20    pub fragment: String,
21    pub description: String,
22    pub created_at: u64,
23}
24
25/// Record (or replace) a request in the local ledger.
26pub fn record(rec: RequestRecord) -> Result<()> {
27    record_at(&requests_path()?, rec)
28}
29
30/// All requests this machine created — what `dove requests` shows.
31pub fn all() -> Vec<RequestRecord> {
32    requests_path()
33        .ok()
34        .map(|p| all_from(&p))
35        .unwrap_or_default()
36}
37
38/// One request by id, if this machine created it — `dove requests get`.
39pub fn get(id: &str) -> Option<RequestRecord> {
40    requests_path().ok().and_then(|p| get_from(&p, id))
41}
42
43/// Drop a request from the ledger.
44pub fn remove(id: &str) -> Result<()> {
45    remove_at(&requests_path()?, id)
46}
47
48/// Record (or replace, by id) a request at a specific ledger path. The
49/// path-injectable core of [`record`] — kept separate so tests can drive it
50/// against a temp file without touching `$HOME`/`$XDG_CONFIG_HOME`.
51fn record_at(path: &Path, rec: RequestRecord) -> Result<()> {
52    let mut all = load_from(path).unwrap_or_default();
53    all.retain(|r| r.id != rec.id);
54    all.push(rec);
55    save_to(path, &all)
56}
57
58/// All records at a specific ledger path. The path-injectable core of [`all`].
59fn all_from(path: &Path) -> Vec<RequestRecord> {
60    load_from(path).unwrap_or_default()
61}
62
63/// One record by id at a specific ledger path. The path-injectable core of
64/// [`get`].
65fn get_from(path: &Path, id: &str) -> Option<RequestRecord> {
66    all_from(path).into_iter().find(|r| r.id == id)
67}
68
69/// Drop a record by id at a specific ledger path. The path-injectable core of
70/// [`remove`].
71fn remove_at(path: &Path, id: &str) -> Result<()> {
72    let mut all = load_from(path).unwrap_or_default();
73    all.retain(|r| r.id != id);
74    save_to(path, &all)
75}
76
77fn load_from(path: &Path) -> Result<Vec<RequestRecord>> {
78    match std::fs::read_to_string(path) {
79        Ok(text) => serde_json::from_str(&text).context("parsing the dove requests ledger"),
80        Err(_) => Ok(Vec::new()), // no ledger yet
81    }
82}
83
84fn save_to(path: &Path, all: &[RequestRecord]) -> Result<()> {
85    std::fs::create_dir_all(path.parent().unwrap())?;
86    let text = serde_json::to_string_pretty(all).context("serializing the dove requests ledger")?;
87    std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
88    set_private(path)
89}
90
91/// Lock the requests file down to the owner (0600) on Unix — it holds
92/// decryption keys, unlike the share ledger.
93#[cfg(unix)]
94fn set_private(path: &Path) -> Result<()> {
95    use std::os::unix::fs::PermissionsExt;
96    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
97        .with_context(|| format!("chmod 600 {}", path.display()))
98}
99#[cfg(not(unix))]
100fn set_private(_path: &Path) -> Result<()> {
101    Ok(())
102}
103
104fn requests_path() -> Result<PathBuf> {
105    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
106        if !x.is_empty() {
107            return Ok(PathBuf::from(x).join("dove/requests.json"));
108        }
109    }
110    let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?;
111    Ok(PathBuf::from(home).join(".config/dove/requests.json"))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    /// Drives `record_at`/`all_from`/`get_from`/`remove_at` — the exact
119    /// path-injectable helpers the public `record`/`all`/`get`/`remove`
120    /// delegate to — against a unique temp path. Deterministic and
121    /// parallel-safe: no `HOME`/`XDG_CONFIG_HOME` mutation, no shared real
122    /// ledger file, and no reimplementation of the dedup/filter logic that
123    /// would let this test drift out of sync with production.
124    #[test]
125    fn round_trips_records_through_the_public_api_paths() {
126        let dir = std::env::temp_dir().join(format!("dove-req-test-{}", std::process::id()));
127        let _ = std::fs::remove_dir_all(&dir);
128        let path = dir.join("requests.json");
129
130        let a = RequestRecord {
131            id: "aaa".into(),
132            fragment: "deadbeef".into(),
133            description: "invoice".into(),
134            created_at: 1_000,
135        };
136        let b = RequestRecord {
137            id: "bbb".into(),
138            fragment: "cafebabe".into(),
139            description: "vacation photo".into(),
140            created_at: 2_000,
141        };
142
143        assert!(all_from(&path).is_empty(), "fresh ledger starts empty");
144
145        // record two, via the same `record_at` the public `record()` calls
146        record_at(&path, a.clone()).unwrap();
147        record_at(&path, b.clone()).unwrap();
148
149        // all_from() returns both
150        let everything = all_from(&path);
151        assert_eq!(everything.len(), 2);
152
153        // get_from(id) returns the right one, including the fragment
154        let got_a = get_from(&path, &a.id).unwrap();
155        assert_eq!(got_a.fragment, "deadbeef");
156        assert_eq!(got_a.description, "invoice");
157        assert_eq!(got_a.created_at, 1_000);
158        let got_b = get_from(&path, &b.id).unwrap();
159        assert_eq!(got_b.fragment, "cafebabe");
160        assert!(get_from(&path, "no-such-id").is_none());
161
162        // recording again with the same id replaces rather than duplicates
163        let a_revised = RequestRecord {
164            description: "invoice (revised)".into(),
165            ..a.clone()
166        };
167        record_at(&path, a_revised).unwrap();
168        assert_eq!(all_from(&path).len(), 2, "same id replaces, not appends");
169        assert_eq!(
170            get_from(&path, &a.id).unwrap().description,
171            "invoice (revised)"
172        );
173
174        // remove_at drops one
175        remove_at(&path, &a.id).unwrap();
176        let remaining = all_from(&path);
177        assert_eq!(remaining.len(), 1);
178        assert_eq!(remaining[0].id, b.id);
179
180        // the fragment key means this file must be private
181        #[cfg(unix)]
182        {
183            use std::os::unix::fs::PermissionsExt;
184            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
185            assert_eq!(mode, 0o600);
186        }
187
188        let _ = std::fs::remove_dir_all(&dir);
189    }
190}