Skip to main content

cardanowall_cli/state/
bookmark.rs

1//! The local inbox bookmark: a plaintext-on-device JSON record of matched sealed
2//! PoEs, keyed per identity by the Ed25519 public-key prefix.
3//!
4//! The bookmark lives at `<HOME>/.cardanowall/<ed25519_prefix>/inbox.json`, where
5//! the prefix is the lowercase-hex first 8 bytes (16 hex chars) of the Ed25519
6//! public key. It is a local-only artefact and is NEVER uploaded. Writes are
7//! atomic (`.tmp` → rename) under `0600` perms on Unix.
8//!
9//! Wire-vocabulary triple: `(tx_hash, item_idx, slot_idx)`.
10
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use crate::util::CliError;
16
17/// The bookmark schema version (the only supported value).
18pub const BOOKMARK_SCHEMA_VERSION: u32 = 1;
19
20/// One matched sealed-PoE entry.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct SealedMatchEntry {
23    /// The carrying transaction hash (lowercase hex).
24    pub tx_hash: String,
25    /// The matched item index.
26    pub item_idx: usize,
27    /// The matched slot index.
28    pub slot_idx: usize,
29    /// The ISO-8601 timestamp this match was first seen.
30    pub first_seen: String,
31    /// The block height, when known.
32    #[serde(skip_serializing_if = "Option::is_none", default)]
33    pub block_height: Option<u64>,
34    /// The confirmation depth at first sight, when known.
35    #[serde(skip_serializing_if = "Option::is_none", default)]
36    pub num_confirmations_at_first_seen: Option<u64>,
37}
38
39/// The full on-disk bookmark.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct InboxBookmark {
42    /// The schema version (always `1`).
43    pub schema_version: u32,
44    /// The identity Ed25519 public key the bookmark belongs to (lowercase hex).
45    pub identity_pubkey_ed25519_hex: String,
46    /// The last indexer cursor processed.
47    pub last_processed_cursor: u64,
48    /// The last block height processed.
49    pub last_processed_block_height: u64,
50    /// The confirmed matches.
51    pub matched: Vec<SealedMatchEntry>,
52    /// Dismissed transaction hashes.
53    pub dismissed: Vec<String>,
54}
55
56impl InboxBookmark {
57    /// An empty bookmark for a fresh identity.
58    #[must_use]
59    pub fn empty(identity_pubkey_ed25519_hex: String) -> Self {
60        Self {
61            schema_version: BOOKMARK_SCHEMA_VERSION,
62            identity_pubkey_ed25519_hex,
63            last_processed_cursor: 0,
64            last_processed_block_height: 0,
65            matched: Vec::new(),
66            dismissed: Vec::new(),
67        }
68    }
69}
70
71const PREFIX_HEX_LEN: usize = 16;
72
73/// The 16-hex-char (8-byte) lowercase prefix of an Ed25519 public key.
74///
75/// # Errors
76///
77/// Returns [`CliError`] (exit `4`) when the key is not 32 bytes.
78pub fn ed25519_prefix(pubkey: &[u8]) -> Result<String, CliError> {
79    if pubkey.len() != 32 {
80        return Err(CliError::input(format!(
81            "inbox: Ed25519 public key MUST be 32 bytes; got {}",
82            pubkey.len()
83        )));
84    }
85    Ok(crate::util::bytes_to_hex(&pubkey[..PREFIX_HEX_LEN / 2]))
86}
87
88/// The full lowercase-hex Ed25519 public key.
89///
90/// # Errors
91///
92/// Returns [`CliError`] (exit `4`) when the key is not 32 bytes.
93pub fn ed25519_pubkey_hex(pubkey: &[u8]) -> Result<String, CliError> {
94    if pubkey.len() != 32 {
95        return Err(CliError::input(format!(
96            "inbox: Ed25519 public key MUST be 32 bytes; got {}",
97            pubkey.len()
98        )));
99    }
100    Ok(crate::util::bytes_to_hex(pubkey))
101}
102
103/// The home directory, for locating the per-identity bookmark dir.
104fn home_dir() -> Option<PathBuf> {
105    std::env::var_os("HOME")
106        .or_else(|| std::env::var_os("USERPROFILE"))
107        .map(PathBuf::from)
108}
109
110/// The bookmark directory for an Ed25519 prefix.
111///
112/// # Errors
113///
114/// Returns [`CliError`] (exit `4`) for a malformed prefix or no home directory.
115pub fn bookmark_dir(prefix_hex: &str) -> Result<PathBuf, CliError> {
116    if prefix_hex.len() != PREFIX_HEX_LEN
117        || !prefix_hex
118            .bytes()
119            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
120    {
121        return Err(CliError::input(format!(
122            "inbox: identity prefix MUST be 16 lowercase-hex chars; got \"{prefix_hex}\""
123        )));
124    }
125    let home = home_dir()
126        .ok_or_else(|| CliError::input("inbox: no home directory to locate the bookmark"))?;
127    Ok(home.join(".cardanowall").join(prefix_hex))
128}
129
130/// The bookmark file path for an Ed25519 prefix.
131///
132/// # Errors
133///
134/// Returns [`CliError`] (exit `4`) for a malformed prefix.
135pub fn bookmark_path(prefix_hex: &str) -> Result<PathBuf, CliError> {
136    Ok(bookmark_dir(prefix_hex)?.join("inbox.json"))
137}
138
139/// Load the bookmark at `path`, or return an empty one if the file is absent.
140///
141/// Validates the schema and the identity binding. Emits a permission-drift nudge
142/// on stderr when the on-disk mode is not `0600` (Unix only).
143///
144/// # Errors
145///
146/// Returns [`CliError`] (exit `4`) for a symlink path, a malformed file, a schema
147/// mismatch, or an identity mismatch.
148pub fn load_or_init(
149    path: &Path,
150    identity_pubkey_ed25519_hex: &str,
151) -> Result<InboxBookmark, CliError> {
152    refuse_if_symlink(path)?;
153    if !path.exists() {
154        return Ok(InboxBookmark::empty(
155            identity_pubkey_ed25519_hex.to_string(),
156        ));
157    }
158    let raw = std::fs::read_to_string(path).map_err(|e| {
159        CliError::input(format!(
160            "inbox: cannot read bookmark file at {}: {e}",
161            path.display()
162        ))
163    })?;
164    let bookmark: InboxBookmark = serde_json::from_str(&raw).map_err(|e| {
165        CliError::input(format!(
166            "inbox: bookmark file at {} is malformed: {e}",
167            path.display()
168        ))
169    })?;
170    if bookmark.schema_version != BOOKMARK_SCHEMA_VERSION {
171        return Err(CliError::input(format!(
172            "inbox: bookmark file at {} has unsupported schema_version {}",
173            path.display(),
174            bookmark.schema_version
175        )));
176    }
177    if bookmark.identity_pubkey_ed25519_hex != identity_pubkey_ed25519_hex {
178        return Err(CliError::input(format!(
179            "inbox: bookmark identity mismatch at {}: expected {identity_pubkey_ed25519_hex}, got {}",
180            path.display(),
181            bookmark.identity_pubkey_ed25519_hex
182        )));
183    }
184    check_perms_and_nudge(path);
185    Ok(bookmark)
186}
187
188/// Persist the bookmark to `path` atomically, under `0600` perms on Unix.
189///
190/// # Errors
191///
192/// Returns [`CliError`] (exit `4`) for a symlink path or a write failure.
193pub fn save(path: &Path, bookmark: &InboxBookmark) -> Result<(), CliError> {
194    refuse_if_symlink(path)?;
195    if let Some(dir) = path.parent() {
196        std::fs::create_dir_all(dir).map_err(|e| {
197            CliError::input(format!(
198                "inbox: cannot create bookmark dir {}: {e}",
199                dir.display()
200            ))
201        })?;
202    }
203    let serialised = serde_json::to_string_pretty(bookmark)
204        .map_err(|e| CliError::input(format!("inbox: cannot serialise bookmark: {e}")))?;
205    let tmp = path.with_extension("json.tmp");
206    std::fs::write(&tmp, format!("{serialised}\n")).map_err(|e| {
207        CliError::input(format!(
208            "inbox: cannot write bookmark tmp at {}: {e}",
209            tmp.display()
210        ))
211    })?;
212    set_owner_only(&tmp);
213    std::fs::rename(&tmp, path).map_err(|e| {
214        CliError::input(format!(
215            "inbox: cannot finalise bookmark at {}: {e}",
216            path.display()
217        ))
218    })?;
219    set_owner_only(path);
220    Ok(())
221}
222
223fn refuse_if_symlink(path: &Path) -> Result<(), CliError> {
224    match std::fs::symlink_metadata(path) {
225        Ok(meta) if meta.file_type().is_symlink() => Err(CliError::input(format!(
226            "inbox: bookmark path {} is a symbolic link; refusing to read/write through it",
227            path.display()
228        ))),
229        _ => Ok(()),
230    }
231}
232
233#[cfg(unix)]
234fn set_owner_only(path: &Path) {
235    use std::os::unix::fs::PermissionsExt;
236    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
237}
238
239#[cfg(not(unix))]
240fn set_owner_only(_path: &Path) {}
241
242#[cfg(unix)]
243fn check_perms_and_nudge(path: &Path) {
244    use std::os::unix::fs::PermissionsExt;
245    if let Ok(meta) = std::fs::metadata(path) {
246        let mode = meta.permissions().mode() & 0o777;
247        if mode != 0o600 {
248            eprintln!(
249                "inbox: bookmark file {} has permissions {:04o}; expected 0600. Run 'chmod 600 {}' to restore.",
250                path.display(),
251                mode,
252                path.display()
253            );
254        }
255    }
256}
257
258#[cfg(not(unix))]
259fn check_perms_and_nudge(_path: &Path) {}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn prefix_is_16_hex_of_pubkey() {
267        let pubkey = [0xabu8; 32];
268        assert_eq!(ed25519_prefix(&pubkey).unwrap(), "abababababababab");
269    }
270
271    #[test]
272    fn rejects_wrong_pubkey_length() {
273        assert_eq!(ed25519_prefix(&[0u8; 31]).unwrap_err().code, 4);
274    }
275
276    #[test]
277    fn rejects_bad_prefix_for_dir() {
278        assert_eq!(bookmark_dir("XYZ").unwrap_err().code, 4);
279    }
280
281    #[test]
282    fn round_trips_through_disk() {
283        let dir = tempfile::tempdir().unwrap();
284        let path = dir.path().join("inbox.json");
285        let mut bm = InboxBookmark::empty("aa".repeat(32));
286        bm.matched.push(SealedMatchEntry {
287            tx_hash: "bb".repeat(32),
288            item_idx: 0,
289            slot_idx: 1,
290            first_seen: "2026-06-01T00:00:00Z".to_string(),
291            block_height: Some(42),
292            num_confirmations_at_first_seen: Some(20),
293        });
294        save(&path, &bm).unwrap();
295        let loaded = load_or_init(&path, &"aa".repeat(32)).unwrap();
296        assert_eq!(loaded, bm);
297    }
298
299    #[test]
300    fn identity_mismatch_is_error() {
301        let dir = tempfile::tempdir().unwrap();
302        let path = dir.path().join("inbox.json");
303        save(&path, &InboxBookmark::empty("aa".repeat(32))).unwrap();
304        assert_eq!(load_or_init(&path, &"cc".repeat(32)).unwrap_err().code, 4);
305    }
306}