1use std::ops::RangeInclusive;
2
3use object_store::path::{Path, PathPart};
4use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
5
6use crate::{Error, ID_MAX, ID_WIDTH, Result};
7
8const TRACK: &AsciiSet = &NON_ALPHANUMERIC.remove(b'_').remove(b'-');
10
11pub(crate) fn encode_track(name: &str) -> Result<String> {
13 if name.is_empty() {
14 return Err(Error::Track);
15 }
16 Ok(utf8_percent_encode(name, TRACK).to_string())
17}
18
19pub(crate) fn decode_track(encoded: &str) -> Result<String> {
21 if encoded.is_empty() || encoded.starts_with('.') || encoded.contains('/') {
22 return Err(Error::Track);
23 }
24 let decoded = percent_decode_str(encoded).decode_utf8().map_err(|_| Error::Track)?;
25 if encode_track(&decoded)? != encoded {
26 return Err(Error::Track);
27 }
28 Ok(decoded.into_owned())
29}
30
31pub(crate) fn format_id(id: u64) -> Result<String> {
33 check_id(id)?;
34 Ok(format!("{id:0width$}", width = ID_WIDTH))
35}
36
37pub(crate) fn parse_id(field: &str) -> Result<u64> {
39 if field.len() != ID_WIDTH || !field.bytes().all(|b| b.is_ascii_digit()) {
40 return Err(Error::Path(field.to_string()));
41 }
42 let id: u64 = field.parse().map_err(|_| Error::Path(field.to_string()))?;
43 check_id(id)
44}
45
46pub(crate) fn check_id(id: u64) -> Result<u64> {
48 if id > ID_MAX { Err(Error::Id(id)) } else { Ok(id) }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub enum Key {
54 Info {
56 track: String,
58 },
59 Groups {
61 track: String,
63 range: RangeInclusive<u64>,
65 },
66 Segments {
68 track: String,
70 segment: u64,
72 },
73}
74
75impl Key {
76 pub fn info(track: impl Into<String>) -> Result<Self> {
78 let track = track.into();
79 encode_track(&track)?;
80 Ok(Self::Info { track })
81 }
82
83 pub fn groups(track: impl Into<String>, range: RangeInclusive<u64>) -> Result<Self> {
85 let track = track.into();
86 encode_track(&track)?;
87 check_range(&range)?;
88 Ok(Self::Groups { track, range })
89 }
90
91 pub fn segments(track: impl Into<String>, segment: u64) -> Result<Self> {
93 let track = track.into();
94 encode_track(&track)?;
95 check_id(segment)?;
96 Ok(Self::Segments { track, segment })
97 }
98
99 pub fn track(&self) -> &str {
101 match self {
102 Self::Info { track } | Self::Groups { track, .. } | Self::Segments { track, .. } => track,
103 }
104 }
105
106 pub(crate) fn path(&self, prefix: &Path) -> Result<Path> {
108 let path = push(prefix, &encode_track(self.track())?)?;
109 match self {
110 Self::Info { .. } => push(&path, ".info"),
111 Self::Groups { range, .. } => {
112 let path = push(&path, "groups")?;
113 push(&path, &range_name(range)?)
114 }
115 Self::Segments { segment, .. } => {
116 let path = push(&path, "segments")?;
117 push(&path, &format_id(*segment)?)
118 }
119 }
120 }
121
122 pub(crate) fn parse(prefix: &Path, location: &Path) -> Result<Self> {
124 if prefix.as_ref().is_empty() {
125 return parse_parts(location.parts(), location);
126 }
127 let parts = location
128 .prefix_match(prefix)
129 .ok_or_else(|| Error::Path(location.to_string()))?;
130 parse_parts(parts, location)
131 }
132}
133
134fn parse_parts<'a>(mut parts: impl Iterator<Item = PathPart<'a>>, location: &Path) -> Result<Key> {
135 let encoded = parts.next().ok_or_else(|| Error::Path(location.to_string()))?;
136 let track = decode_track(encoded.as_ref())?;
137 let kind = parts.next().ok_or_else(|| Error::Path(location.to_string()))?;
138 match kind.as_ref() {
139 ".info" => {
140 if parts.next().is_some() {
141 return Err(Error::Path(location.to_string()));
142 }
143 Ok(Key::Info { track })
144 }
145 "groups" => {
146 let name = parts.next().ok_or_else(|| Error::Path(location.to_string()))?;
147 if parts.next().is_some() {
148 return Err(Error::Path(location.to_string()));
149 }
150 let range = parse_range(name.as_ref())?;
151 Ok(Key::Groups { track, range })
152 }
153 "segments" => {
154 let name = parts.next().ok_or_else(|| Error::Path(location.to_string()))?;
155 if parts.next().is_some() {
156 return Err(Error::Path(location.to_string()));
157 }
158 Ok(Key::Segments {
159 track,
160 segment: parse_id(name.as_ref())?,
161 })
162 }
163 _ => Err(Error::Path(location.to_string())),
164 }
165}
166
167pub(crate) fn track_prefix(track: &str) -> Result<Path> {
168 push(&Path::ROOT, &encode_track(track)?)
169}
170
171pub(crate) fn groups_prefix(prefix: &Path, track: &str) -> Result<Path> {
172 let path = append(prefix, &track_prefix(track)?);
173 push(&path, "groups")
174}
175
176pub(crate) fn segments_prefix(prefix: &Path, track: &str) -> Result<Path> {
177 let path = append(prefix, &track_prefix(track)?);
178 push(&path, "segments")
179}
180
181pub(crate) fn groups_offset(prefix: &Path, track: &str, group: u64) -> Result<Path> {
182 let path = groups_prefix(prefix, track)?;
183 push(&path, &format_id(group)?)
184}
185
186fn append(base: &Path, relative: &Path) -> Path {
187 let mut path = base.clone();
188 path.extend(relative.parts());
189 path
190}
191
192pub(crate) fn push(base: &Path, segment: &str) -> Result<Path> {
193 let part = PathPart::parse(segment).map_err(|err| Error::Path(err.to_string()))?;
194 Ok(base.clone().join(part))
195}
196
197fn range_name(range: &RangeInclusive<u64>) -> Result<String> {
198 check_range(range)?;
199 let (smallest, largest) = (*range.start(), *range.end());
200 Ok(format!("{}.{}", format_id(largest)?, format_id(smallest)?))
201}
202
203fn parse_range(name: &str) -> Result<RangeInclusive<u64>> {
204 let (largest, smallest) = name.split_once('.').ok_or_else(|| Error::Path(name.to_string()))?;
205 if smallest.contains('.') {
206 return Err(Error::Path(name.to_string()));
207 }
208 let largest = parse_id(largest)?;
209 let smallest = parse_id(smallest)?;
210 if largest < smallest {
211 return Err(Error::Bounds { smallest, largest });
212 }
213 Ok(smallest..=largest)
214}
215
216pub(crate) fn check_range(range: &RangeInclusive<u64>) -> Result<()> {
217 let (smallest, largest) = (*range.start(), *range.end());
218 check_id(smallest)?;
219 check_id(largest)?;
220 if range.is_empty() {
221 return Err(Error::Bounds { smallest, largest });
222 }
223 Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn catalog_json_is_percent_encoded() {
232 assert_eq!(encode_track("catalog.json").unwrap(), "catalog%2Ejson");
233 }
234
235 #[test]
236 fn unreserved_bytes_stay_literal() {
237 assert_eq!(encode_track("AZaz09_-").unwrap(), "AZaz09_-");
238 }
239
240 #[test]
241 fn slash_and_dot_prefix_are_encoded() {
242 assert_eq!(encode_track(".hidden").unwrap(), "%2Ehidden");
243 assert_eq!(encode_track("a/b").unwrap(), "a%2Fb");
244 }
245
246 #[test]
247 fn hex_is_uppercase() {
248 assert_eq!(encode_track(" ").unwrap(), "%20");
249 assert_eq!(encode_track("é").unwrap(), "%C3%A9");
250 }
251
252 #[test]
253 fn empty_track_is_rejected() {
254 assert!(matches!(encode_track(""), Err(Error::Track)));
255 assert!(matches!(decode_track(""), Err(Error::Track)));
256 }
257
258 #[test]
259 fn decode_requires_canonical_encoding() {
260 assert_eq!(decode_track("catalog%2Ejson").unwrap(), "catalog.json");
261 assert!(matches!(decode_track("catalog%2ejson"), Err(Error::Track)));
262 assert!(matches!(decode_track("%41"), Err(Error::Track)));
263 assert!(matches!(decode_track("a/b"), Err(Error::Track)));
264 assert!(matches!(decode_track(".info"), Err(Error::Track)));
265 }
266
267 #[test]
268 fn id_fields_are_19_digits() {
269 assert_eq!(format_id(0).unwrap(), "0000000000000000000");
270 assert_eq!(format_id(ID_MAX).unwrap(), "0009007199254740991");
271 assert_eq!(parse_id("0000000000000000000").unwrap(), 0);
272 assert_eq!(parse_id("0009007199254740991").unwrap(), ID_MAX);
273 }
274
275 #[test]
276 fn ids_outside_the_json_range_are_rejected() {
277 assert!(matches!(format_id(ID_MAX + 1), Err(Error::Id(_))));
278 assert!(matches!(check_id(u64::MAX), Err(Error::Id(_))));
279 assert!(matches!(
280 parse_id("0009007199254740992"),
281 Err(Error::Id(n)) if n == ID_MAX + 1
282 ));
283 assert!(matches!(parse_id("5"), Err(Error::Path(_))));
284 assert!(matches!(parse_id("000000000000000000X"), Err(Error::Path(_))));
285 }
286
287 #[test]
288 fn keys_roundtrip_under_a_prefix() {
289 let prefix = Path::from("rec/1");
290 for key in [
291 Key::info("catalog.json").unwrap(),
292 Key::groups("video", 5..=10).unwrap(),
293 Key::segments("timeline.z", 0).unwrap(),
294 Key::segments("timeline.z", ID_MAX).unwrap(),
295 ] {
296 let path = key.path(&prefix).unwrap();
297 assert_eq!(Key::parse(&prefix, &path).unwrap(), key);
298 }
299 }
300
301 #[test]
302 fn encoded_track_is_not_double_encoded() {
303 let path = Key::info("catalog.json").unwrap().path(&Path::from("rec")).unwrap();
304 assert_eq!(path.as_ref(), "rec/catalog%2Ejson/.info");
305 }
306
307 #[test]
308 fn inverted_range_is_rejected() {
309 let (smallest, largest) = (2, 1);
310 assert!(matches!(
311 Key::groups("v", smallest..=largest),
312 Err(Error::Bounds {
313 smallest: 2,
314 largest: 1
315 })
316 ));
317 }
318
319 #[test]
320 fn exhausted_range_is_rejected() {
321 let mut range = 1..=1;
322 assert_eq!(range.next(), Some(1));
323 assert!(matches!(Key::groups("v", range), Err(Error::Bounds { .. })));
324 }
325
326 #[test]
327 fn range_id_endpoints_are_valid() {
328 let key = Key::groups("v", 0..=ID_MAX).unwrap();
329 assert_eq!(
330 key.path(&Path::from("rec")).unwrap().as_ref(),
331 "rec/v/groups/0009007199254740991.0000000000000000000"
332 );
333 assert!(matches!(Key::groups("v", 0..=ID_MAX + 1), Err(Error::Id(_))));
334 assert!(matches!(Key::groups("v", ID_MAX + 1..=ID_MAX + 1), Err(Error::Id(_))));
335 }
336
337 #[test]
338 fn direct_inverted_range_is_rejected_when_serialized() {
339 let (smallest, largest) = (2, 1);
340 let key = Key::Groups {
341 track: "v".to_string(),
342 range: smallest..=largest,
343 };
344 assert!(matches!(
345 key.path(&Path::from("rec")),
346 Err(Error::Bounds {
347 smallest: 2,
348 largest: 1
349 })
350 ));
351
352 let key = Key::Groups {
353 track: "v".to_string(),
354 range: 0..=ID_MAX + 1,
355 };
356 assert!(matches!(key.path(&Path::from("rec")), Err(Error::Id(_))));
357 }
358
359 #[test]
360 fn groups_offset_has_no_dot() {
361 let path = groups_offset(&Path::from("rec"), "video", 5).unwrap();
362 assert_eq!(path.as_ref(), "rec/video/groups/0000000000000000005");
363 }
364}