cardanowall_cli/state/
bookmark.rs1use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use crate::util::CliError;
16
17pub const BOOKMARK_SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct SealedMatchEntry {
23 pub tx_hash: String,
25 pub item_idx: usize,
27 pub slot_idx: usize,
29 pub first_seen: String,
31 #[serde(skip_serializing_if = "Option::is_none", default)]
33 pub block_height: Option<u64>,
34 #[serde(skip_serializing_if = "Option::is_none", default)]
36 pub num_confirmations_at_first_seen: Option<u64>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct InboxBookmark {
42 pub schema_version: u32,
44 pub identity_pubkey_ed25519_hex: String,
46 pub last_processed_cursor: u64,
48 pub last_processed_block_height: u64,
50 pub matched: Vec<SealedMatchEntry>,
52 pub dismissed: Vec<String>,
54}
55
56impl InboxBookmark {
57 #[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
73pub 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
88pub 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
103fn home_dir() -> Option<PathBuf> {
105 std::env::var_os("HOME")
106 .or_else(|| std::env::var_os("USERPROFILE"))
107 .map(PathBuf::from)
108}
109
110pub 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
130pub fn bookmark_path(prefix_hex: &str) -> Result<PathBuf, CliError> {
136 Ok(bookmark_dir(prefix_hex)?.join("inbox.json"))
137}
138
139pub 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
188pub 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}