1use 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#[must_use]
29pub fn trail_path(ticker: &str) -> String {
30 format!("/.well-known/token/{}.json", ticker.to_lowercase())
31}
32
33#[must_use]
38pub fn is_secret_trail_path(path: &str) -> bool {
39 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", _, "blocktrails.json"] => canon.ends_with('/'),
51 [".well-known", "gitmark", ..] => true,
52 _ => false,
53 }
54}
55
56#[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#[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 .map(solid_pod_rs::security::dotfile::fold_case)
102 .collect()
103}
104
105#[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 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 #[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 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
160pub 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
179pub 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
198pub const DEFAULT_GITMARK_TRAIL: &str = "prov";
204
205#[must_use]
208pub fn gitmark_trail_path(name: &str) -> String {
209 format!("/.well-known/gitmark/{}.json", name.to_lowercase())
210}
211
212#[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#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct StoredGitmarkTrail {
227 pub name: String,
229 pub privkey: String,
232 pub trail: Blocktrail,
234 #[serde(default)]
236 pub date_created: String,
237}
238
239impl StoredGitmarkTrail {
240 #[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
252pub 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#[must_use]
271pub fn gitmark_intent_path(name: &str) -> String {
272 format!("/.well-known/gitmark/{}/intent.json", name.to_lowercase())
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct GitmarkIntent {
282 pub txid: String,
284 pub stored: StoredGitmarkTrail,
286}
287
288pub 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
305pub 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
323pub 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
336pub(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 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
353pub 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#[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 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 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 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()); 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 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}