Skip to main content

cashu/nuts/
nut19.rs

1//! NUT-19: Cached Responses
2//!
3//! <https://github.com/cashubtc/nuts/blob/main/19.md>
4
5use serde::{Deserialize, Serialize};
6
7/// Mint settings
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
9pub struct Settings {
10    /// Number of seconds the responses are cached for
11    pub ttl: Option<u64>,
12    /// Cached endpoints
13    pub cached_endpoints: Vec<CachedEndpoint>,
14}
15
16/// List of the methods and paths for which caching is enabled
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub struct CachedEndpoint {
19    /// HTTP Method
20    pub method: Method,
21    /// Route path
22    pub path: Path,
23}
24
25impl CachedEndpoint {
26    /// Create [`CachedEndpoint`]
27    pub fn new(method: Method, path: Path) -> Self {
28        Self { method, path }
29    }
30}
31
32impl Path {
33    /// Create a custom mint path for a payment method
34    pub fn custom_mint(method: &str) -> Self {
35        Path::Custom(format!("/v1/mint/{}", encode_path_segment(method)))
36    }
37
38    /// Create a custom melt path for a payment method
39    pub fn custom_melt(method: &str) -> Self {
40        Path::Custom(format!("/v1/melt/{}", encode_path_segment(method)))
41    }
42}
43
44fn encode_path_segment(segment: &str) -> String {
45    let mut encoded = String::with_capacity(segment.len());
46    for byte in segment.bytes() {
47        match byte {
48            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => {
49                encoded.push(byte as char);
50            }
51            _ => {
52                encoded.push_str(&format!("%{byte:02X}"));
53            }
54        }
55    }
56    encoded
57}
58
59/// HTTP method
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "UPPERCASE")]
62pub enum Method {
63    /// Get
64    Get,
65    /// POST
66    Post,
67}
68
69/// Route path
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
71pub enum Path {
72    /// Swap
73    Swap,
74    /// Custom payment method path (including bolt11, bolt12, and other methods)
75    Custom(String),
76}
77
78impl Serialize for Path {
79    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80    where
81        S: serde::Serializer,
82    {
83        let s = match self {
84            Path::Swap => "/v1/swap",
85            Path::Custom(custom) => custom.as_str(),
86        };
87        serializer.serialize_str(s)
88    }
89}
90
91impl<'de> Deserialize<'de> for Path {
92    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93    where
94        D: serde::Deserializer<'de>,
95    {
96        let s = String::deserialize(deserializer)?;
97        Ok(match s.as_str() {
98            "/v1/swap" => Path::Swap,
99            custom => Path::Custom(custom.to_string()),
100        })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn custom_mint_method_cannot_inject_path_segments() {
110        for method in ["../../v1/swap", "..", "."] {
111            let s = match Path::custom_mint(method) {
112                Path::Custom(s) => s,
113                other => panic!("expected Path::Custom, got {other:?}"),
114            };
115            let segments: Vec<&str> = s.trim_start_matches('/').split('/').collect();
116
117            assert_eq!(
118                segments.len(),
119                3,
120                "method injected extra path segments: {s}"
121            );
122            assert!(
123                !segments.iter().any(|seg| *seg == ".." || *seg == "."),
124                "method injected a path-traversal segment: {s}"
125            );
126        }
127    }
128
129    #[test]
130    fn custom_melt_method_cannot_inject_path_segments() {
131        for method in ["../../v1/swap", "..", "."] {
132            let s = match Path::custom_melt(method) {
133                Path::Custom(s) => s,
134                other => panic!("expected Path::Custom, got {other:?}"),
135            };
136            let segments: Vec<&str> = s.trim_start_matches('/').split('/').collect();
137
138            assert_eq!(
139                segments.len(),
140                3,
141                "method injected extra path segments: {s}"
142            );
143            assert!(
144                !segments.iter().any(|seg| *seg == ".." || *seg == "."),
145                "method injected a path-traversal segment: {s}"
146            );
147        }
148    }
149}