1use coven_foundation::id_source::{DeviceId, InviteId, ObjectId, StoreId};
8use coven_merge::Audience;
9use serde::{Deserialize, Serialize};
10use std::num::NonZeroU64;
11
12#[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 pub fn slot(store: StoreId, number: NonZeroU64) -> Self {
20 Self(format!("{store}/seq/{number}"))
21 }
22 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 pub fn floor(store: StoreId) -> Self {
30 Self(format!("{store}/floor"))
31 }
32 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 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 pub fn file(store: StoreId, device: DeviceId, object: ObjectId) -> Self {
55 Self(format!("{store}/files/{}/{object}", device.0))
56 }
57 pub fn join_request(store: StoreId, inviting: DeviceId, invite: InviteId) -> Self {
60 Self(format!("{store}/join-requests/{}/{invite}", inviting.0))
61 }
62 pub fn device_gone(store: StoreId, device: DeviceId) -> Self {
65 Self(format!("{store}/join-requests/{}/{GONE}", device.0))
66 }
67 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 pub fn parse(value: &str) -> Result<Self, PathError> {
79 value.to_owned().try_into()
80 }
81 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 pub fn as_str(&self) -> &str {
90 &self.0
91 }
92 pub fn is_replaceable(&self) -> bool {
94 self.relative() == "floor"
95 }
96 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 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 pub fn components(&self) -> Vec<&str> {
117 self.0.split('/').collect()
118 }
119 pub fn file_name(&self) -> &str {
121 match self.0.rsplit('/').next() {
122 Some(name) => name,
123 None => &self.0,
124 }
125 }
126 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
163const GONE: &str = "gone";
165
166#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
168pub struct ObjectPrefix(String);
169impl ObjectPrefix {
170 pub fn all(store: StoreId) -> Self {
172 Self(format!("{store}/"))
173 }
174 pub fn slots(store: StoreId) -> Self {
176 Self(format!("{store}/seq/"))
177 }
178 pub fn snapshots(store: StoreId) -> Self {
180 Self(format!("{store}/snapshots/"))
181 }
182 pub fn files(store: StoreId) -> Self {
184 Self(format!("{store}/files/"))
185 }
186 pub fn device_files(store: StoreId, device: DeviceId) -> Self {
188 Self(format!("{store}/files/{}/", device.0))
189 }
190 pub fn device_snapshots(store: StoreId, audience: Audience, device: DeviceId) -> Self {
192 Self(format!("{store}/snapshots/{}/{}/", audience, device.0))
193 }
194 pub fn join_requests(store: StoreId) -> Self {
196 Self(format!("{store}/join-requests/"))
197 }
198 pub fn device_join_requests(store: StoreId, device: DeviceId) -> Self {
201 Self(format!("{store}/join-requests/{}/", device.0))
202 }
203 pub fn store(&self) -> StoreId {
205 store_id(&self.0)
206 }
207 pub fn as_str(&self) -> &str {
209 &self.0
210 }
211 pub fn folder(&self) -> &str {
214 self.0.trim_end_matches('/')
215 }
216 pub fn contains(&self, path: &ObjectPath) -> bool {
218 path.0.starts_with(&self.0)
219 }
220}
221
222pub fn device_uploads(store: StoreId, device: DeviceId) -> String {
227 format!("{store}/.coven-uploads/{}", device.0)
228}
229
230pub 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
264pub(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#[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;