spool/
spool_id.rs

1use chrono::{DateTime, Duration, TimeZone, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5use uuid::{ClockSequence, ContextV1, Timestamp, Uuid};
6
7/// Identifies a message within the spool of its host node.
8// Always a v1 UUID. Every construction path rejects other UUID
9// versions, guaranteeing the embedded timestamp used for expiry,
10// spool path layout, and xfer id derivation.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(into = "String", try_from = "String")]
13#[derive(utoipa::ToSchema)]
14#[schema(value_type=String, example="d7ef132b5d7711eea8c8000c29c33806")]
15pub struct SpoolId(Uuid);
16
17impl std::fmt::Display for SpoolId {
18    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
19        self.0.simple().fmt(fmt)
20    }
21}
22
23impl From<SpoolId> for String {
24    fn from(id: SpoolId) -> String {
25        id.to_string()
26    }
27}
28
29/// Error raised when a string cannot be interpreted as a SpoolId.
30#[derive(thiserror::Error, Debug)]
31pub enum SpoolIdParseError {
32    #[error(transparent)]
33    Parse(#[from] uuid::Error),
34    #[error("spool id {0} is not a v1 UUID")]
35    NotV1(String),
36}
37
38impl TryFrom<String> for SpoolId {
39    type Error = SpoolIdParseError;
40
41    fn try_from(s: String) -> Result<Self, Self::Error> {
42        let uuid = Uuid::parse_str(&s)?;
43        Self::checked(uuid).ok_or(SpoolIdParseError::NotV1(s))
44    }
45}
46
47impl Default for SpoolId {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl SpoolId {
54    pub fn new() -> Self {
55        // We're using v1, but we should be able to seamlessly upgrade to v7
56        // once that feature stabilizes in the uuid crate
57        Self(uuid_helper::now_v1())
58    }
59
60    pub fn compute_path(&self, in_dir: &Path) -> PathBuf {
61        let (a, b, c, [d, e, f, g, h, i, j, k]) = self.0.as_fields();
62        // Note that in a v1 UUID, a,b,c holds the timestamp components
63        // from least-significant up to most significant.
64        let [a1, a2, a3, a4] = a.to_be_bytes();
65        let name = format!(
66            "{a1:02x}/{a2:02x}/{a3:02x}/{a4:02x}/{b:04x}{c:04x}{d:02x}{e:02x}{f:02x}{g:02x}{h:02x}{i:02x}{j:02x}{k:02x}"
67        );
68        in_dir.join(name)
69    }
70
71    pub fn as_bytes(&self) -> &[u8; 16] {
72        self.0.as_bytes()
73    }
74
75    pub fn from_slice(s: &[u8]) -> Option<Self> {
76        let uuid = Uuid::from_slice(s).ok()?;
77        Self::checked(uuid)
78    }
79
80    pub fn from_ascii_bytes(s: &[u8]) -> Option<Self> {
81        let uuid = Uuid::try_parse_ascii(s).ok()?;
82        Self::checked(uuid)
83    }
84
85    pub fn from_path(mut path: &Path) -> Option<Self> {
86        let mut components = vec![];
87
88        for _ in 0..5 {
89            components.push(path.file_name()?.to_str()?);
90            path = path.parent()?;
91        }
92
93        components.reverse();
94        Self::checked(Uuid::parse_str(&components.join("")).ok()?)
95    }
96
97    /// Rejects any UUID that is not v1.
98    fn checked(uuid: Uuid) -> Option<Self> {
99        (uuid.get_version_num() == 1).then_some(Self(uuid))
100    }
101
102    /// Returns time elapsed since the id was created,
103    /// given the current timestamp
104    pub fn age(&self, now: DateTime<Utc>) -> Duration {
105        let created = self.created();
106        now - created
107    }
108
109    pub fn created(&self) -> DateTime<Utc> {
110        let (seconds, nanos) = self.0.get_timestamp().unwrap().to_unix();
111        Utc.timestamp_opt(seconds.try_into().unwrap(), nanos)
112            .unwrap()
113    }
114
115    /// Assuming that self is a SpoolId received from some other node,
116    /// this method produces a new SpoolId with the information
117    /// from the local node, but with the timestamp from the source
118    /// spool id.
119    /// The intent is to reduces the chances of having multiple
120    /// messages with the same spool id live on a system in the
121    /// case of a misconfiguration that produces a loop.
122    pub fn derive_new_with_cloned_timestamp(&self) -> Self {
123        let ts = self
124            .0
125            .get_timestamp()
126            .expect("SpoolId is always v1, and v1 UUIDs always have a timestamp");
127
128        let candidate = Self(uuid_helper::new_v1(ts));
129
130        if candidate != *self {
131            return candidate;
132        }
133
134        // There's a conflict; try to avoid it by working
135        // through a sequence that increments a shared, initially
136        // randomized, counter.
137        // If we do have a routing loop then at least
138        // we stand some chance of avoiding re-using
139        // the same spoolid, but it isn't totally foolproof.
140
141        // Note: Context is only suitable for V1 uuids,
142        // which is what we're using here.
143        static CONTEXT: LazyLock<ContextV1> = LazyLock::new(ContextV1::new_random);
144
145        let (mut seconds, mut subsec_nanos) = ts.to_unix();
146        loop {
147            let (counter, secs, nanos) = CONTEXT.generate_timestamp_sequence(seconds, subsec_nanos);
148            seconds = secs;
149            subsec_nanos = nanos;
150
151            let ts = Timestamp::from_unix_time(
152                seconds,
153                subsec_nanos,
154                counter.into(),
155                CONTEXT.usable_bits() as u8,
156            );
157
158            let candidate = Self(uuid_helper::new_v1(ts));
159
160            if candidate != *self {
161                return candidate;
162            }
163        }
164    }
165}
166
167#[cfg(test)]
168mod test {
169    use super::*;
170
171    #[test]
172    fn roundtrip_path() {
173        let id = SpoolId::new();
174        eprintln!("{id}");
175        let path = id.compute_path(Path::new("."));
176        let id2 = SpoolId::from_path(&path).unwrap();
177        assert_eq!(id, id2);
178    }
179
180    #[test]
181    fn roundtrip_bytes() {
182        let id = SpoolId::new();
183        eprintln!("{id}");
184        let bytes = id.as_bytes();
185        let id2 = SpoolId::from_slice(bytes.as_slice()).unwrap();
186        assert_eq!(id, id2);
187    }
188
189    #[test]
190    fn non_v1_id_is_rejected() {
191        // Spool ids are v1 UUIDs. A well-formed but non-v1 id must be rejected
192        // at every construction path, not just the string boundary.
193        let v4 = "6ba7b810-9dad-41d1-80b4-00c04fd430c8";
194        let err = SpoolId::try_from(v4.to_string()).unwrap_err();
195        assert!(matches!(err, SpoolIdParseError::NotV1(_)), "got: {err:#}");
196        k9::assert_equal!(
197            err.to_string(),
198            "spool id 6ba7b810-9dad-41d1-80b4-00c04fd430c8 is not a v1 UUID"
199        );
200        let err = SpoolId::try_from("not-a-uuid".to_string()).unwrap_err();
201        assert!(matches!(err, SpoolIdParseError::Parse(_)), "got: {err:#}");
202        let v4_uuid = uuid::Uuid::parse_str(v4).unwrap();
203        assert!(SpoolId::from_slice(v4_uuid.as_bytes()).is_none());
204        assert!(SpoolId::from_ascii_bytes(v4.as_bytes()).is_none());
205        // Path layout: a1/a2/a3/a4/rest, where a1..a4 are the bytes of the
206        // first field of the UUID.
207        let path = Path::new("6b/a7/b8/10/9dad41d180b400c04fd430c8");
208        assert!(SpoolId::from_path(path).is_none());
209    }
210
211    #[test]
212    fn cloned_timestamp_preserves_time() {
213        // A freshly minted id already has this node's mac address. Re-deriving
214        // it collides on the first attempt and exercises the clock-sequence
215        // fallback loop that reconstructs the timestamp.
216        let id = SpoolId::new();
217        let derived = id.derive_new_with_cloned_timestamp();
218        assert_ne!(id, derived);
219
220        let delta = (derived.created() - id.created()).abs();
221        assert!(
222            delta < Duration::seconds(1),
223            "derived timestamp {} should be close to source {}, delta {delta:?}",
224            derived.created(),
225            id.created()
226        );
227    }
228}