Skip to main content

solid_pod_rs_server/
trail_store.rs

1//! MRC20 trail persistence — the server-side load/save for a token's
2//! Bitcoin-anchored state chain (ADR-059 Phase 4; JSS `token.js:189-208`).
3//!
4//! A trail lives at `/.well-known/token/{ticker}.json` in pod storage. JSS
5//! persists the issuer **private key** straight inside the trail JSON
6//! (`token.js:294`) so `transferToken` can re-sign the next state. We keep the
7//! same on-disk shape but model it as a [`StoredTrail`](crate::trail_store::StoredTrail) = the public
8//! [`Mrc20Trail`](solid_pod_rs::mrc20::Mrc20Trail) plus a `privkey` (and a `date_created` the library type does
9//! not carry), so the secret never leaks onto the shared `Mrc20Trail` type that
10//! flows through wasm/core and the portable proof.
11//!
12//! Native-only: it uses `Arc<dyn Storage>` (the pod's filesystem/cloud
13//! backend). wasm consumers never compile this.
14
15use std::sync::Arc;
16
17use bytes::Bytes;
18use serde::{Deserialize, Serialize};
19
20use solid_pod_rs::blocktrail::{network_for_txo_chain, Blocktrail};
21use solid_pod_rs::mrc20::{Mrc20State, Mrc20Trail};
22use solid_pod_rs::payments::PaymentError;
23use solid_pod_rs::storage::Storage;
24use solid_pod_rs::PodError;
25
26/// Pod-storage path of a trail file (JSS `token.js:194-196`). Ticker is
27/// lower-cased so `PROV` and `prov` resolve to one file.
28#[must_use]
29pub fn trail_path(ticker: &str) -> String {
30    format!("/.well-known/token/{}.json", ticker.to_lowercase())
31}
32
33/// Whether `path` is a pod-storage trail file that holds an issuer secret
34/// ([`trail_path`], [`gitmark_trail_path`]). The public
35/// `/.well-known/gitmark/{name}/blocktrails.json` is deliberately excluded:
36/// it is the file a verifier reads and carries no secret.
37#[must_use]
38pub fn is_secret_trail_path(path: &str) -> bool {
39    // Canonicalise first (decode once, collapse `//`, add the leading `/`) and
40    // compare whole segments, so no spelling of the path slips past the test.
41    // A path that cannot be canonicalised is treated as secret.
42    let Some(canon) = crate::canonical_pod_path(path) else {
43        return true;
44    };
45    let segs = lower_segments(&canon);
46    let segs: Vec<&str> = segs.iter().map(String::as_str).collect();
47    match segs.as_slice() {
48        [".well-known", "token", ..] => true,
49        // `/.well-known/gitmark/{name}/blocktrails.json` is the public file.
50        [".well-known", "gitmark", _, "blocktrails.json"] => canon.ends_with('/'),
51        [".well-known", "gitmark", ..] => true,
52        _ => false,
53    }
54}
55
56/// Whether `path` is under `/.well-known/prov/` (the pending-refunds list and
57/// the epoch counter). Both are operator state: payer DIDs and amounts, and a
58/// file whose corruption halts epoch anchoring.
59#[must_use]
60pub fn is_prov_state_path(path: &str) -> bool {
61    let Some(canon) = crate::canonical_pod_path(path) else {
62        return true;
63    };
64    let segs = lower_segments(&canon);
65    matches!(
66        segs.iter()
67            .map(String::as_str)
68            .collect::<Vec<_>>()
69            .as_slice(),
70        [".well-known", "prov", ..]
71    )
72}
73
74/// Whether `path` is under `/.well-known/webledgers/`: the Web Ledger and the
75/// payment state, replay set, order book and pool documents. They hold every
76/// principal's balance, and a balance written there is spendable; only the
77/// payment handlers, which use `Storage` directly, may change them.
78#[must_use]
79pub fn is_server_state_path(path: &str) -> bool {
80    let Some(canon) = crate::canonical_pod_path(path) else {
81        return true;
82    };
83    let segs = lower_segments(&canon);
84    matches!(
85        segs.iter()
86            .map(String::as_str)
87            .collect::<Vec<_>>()
88            .as_slice(),
89        [".well-known", "webledgers", ..]
90    )
91}
92
93fn lower_segments(canon: &str) -> Vec<String> {
94    canon
95        .split('/')
96        .filter(|s| !s.is_empty())
97        // Full Unicode lower-casing, not ASCII-only: on a case-insensitive
98        // filesystem `.well-known/TOKEN` and `.well-known/to\u{212A}en`
99        // (Kelvin sign) reach the same directory as `token`.
100        // `fold_case` adds the full-folding cases lower-casing misses.
101        .map(solid_pod_rs::security::dotfile::fold_case)
102        .collect()
103}
104
105/// The persisted trail — the public [`Mrc20Trail`] plus the issuer secret and
106/// creation timestamp (JSS stores all of these in one JSON blob,
107/// `token.js:290-303`). Serialises with camelCase keys to match the JSS file
108/// format byte-for-byte so a JSS-written trail loads here and vice versa.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct StoredTrail {
112    pub ticker: String,
113    pub name: String,
114    pub supply: u64,
115    /// Issuer private key (64-char hex). **Secret** — never placed on the
116    /// public `Mrc20Trail` nor in any portable proof.
117    pub privkey: String,
118    pub pubkey_base: String,
119    pub states: Vec<Mrc20State>,
120    pub state_strings: Vec<String>,
121    pub current_txid: String,
122    pub current_vout: u32,
123    pub current_amount: u64,
124    pub network: String,
125    #[serde(default)]
126    pub date_created: String,
127}
128
129impl StoredTrail {
130    /// Project to the public [`Mrc20Trail`] (drops the secret `privkey`).
131    #[must_use]
132    pub fn to_public(&self) -> Mrc20Trail {
133        Mrc20Trail {
134            ticker: self.ticker.clone(),
135            name: self.name.clone(),
136            supply: self.supply,
137            pubkey_base: self.pubkey_base.clone(),
138            states: self.states.clone(),
139            state_strings: self.state_strings.clone(),
140            current_txid: self.current_txid.clone(),
141            current_vout: self.current_vout,
142            current_amount: self.current_amount,
143            network: self.network.clone(),
144            date_created: self.date_created.clone(),
145        }
146    }
147
148    /// Re-absorb a public [`Mrc20Trail`] (e.g. the appended trail returned by a
149    /// transfer/anchor) while keeping this trail's secret `privkey`.
150    pub fn merge_public(&mut self, public: &Mrc20Trail) {
151        self.states = public.states.clone();
152        self.state_strings = public.state_strings.clone();
153        self.current_txid = public.current_txid.clone();
154        self.current_vout = public.current_vout;
155        self.current_amount = public.current_amount;
156        self.supply = public.supply;
157    }
158}
159
160/// Load a trail for `ticker`, or `None` if it does not exist (JSS
161/// `loadTrail`, `token.js:198-203`). Only [`PodError::NotFound`] means absent:
162/// any other read failure is an error, so a storage fault is never mistaken
163/// for "no trail" (and a trail never overwritten on the strength of one).
164pub async fn load_trail(
165    storage: &Arc<dyn Storage>,
166    ticker: &str,
167) -> Result<Option<StoredTrail>, PaymentError> {
168    match storage.get(&trail_path(ticker)).await {
169        Ok((bytes, _meta)) => {
170            let trail: StoredTrail = serde_json::from_slice(&bytes)
171                .map_err(|e| PaymentError::Store(format!("malformed trail {ticker}: {e}")))?;
172            Ok(Some(trail))
173        }
174        Err(PodError::NotFound(_)) => Ok(None),
175        Err(e) => Err(PaymentError::Store(format!("read trail {ticker}: {e}"))),
176    }
177}
178
179/// Persist a trail (JSS `saveTrail`, `token.js:205-208`). Pretty-printed to
180/// match the JSS `JSON.stringify(trail, null, 2)` file format.
181pub async fn save_trail(
182    storage: &Arc<dyn Storage>,
183    trail: &StoredTrail,
184) -> Result<(), PaymentError> {
185    let body = serde_json::to_vec_pretty(trail)
186        .map_err(|e| PaymentError::Store(format!("serialise trail: {e}")))?;
187    storage
188        .put(
189            &trail_path(&trail.ticker),
190            Bytes::from(body),
191            "application/json",
192        )
193        .await
194        .map_err(|e| PaymentError::Store(format!("save trail: {e}")))?;
195    Ok(())
196}
197
198// ---------------------------------------------------------------------------
199// Git-mark trails (new trails' profile)
200// ---------------------------------------------------------------------------
201
202/// The trail name a pod anchors under when nothing names one.
203pub const DEFAULT_GITMARK_TRAIL: &str = "prov";
204
205/// Pod-storage path of a git-mark trail file (with its base secret). The name
206/// is lower-cased, as [`trail_path`] does for tickers.
207#[must_use]
208pub fn gitmark_trail_path(name: &str) -> String {
209    format!("/.well-known/gitmark/{}.json", name.to_lowercase())
210}
211
212/// Pod-storage path of a git-mark trail's public `blocktrails.json` (no
213/// secret), the file a verifier reads.
214#[must_use]
215pub fn gitmark_blocktrails_path(name: &str) -> String {
216    format!(
217        "/.well-known/gitmark/{}/blocktrails.json",
218        name.to_lowercase()
219    )
220}
221
222/// A persisted git-mark trail: the public [`Blocktrail`] (§5.2 shape) plus
223/// the base key's secret, which spends each mark to the next.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct StoredGitmarkTrail {
227    /// The trail's name (what an anchor's `ticker` names).
228    pub name: String,
229    /// The base key's secret (64-char hex). **Secret**: never in the public
230    /// `blocktrails.json`.
231    pub privkey: String,
232    /// The trail as a verifier reads it.
233    pub trail: Blocktrail,
234    /// Creation timestamp, set by the caller.
235    #[serde(default)]
236    pub date_created: String,
237}
238
239impl StoredGitmarkTrail {
240    /// The network name of the trail's chain (`testnet4` for `tbtc4`), as
241    /// [`solid_pod_rs::mrc20::bt_address`] takes it; empty when it names none.
242    #[must_use]
243    pub fn network(&self) -> String {
244        self.trail
245            .chain
246            .as_deref()
247            .map(network_for_txo_chain)
248            .unwrap_or_default()
249    }
250}
251
252/// Load the git-mark trail `name`, or `None` if there is none. Only
253/// [`PodError::NotFound`] means absent; any other read failure is an error.
254pub async fn load_gitmark_trail(
255    storage: &Arc<dyn Storage>,
256    name: &str,
257) -> Result<Option<StoredGitmarkTrail>, PaymentError> {
258    match storage.get(&gitmark_trail_path(name)).await {
259        Ok((bytes, _meta)) => serde_json::from_slice(&bytes)
260            .map(Some)
261            .map_err(|e| PaymentError::Store(format!("malformed git-mark trail {name}: {e}"))),
262        Err(PodError::NotFound(_)) => Ok(None),
263        Err(e) => Err(PaymentError::Store(format!(
264            "read git-mark trail {name}: {e}"
265        ))),
266    }
267}
268
269/// Pod-storage path of a git-mark trail's pending broadcast intent.
270#[must_use]
271pub fn gitmark_intent_path(name: &str) -> String {
272    format!("/.well-known/gitmark/{}/intent.json", name.to_lowercase())
273}
274
275/// A broadcast about to happen: the txid the signed transaction will have and
276/// the trail as it stands once that transaction is in. Written before the
277/// broadcast, so a lost response or a failed save is resolved from the chain
278/// on the next advance instead of leaving the stored head stale.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct GitmarkIntent {
282    /// The txid the broadcast transaction has.
283    pub txid: String,
284    /// The trail once the transaction is in (with its secret, as the trail file).
285    pub stored: StoredGitmarkTrail,
286}
287
288/// Load the pending intent for git-mark trail `name`, if any. Only
289/// [`PodError::NotFound`] means none.
290pub async fn load_gitmark_intent(
291    storage: &Arc<dyn Storage>,
292    name: &str,
293) -> Result<Option<GitmarkIntent>, PaymentError> {
294    match storage.get(&gitmark_intent_path(name)).await {
295        Ok((bytes, _meta)) => serde_json::from_slice(&bytes)
296            .map(Some)
297            .map_err(|e| PaymentError::Store(format!("malformed git-mark intent {name}: {e}"))),
298        Err(PodError::NotFound(_)) => Ok(None),
299        Err(e) => Err(PaymentError::Store(format!(
300            "read git-mark intent {name}: {e}"
301        ))),
302    }
303}
304
305/// Persist the pending intent for a git-mark trail.
306pub async fn save_gitmark_intent(
307    storage: &Arc<dyn Storage>,
308    intent: &GitmarkIntent,
309) -> Result<(), PaymentError> {
310    let body = serde_json::to_vec(intent)
311        .map_err(|e| PaymentError::Store(format!("serialise git-mark intent: {e}")))?;
312    storage
313        .put(
314            &gitmark_intent_path(&intent.stored.name),
315            Bytes::from(body),
316            "application/json",
317        )
318        .await
319        .map_err(|e| PaymentError::Store(format!("save git-mark intent: {e}")))?;
320    Ok(())
321}
322
323/// Remove the pending intent for git-mark trail `name` (already absent is fine).
324pub async fn clear_gitmark_intent(
325    storage: &Arc<dyn Storage>,
326    name: &str,
327) -> Result<(), PaymentError> {
328    match storage.delete(&gitmark_intent_path(name)).await {
329        Ok(()) | Err(PodError::NotFound(_)) => Ok(()),
330        Err(e) => Err(PaymentError::Store(format!(
331            "clear git-mark intent {name}: {e}"
332        ))),
333    }
334}
335
336/// Take the in-process lock for `key`, serialising every read-modify-write of
337/// the file(s) it names. Anchorers are built per request (and per worker), so
338/// the lock is process-global and keyed by path alone: serving separate
339/// storages from one process at worst over-serialises, never under-serialises.
340pub(crate) async fn lock_path(key: &str) -> tokio::sync::OwnedMutexGuard<()> {
341    type Locks = std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>;
342    static LOCKS: std::sync::LazyLock<std::sync::Mutex<Locks>> =
343        std::sync::LazyLock::new(Default::default);
344    let lock = {
345        let mut locks = LOCKS.lock().unwrap_or_else(|p| p.into_inner());
346        // Drop locks nobody holds or waits on, so the map stays bounded.
347        locks.retain(|_, l| Arc::strong_count(l) > 1);
348        locks.entry(key.to_string()).or_default().clone()
349    };
350    lock.lock_owned().await
351}
352
353/// Persist a git-mark trail and publish its `blocktrails.json` beside it.
354pub async fn save_gitmark_trail(
355    storage: &Arc<dyn Storage>,
356    stored: &StoredGitmarkTrail,
357) -> Result<(), PaymentError> {
358    let body = serde_json::to_vec_pretty(stored)
359        .map_err(|e| PaymentError::Store(format!("serialise git-mark trail: {e}")))?;
360    storage
361        .put(
362            &gitmark_trail_path(&stored.name),
363            Bytes::from(body),
364            "application/json",
365        )
366        .await
367        .map_err(|e| PaymentError::Store(format!("save git-mark trail: {e}")))?;
368    let public = serde_json::to_vec_pretty(&stored.trail)
369        .map_err(|e| PaymentError::Store(format!("serialise blocktrails.json: {e}")))?;
370    storage
371        .put(
372            &gitmark_blocktrails_path(&stored.name),
373            Bytes::from(public),
374            "application/json",
375        )
376        .await
377        .map_err(|e| PaymentError::Store(format!("save blocktrails.json: {e}")))?;
378    Ok(())
379}
380
381/// A fault-injecting [`Storage`] for the anchoring tests: it wraps a real
382/// backend, fails chosen paths with `Io`, and (optionally) yields at every
383/// call so concurrent tasks genuinely interleave.
384#[cfg(test)]
385pub(crate) mod fault_storage {
386    use super::*;
387    use solid_pod_rs::storage::{ResourceMeta, StorageEvent};
388    use solid_pod_rs::PodError;
389    use std::collections::HashSet;
390    use std::sync::Mutex;
391
392    pub(crate) struct FaultStorage {
393        pub(crate) inner: Arc<dyn Storage>,
394        pub(crate) fail_get: Mutex<HashSet<String>>,
395        pub(crate) fail_put: Mutex<HashSet<String>>,
396        pub(crate) yielding: bool,
397    }
398
399    impl FaultStorage {
400        pub(crate) fn new(inner: Arc<dyn Storage>, yielding: bool) -> Arc<Self> {
401            Arc::new(Self {
402                inner,
403                fail_get: Mutex::default(),
404                fail_put: Mutex::default(),
405                yielding,
406            })
407        }
408        pub(crate) fn fail_get(&self, path: &str) {
409            self.fail_get.lock().unwrap().insert(path.to_string());
410        }
411        pub(crate) fn fail_put(&self, path: &str) {
412            self.fail_put.lock().unwrap().insert(path.to_string());
413        }
414        pub(crate) fn heal(&self) {
415            self.fail_get.lock().unwrap().clear();
416            self.fail_put.lock().unwrap().clear();
417        }
418        fn io() -> PodError {
419            PodError::Io(std::io::Error::other("injected fault"))
420        }
421    }
422
423    #[async_trait::async_trait]
424    impl Storage for FaultStorage {
425        async fn get(&self, path: &str) -> Result<(Bytes, ResourceMeta), PodError> {
426            if self.yielding {
427                tokio::task::yield_now().await;
428            }
429            if self.fail_get.lock().unwrap().contains(path) {
430                return Err(Self::io());
431            }
432            self.inner.get(path).await
433        }
434        async fn put(
435            &self,
436            path: &str,
437            body: Bytes,
438            content_type: &str,
439        ) -> Result<ResourceMeta, PodError> {
440            if self.yielding {
441                tokio::task::yield_now().await;
442            }
443            if self.fail_put.lock().unwrap().contains(path) {
444                return Err(Self::io());
445            }
446            self.inner.put(path, body, content_type).await
447        }
448        async fn delete(&self, path: &str) -> Result<(), PodError> {
449            self.inner.delete(path).await
450        }
451        async fn list(&self, container: &str) -> Result<Vec<String>, PodError> {
452            self.inner.list(container).await
453        }
454        async fn head(&self, path: &str) -> Result<ResourceMeta, PodError> {
455            self.inner.head(path).await
456        }
457        async fn exists(&self, path: &str) -> Result<bool, PodError> {
458            self.inner.exists(path).await
459        }
460        async fn watch(
461            &self,
462            path: &str,
463        ) -> Result<tokio::sync::mpsc::Receiver<StorageEvent>, PodError> {
464            self.inner.watch(path).await
465        }
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::fault_storage::FaultStorage;
472    use super::*;
473    use solid_pod_rs::mrc20::MRC20_PROFILE;
474    use solid_pod_rs::storage::memory::MemoryBackend;
475
476    #[test]
477    fn secret_and_prov_paths_compare_whole_segments() {
478        for p in [
479            "/.well-known/token/x.json",
480            ".well-known//token/x.json",
481            "/.well-known/token/blocktrails.json",
482            "/.WELL-KNOWN/gitmark/prov/intent.json",
483            "/.well-known/TOKEN/x.json",
484            "/.well-known/to\u{212A}en/x.json",
485            "/.well-known/gitmar\u{212A}/prov/intent.json",
486            "/.well-known/token/",
487        ] {
488            assert!(is_secret_trail_path(p), "{p}");
489        }
490        for p in [
491            "/.well-known/gitmark/prov/blocktrails.json",
492            "/.well-known/tokens/x.json",
493            "/x/.well-known/token/y",
494            "/open/y",
495        ] {
496            assert!(!is_secret_trail_path(p), "{p}");
497        }
498        assert!(is_prov_state_path("//.well-known/prov/epoch.json"));
499        assert!(!is_prov_state_path("/.well-known/provision/x"));
500    }
501
502    fn genesis() -> Mrc20State {
503        Mrc20State {
504            profile: MRC20_PROFILE.into(),
505            prev: "0".repeat(64),
506            seq: 0,
507            ticker: Some("PROV".into()),
508            name: Some("Prov".into()),
509            decimals: Some(0),
510            supply: Some(1000),
511            balances: Some(std::collections::BTreeMap::from([("02ab".into(), 1000)])),
512            ops: vec![],
513            anchor: None,
514        }
515    }
516
517    fn sample() -> StoredTrail {
518        StoredTrail {
519            ticker: "PROV".into(),
520            name: "Prov".into(),
521            supply: 1000,
522            privkey: "07".repeat(32),
523            pubkey_base: "02".to_string() + &"ab".repeat(32),
524            states: vec![genesis()],
525            state_strings: vec!["{\"seq\":0}".into()],
526            current_txid: "ab".repeat(32),
527            current_vout: 0,
528            current_amount: 9700,
529            network: "testnet4".into(),
530            date_created: "2026-06-13T00:00:00Z".into(),
531        }
532    }
533
534    #[test]
535    fn trail_path_lowercases() {
536        assert_eq!(trail_path("PROV"), "/.well-known/token/prov.json");
537        assert_eq!(trail_path("prov"), "/.well-known/token/prov.json");
538    }
539
540    #[test]
541    fn to_public_drops_privkey() {
542        let t = sample();
543        let public = t.to_public();
544        // The public type has no privkey field; round-trip its JSON and assert
545        // the secret is absent.
546        let json = serde_json::to_string(&public).unwrap();
547        assert!(
548            !json.contains(&t.privkey),
549            "privkey must not appear in public trail"
550        );
551        assert_eq!(public.ticker, "PROV");
552        assert_eq!(public.current_amount, 9700);
553    }
554
555    #[test]
556    fn stored_trail_uses_camelcase_keys() {
557        // JSS file-format parity: keys are camelCase (pubkeyBase, stateStrings…).
558        let json = serde_json::to_string(&sample()).unwrap();
559        assert!(json.contains("\"pubkeyBase\""));
560        assert!(json.contains("\"stateStrings\""));
561        assert!(json.contains("\"currentTxid\""));
562        assert!(json.contains("\"dateCreated\""));
563    }
564
565    #[tokio::test]
566    async fn load_missing_trail_is_none() {
567        let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
568        let got = load_trail(&storage, "NOPE").await.unwrap();
569        assert!(got.is_none());
570    }
571
572    #[tokio::test]
573    async fn save_then_load_round_trips() {
574        let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
575        save_trail(&storage, &sample()).await.unwrap();
576        let got = load_trail(&storage, "PROV")
577            .await
578            .unwrap()
579            .expect("trail present");
580        assert_eq!(got.ticker, "PROV");
581        assert_eq!(got.privkey, "07".repeat(32));
582        assert_eq!(got.states.len(), 1);
583        // Lower-cased lookup also resolves.
584        assert!(load_trail(&storage, "prov").await.unwrap().is_some());
585    }
586
587    #[tokio::test]
588    async fn merge_public_keeps_secret() {
589        let mut t = sample();
590        let secret = t.privkey.clone();
591        let mut public = t.to_public();
592        public.current_txid = "ff".repeat(32);
593        public.current_amount = 9400;
594        public.states.push(genesis()); // simulate an appended state
595        t.merge_public(&public);
596        assert_eq!(t.privkey, secret, "secret retained across merge");
597        assert_eq!(t.current_txid, "ff".repeat(32));
598        assert_eq!(t.current_amount, 9400);
599        assert_eq!(t.states.len(), 2);
600    }
601
602    #[tokio::test]
603    async fn gitmark_trail_round_trips_and_publishes_no_secret() {
604        use solid_pod_rs::blocktrail::BlocktrailTxo;
605        let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
606        let commit = "9adc596cfd1100333393a12f2f41b2d820f16d0b";
607        let stored = StoredGitmarkTrail {
608            name: "Prov".into(),
609            privkey: "07".repeat(32),
610            trail: Blocktrail::gitmark(
611                "02".to_string() + &"ab".repeat(32),
612                "tbtc4",
613                vec![commit.into()],
614                vec![BlocktrailTxo::new("tbtc4", "cd".repeat(32), 0).with_commit(commit)],
615            ),
616            date_created: "2026-10-02T00:00:00Z".into(),
617        };
618        assert_eq!(stored.network(), "testnet4");
619        save_gitmark_trail(&storage, &stored).await.unwrap();
620        let back = load_gitmark_trail(&storage, "prov").await.unwrap().unwrap();
621        assert_eq!(back.trail, stored.trail);
622        assert_eq!(back.privkey, stored.privkey);
623        let (public, _) = storage
624            .get(&gitmark_blocktrails_path("PROV"))
625            .await
626            .unwrap();
627        let public = String::from_utf8(public.to_vec()).unwrap();
628        assert!(
629            !public.contains(&stored.privkey),
630            "the secret is never published"
631        );
632        let parsed: Blocktrail = serde_json::from_str(&public).unwrap();
633        assert_eq!(parsed, stored.trail);
634        assert!(load_gitmark_trail(&storage, "none")
635            .await
636            .unwrap()
637            .is_none());
638    }
639
640    #[tokio::test]
641    async fn loaders_propagate_storage_errors_and_only_not_found_is_absent() {
642        let mem: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
643        let faulty = FaultStorage::new(mem, false);
644        faulty.fail_get(&trail_path("prov"));
645        faulty.fail_get(&gitmark_trail_path("prov"));
646        let storage: Arc<dyn Storage> = faulty.clone();
647        assert!(load_trail(&storage, "prov").await.is_err());
648        assert!(load_gitmark_trail(&storage, "prov").await.is_err());
649        // a path that is merely absent is still `None`
650        assert!(load_trail(&storage, "other").await.unwrap().is_none());
651        assert!(load_gitmark_trail(&storage, "other")
652            .await
653            .unwrap()
654            .is_none());
655    }
656}