Skip to main content

coven_format/
path.rs

1//! Canonical object names and listing prefixes from format.md D10.
2//!
3//! Crate-to-crate API: coven's format, storage and sync code build paths and
4//! prefixes here. Native provider adapters map these validated paths into
5//! their provider's object names.
6
7use coven_foundation::id_source::{DeviceId, InviteId, ObjectId, StoreId};
8use coven_merge::Audience;
9use serde::{Deserialize, Serialize};
10use std::num::NonZeroU64;
11
12/// One validated encrypted-object path in the layout of §§5, 10, 12, 13, 16 and 17.
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(try_from = "String", into = "String")]
15pub struct ObjectPath(String);
16
17impl ObjectPath {
18    /// One create-once slot in the store's shared sequence (§5).
19    pub fn slot(store: StoreId, number: NonZeroU64) -> Self {
20        Self(format!("{store}/seq/{number}"))
21    }
22    /// The positive number of a sequence slot.
23    pub fn slot_number(&self) -> Option<crate::value::Slot> {
24        self.relative()
25            .strip_prefix("seq/")
26            .map(|number| number.parse::<NonZeroU64>().expect("validated slot").into())
27    }
28    /// The store's conditionally replaced floor (§16.4).
29    pub fn floor(store: StoreId) -> Self {
30        Self(format!("{store}/floor"))
31    }
32    /// The identity carried by a snapshot path.
33    pub fn snapshot_id(&self) -> Option<crate::admin::SnapshotId> {
34        let rest = self.relative().strip_prefix("snapshots/")?;
35        let (audience, rest) = rest.split_once('/').expect("validated snapshot path");
36        let (device, number) = rest.split_once('/').expect("validated snapshot path");
37        let audience: Audience = audience.parse().expect("validated audience");
38        Some(crate::admin::SnapshotId {
39            audience,
40            device: DeviceId(device.parse().expect("validated device")),
41            number: number.parse().expect("validated number"),
42        })
43    }
44    /// A snapshot written by a device (§16).
45    pub fn snapshot(
46        store: StoreId,
47        audience: Audience,
48        device: DeviceId,
49        number: NonZeroU64,
50    ) -> Self {
51        Self(format!("{store}/snapshots/{}/{}/{number}", audience, device.0))
52    }
53    /// A stored object: encrypted file bytes named by a random id (§17.5).
54    pub fn file(store: StoreId, device: DeviceId, object: ObjectId) -> Self {
55        Self(format!("{store}/files/{}/{object}", device.0))
56    }
57    /// An encrypted join request under the device that made its invite and
58    /// the invite's id (§13.2).
59    pub fn join_request(store: StoreId, inviting: DeviceId, invite: InviteId) -> Self {
60        Self(format!("{store}/join-requests/{}/{invite}", inviting.0))
61    }
62    /// The marker of a device gone for good, beside the join requests to
63    /// its invites (§13.2).
64    pub fn device_gone(store: StoreId, device: DeviceId) -> Self {
65        Self(format!("{store}/join-requests/{}/{GONE}", device.0))
66    }
67    /// The invite named by a join request's path.
68    pub fn join_request_invite(&self) -> Option<InviteId> {
69        match self.relative().split('/').collect::<Vec<_>>().as_slice() {
70            ["join-requests", _, GONE] => None,
71            ["join-requests", _, invite] => {
72                Some(InviteId(invite.parse().expect("validated invite id")))
73            }
74            _ => None,
75        }
76    }
77    /// Parse a listed or recorded path without permitting traversal or other layouts.
78    pub fn parse(value: &str) -> Result<Self, PathError> {
79        value.to_owned().try_into()
80    }
81    /// The store whose objects this path names.
82    pub fn store(&self) -> StoreId {
83        store_id(&self.0)
84    }
85    fn relative(&self) -> &str {
86        self.0.split_once('/').expect("validated store path").1
87    }
88    /// The path bound into the object's encryption by the caller.
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92    /// Only the floor can be replaced (D10).
93    pub fn is_replaceable(&self) -> bool {
94        self.relative() == "floor"
95    }
96    /// The device named by a snapshot or uploaded file path.
97    pub fn device(&self) -> Option<DeviceId> {
98        let parts: Vec<_> = self.relative().split('/').collect();
99        match parts.as_slice() {
100            ["files", device, _] | ["snapshots", _, device, _] => {
101                Some(DeviceId(device.parse().expect("validated device id")))
102            }
103            _ => None,
104        }
105    }
106
107    /// The stored object's id, the last part of a stored file's path.
108    pub fn object_id(&self) -> Option<ObjectId> {
109        match self.relative().split('/').collect::<Vec<_>>().as_slice() {
110            ["files", _, object] => Some(ObjectId(object.parse().expect("validated object id"))),
111            _ => None,
112        }
113    }
114
115    /// The canonical path components, for providers with folder objects.
116    pub fn components(&self) -> Vec<&str> {
117        self.0.split('/').collect()
118    }
119    /// The last path component.
120    pub fn file_name(&self) -> &str {
121        match self.0.rsplit('/').next() {
122            Some(name) => name,
123            None => &self.0,
124        }
125    }
126    /// The provider path with a leading slash.
127    pub fn absolute(&self) -> String {
128        format!("/{}", self.0)
129    }
130}
131
132impl TryFrom<String> for ObjectPath {
133    type Error = PathError;
134    fn try_from(value: String) -> Result<Self, Self::Error> {
135        let (store, relative) = value.split_once('/').ok_or(PathError)?;
136        if !canonical_uuid(store) {
137            return Err(PathError);
138        }
139        let parts: Vec<_> = relative.split('/').collect();
140        let valid = match parts.as_slice() {
141            ["seq", n] => decimal(n, true),
142            ["snapshots", audience, device, n] => {
143                audience.parse::<Audience>().is_ok() && decimal(device, false) && decimal(n, true)
144            }
145            ["files", device, name] => decimal(device, false) && canonical_uuid(name),
146            ["join-requests", device, GONE] => decimal(device, false),
147            ["join-requests", device, invite] => decimal(device, false) && canonical_uuid(invite),
148            ["floor"] => true,
149            _ => false,
150        };
151        if !valid {
152            return Err(PathError);
153        }
154        Ok(Self(value))
155    }
156}
157impl From<ObjectPath> for String {
158    fn from(path: ObjectPath) -> Self {
159        path.0
160    }
161}
162
163/// The last part of a gone device's marker's path.
164const GONE: &str = "gone";
165
166/// A prefix of the validated object layout, used for provider listing.
167#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
168pub struct ObjectPrefix(String);
169impl ObjectPrefix {
170    /// All objects belonging to this store.
171    pub fn all(store: StoreId) -> Self {
172        Self(format!("{store}/"))
173    }
174    /// Every slot of the store's sequence (§5).
175    pub fn slots(store: StoreId) -> Self {
176        Self(format!("{store}/seq/"))
177    }
178    /// Every snapshot, of every audience and device (§16).
179    pub fn snapshots(store: StoreId) -> Self {
180        Self(format!("{store}/snapshots/"))
181    }
182    /// Every stored file.
183    pub fn files(store: StoreId) -> Self {
184        Self(format!("{store}/files/"))
185    }
186    /// The files one device uploaded (§17.5).
187    pub fn device_files(store: StoreId, device: DeviceId) -> Self {
188        Self(format!("{store}/files/{}/", device.0))
189    }
190    /// The snapshots one device stored of one audience (§16.6).
191    pub fn device_snapshots(store: StoreId, audience: Audience, device: DeviceId) -> Self {
192        Self(format!("{store}/snapshots/{}/{}/", audience, device.0))
193    }
194    /// Every waiting join request.
195    pub fn join_requests(store: StoreId) -> Self {
196        Self(format!("{store}/join-requests/"))
197    }
198    /// The join requests to one device's invites, the markers of its ended
199    /// invites, and its gone marker once it is gone for good (§13.2).
200    pub fn device_join_requests(store: StoreId, device: DeviceId) -> Self {
201        Self(format!("{store}/join-requests/{}/", device.0))
202    }
203    /// The store whose objects this prefix selects.
204    pub fn store(&self) -> StoreId {
205        store_id(&self.0)
206    }
207    /// The prefix supplied to the provider.
208    pub fn as_str(&self) -> &str {
209        &self.0
210    }
211    /// The folder this prefix names, without its closing slash, as a
212    /// provider with folders names it.
213    pub fn folder(&self) -> &str {
214        self.0.trim_end_matches('/')
215    }
216    /// Whether a validated path is under this prefix.
217    pub fn contains(&self, path: &ObjectPath) -> bool {
218        path.0.starts_with(&self.0)
219    }
220}
221
222/// The reserved folder holding one device's upload temporaries and
223/// sessions in a store (D10). It names the device, so whose they are stays
224/// known, and no object path lies under it, so listings of published
225/// objects skip it.
226pub fn device_uploads(store: StoreId, device: DeviceId) -> String {
227    format!("{store}/.coven-uploads/{}", device.0)
228}
229
230/// Validate a provider folder as a strict ancestor of an object in the
231/// layout. Empty folders left by interrupted uploads remain recognizable.
232pub fn validate_directory(value: &str) -> Result<(), PathError> {
233    let mut parts = value.split('/');
234    if !parts.next().is_some_and(canonical_uuid) {
235        return Err(PathError);
236    }
237    let parts: Vec<_> = parts.collect();
238    let valid = match parts.as_slice() {
239        [] => true,
240        ["seq" | "snapshots" | "files" | "join-requests"] => true,
241        ["files", device] | ["join-requests", device] => decimal(device, false),
242        ["snapshots", audience] => audience.parse::<Audience>().is_ok(),
243        ["snapshots", audience, device] => {
244            audience.parse::<Audience>().is_ok() && decimal(device, false)
245        }
246        _ => false,
247    };
248    if !valid {
249        return Err(PathError);
250    }
251    Ok(())
252}
253
254fn store_id(path: &str) -> StoreId {
255    StoreId(
256        path.split_once('/').expect("validated store path").0.parse().expect("validated store id"),
257    )
258}
259
260fn decimal(part: &str, nonzero: bool) -> bool {
261    canonical_decimal(part).is_some_and(|n| !nonzero || n != 0)
262}
263
264/// The number `text` spells in decimal with no sign, padding or leading
265/// zeros (D10), the one spelling coven writes.
266pub(crate) fn canonical_decimal(text: &str) -> Option<u64> {
267    text.parse::<u64>().ok().filter(|n| n.to_string() == text)
268}
269fn canonical_uuid(value: &str) -> bool {
270    coven_foundation::id_source::canonical_uuid(value).is_ok()
271}
272
273/// A path is outside the canonical object layout.
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub struct PathError;
276impl std::fmt::Display for PathError {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.write_str("invalid storage path")
279    }
280}
281impl std::error::Error for PathError {}
282
283#[cfg(test)]
284#[path = "path_tests.rs"]
285mod tests;