Skip to main content

moq_archive/
path.rs

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
8/// Bytes that are not `A-Z a-z 0-9 _ -` and must be percent-encoded in a track name.
9const TRACK: &AsciiSet = &NON_ALPHANUMERIC.remove(b'_').remove(b'-');
10
11/// Percent-encode a track name as one object-store path segment.
12pub(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
19/// Decode a percent-encoded track name, refusing anything that is not canonical.
20pub(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
31/// Write a group or segment ID as 19 zero-padded decimal digits.
32pub(crate) fn format_id(id: u64) -> Result<String> {
33	check_id(id)?;
34	Ok(format!("{id:0width$}", width = ID_WIDTH))
35}
36
37/// Parse a 19-digit decimal ID field, refusing values outside the recording range.
38pub(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
46/// Refuse an ID outside 0 through 2^53 - 1.
47pub(crate) fn check_id(id: u64) -> Result<u64> {
48	if id > ID_MAX { Err(Error::Id(id)) } else { Ok(id) }
49}
50
51/// A recording object key under an application prefix.
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub enum Key {
54	/// `<encoded-track>/.info`
55	Info {
56		/// The unencoded track name.
57		track: String,
58	},
59	/// `<encoded-track>/groups/<largest>.<smallest>`
60	Groups {
61		/// The unencoded track name.
62		track: String,
63		/// Inclusive group sequences in the object, from first to last.
64		range: RangeInclusive<u64>,
65	},
66	/// `<encoded-track>/segments/<segment>`
67	Segments {
68		/// The unencoded track name.
69		track: String,
70		/// The committed timeline segment ID.
71		segment: u64,
72	},
73}
74
75impl Key {
76	/// A track's `.info` object.
77	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	/// A groups object named by its inclusive first-to-last sequence range.
84	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	/// A timeline object at `segments/<segment>`.
92	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	/// The unencoded track name this key belongs to.
100	pub fn track(&self) -> &str {
101		match self {
102			Self::Info { track } | Self::Groups { track, .. } | Self::Segments { track, .. } => track,
103		}
104	}
105
106	/// Encode this key under `prefix`.
107	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	/// Parse a store location relative to `prefix`.
123	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}