1use crate::cursor::ManifestCursor;
9use crate::error::CoreError;
10
11pub const LOCATOR_LENGTH_PREFIX_LEN: usize = 4;
13
14pub const DEFAULT_LOCATOR_MAX_URI_BYTES: u32 = 4 * 1024;
17
18pub const MIN_LOCATOR_URI_BYTES: u32 = 4;
21
22#[derive(Clone, Debug, Eq, PartialEq, Hash)]
25pub struct LocatorEntry {
26 pub uri: String,
27}
28
29impl LocatorEntry {
30 #[must_use]
35 pub fn scheme(&self) -> Option<&str> {
36 self.uri.split_once(':').map(|(scheme, _)| scheme)
37 }
38
39 #[must_use]
42 pub fn scheme_specific_part(&self) -> Option<&str> {
43 self.uri.split_once(':').map(|(_, rest)| rest)
44 }
45}
46
47pub fn parse_locator_entry(cursor: &mut ManifestCursor<'_>) -> Result<LocatorEntry, CoreError> {
67 parse_locator_entry_with_ceiling(cursor, DEFAULT_LOCATOR_MAX_URI_BYTES)
68}
69
70pub fn parse_locator_entries(
86 cursor: &mut ManifestCursor<'_>,
87 count: u32,
88) -> Result<Vec<LocatorEntry>, CoreError> {
89 parse_locator_entries_with_ceiling(cursor, count, DEFAULT_LOCATOR_MAX_URI_BYTES)
90}
91
92pub fn parse_locator_entries_with_ceiling(
106 cursor: &mut ManifestCursor<'_>,
107 count: u32,
108 max_uri_bytes: u32,
109) -> Result<Vec<LocatorEntry>, CoreError> {
110 let count_us = usize::try_from(count).map_err(|_| CoreError::Corrupt {
111 reason: format!("locator entry count {count} exceeds usize"),
112 })?;
113 let min_uri = usize::try_from(MIN_LOCATOR_URI_BYTES).expect("MIN_LOCATOR_URI_BYTES fits usize");
115 let min_entry_width = LOCATOR_LENGTH_PREFIX_LEN + min_uri;
116 let min_total = count_us
117 .checked_mul(min_entry_width)
118 .ok_or_else(|| CoreError::Corrupt {
119 reason: format!("locator entry count {count_us} overflows usize"),
120 })?;
121 if cursor.remaining_len() < min_total {
122 return Err(CoreError::TooShort {
123 have: cursor.remaining_len(),
124 need: min_total,
125 });
126 }
127 let mut entries = Vec::with_capacity(count_us);
128 for index in 0..count_us {
129 let entry = parse_locator_entry_with_ceiling(cursor, max_uri_bytes).map_err(|err| {
130 match err {
133 CoreError::Corrupt { reason } => CoreError::Corrupt {
134 reason: format!("locator entry {index}: {reason}"),
135 },
136 other => other,
137 }
138 })?;
139 entries.push(entry);
140 }
141 Ok(entries)
142}
143
144pub fn parse_locator_entry_with_ceiling(
151 cursor: &mut ManifestCursor<'_>,
152 max_uri_bytes: u32,
153) -> Result<LocatorEntry, CoreError> {
154 let raw_length = cursor.read_u32_le()?;
155 if raw_length < MIN_LOCATOR_URI_BYTES {
156 return Err(CoreError::Corrupt {
157 reason: format!("locator length {raw_length} is below minimum {MIN_LOCATOR_URI_BYTES}"),
158 });
159 }
160 if raw_length > max_uri_bytes {
161 return Err(CoreError::Corrupt {
162 reason: format!("locator length {raw_length} exceeds ceiling {max_uri_bytes}"),
163 });
164 }
165 let length = usize::try_from(raw_length).map_err(|_| CoreError::Corrupt {
166 reason: format!("locator length {raw_length} exceeds usize"),
167 })?;
168 let uri_bytes = cursor.read_n(length)?;
169 let uri = std::str::from_utf8(uri_bytes).map_err(|_| CoreError::Corrupt {
170 reason: format!("locator URI is not valid UTF-8 ({length} bytes)"),
171 })?;
172 let (scheme, rest) = uri.split_once(':').ok_or_else(|| CoreError::Corrupt {
173 reason: format!("locator URI {uri:?} missing scheme separator ':'"),
174 })?;
175 if scheme.is_empty() {
176 return Err(CoreError::Corrupt {
177 reason: format!("locator URI {uri:?} has empty scheme"),
178 });
179 }
180 if !is_valid_scheme(scheme) {
181 return Err(CoreError::Corrupt {
182 reason: format!(
183 "locator URI {uri:?} has scheme {scheme:?} that does not match RFC 3986 grammar"
184 ),
185 });
186 }
187 if rest.is_empty() {
188 return Err(CoreError::Corrupt {
189 reason: format!("locator URI {uri:?} has empty scheme-specific part"),
190 });
191 }
192 Ok(LocatorEntry {
193 uri: uri.to_owned(),
194 })
195}
196
197pub fn local_sidecar_name(uri: &str) -> Result<&str, CoreError> {
219 let rest = uri
220 .strip_prefix("file:")
221 .ok_or_else(|| CoreError::Corrupt {
222 reason: format!(
223 "locator {uri:?} is not a file: URI; local sidecar access requires one"
224 ),
225 })?;
226 if rest.is_empty()
227 || rest == "."
228 || rest == ".."
229 || rest.contains('/')
230 || rest.contains('\\')
231 || rest.contains('\0')
232 || rest.contains(':')
233 {
234 return Err(CoreError::Corrupt {
235 reason: format!(
236 "locator {uri:?} is not a flat file name; local sidecar access \
237 refuses paths that could escape the image directory"
238 ),
239 });
240 }
241 Ok(rest)
242}
243
244fn is_valid_scheme(scheme: &str) -> bool {
246 let mut chars = scheme.chars();
247 let first = chars.next();
248 if !first.is_some_and(|c| c.is_ascii_alphabetic()) {
249 return false;
250 }
251 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn make_locator_bytes(uri: &str) -> Vec<u8> {
259 let mut bytes = Vec::with_capacity(LOCATOR_LENGTH_PREFIX_LEN + uri.len());
260 let length = u32::try_from(uri.len()).expect("test URI fits u32");
261 bytes.extend_from_slice(&length.to_le_bytes());
262 bytes.extend_from_slice(uri.as_bytes());
263 bytes
264 }
265
266 #[test]
267 fn parses_file_uri() {
268 let uri = "file:///var/lib/limnifs/slab-7.bin";
269 let bytes = make_locator_bytes(uri);
270 let mut cursor = ManifestCursor::new(&bytes);
271 let entry = parse_locator_entry(&mut cursor).expect("file URI parses");
272 assert_eq!(entry.uri, uri);
273 assert_eq!(entry.scheme(), Some("file"));
274 assert_eq!(
275 entry.scheme_specific_part(),
276 Some("///var/lib/limnifs/slab-7.bin")
277 );
278 assert_eq!(cursor.position(), bytes.len());
279 }
280
281 #[test]
282 fn parses_https_uri_with_query() {
283 let uri = "https://cdn.example.com/slabs/7.bin?range=0-4095";
284 let bytes = make_locator_bytes(uri);
285 let mut cursor = ManifestCursor::new(&bytes);
286 let entry = parse_locator_entry(&mut cursor).expect("https URI parses");
287 assert_eq!(entry.scheme(), Some("https"));
288 }
289
290 #[test]
291 fn parses_s3_uri() {
292 let uri = "s3://my-bucket/slabs/7.bin?region=us-east-1";
293 let bytes = make_locator_bytes(uri);
294 let mut cursor = ManifestCursor::new(&bytes);
295 let entry = parse_locator_entry(&mut cursor).expect("s3 URI parses");
296 assert_eq!(entry.scheme(), Some("s3"));
297 }
298
299 #[test]
300 fn parses_ipfs_uri() {
301 let uri = "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";
302 let bytes = make_locator_bytes(uri);
303 let mut cursor = ManifestCursor::new(&bytes);
304 let entry = parse_locator_entry(&mut cursor).expect("ipfs URI parses");
305 assert_eq!(entry.scheme(), Some("ipfs"));
306 }
307
308 #[test]
309 fn parses_limni_p2p_uri_with_plus_and_dash() {
310 let uri = "limni-p2p://12D3KooWabc/some-hash";
313 let bytes = make_locator_bytes(uri);
314 let mut cursor = ManifestCursor::new(&bytes);
315 let entry = parse_locator_entry(&mut cursor).expect("limni-p2p URI parses");
316 assert_eq!(entry.scheme(), Some("limni-p2p"));
317 }
318
319 #[test]
320 fn rejects_length_below_minimum() {
321 let bytes = 3u32.to_le_bytes();
322 let mut cursor = ManifestCursor::new(&bytes);
323 match parse_locator_entry(&mut cursor) {
324 Err(CoreError::Corrupt { reason }) => {
325 assert!(reason.contains("minimum"), "got: {reason}");
326 }
327 other => panic!("expected Corrupt, got {other:?}"),
328 }
329 }
330
331 #[test]
332 fn rejects_length_above_default_ceiling() {
333 let bytes = (DEFAULT_LOCATOR_MAX_URI_BYTES + 1).to_le_bytes();
334 let mut cursor = ManifestCursor::new(&bytes);
335 match parse_locator_entry(&mut cursor) {
336 Err(CoreError::Corrupt { reason }) => {
337 assert!(reason.contains("ceiling"), "got: {reason}");
338 }
339 other => panic!("expected Corrupt, got {other:?}"),
340 }
341 }
342
343 #[test]
344 fn custom_ceiling_accepts_longer_uri() {
345 let long_uri = format!("file:///{}", "a".repeat(8192));
346 let bytes = make_locator_bytes(&long_uri);
347 let mut cursor = ManifestCursor::new(&bytes);
348 let entry = parse_locator_entry_with_ceiling(&mut cursor, 16 * 1024)
349 .expect("custom ceiling accepts");
350 assert_eq!(entry.uri, long_uri);
351 }
352
353 #[test]
354 fn rejects_non_utf8_uri() {
355 let mut bytes = Vec::new();
356 bytes.extend_from_slice(&5u32.to_le_bytes());
357 bytes.extend_from_slice(b"ab\xff\xfe:"); let mut cursor = ManifestCursor::new(&bytes);
359 match parse_locator_entry(&mut cursor) {
360 Err(CoreError::Corrupt { reason }) => {
361 assert!(reason.contains("UTF-8"), "got: {reason}");
362 }
363 other => panic!("expected Corrupt, got {other:?}"),
364 }
365 }
366
367 #[test]
368 fn rejects_missing_colon() {
369 let bytes = make_locator_bytes("abcde");
370 let mut cursor = ManifestCursor::new(&bytes);
371 match parse_locator_entry(&mut cursor) {
372 Err(CoreError::Corrupt { reason }) => {
373 assert!(reason.contains("separator"), "got: {reason}");
374 }
375 other => panic!("expected Corrupt, got {other:?}"),
376 }
377 }
378
379 #[test]
380 fn rejects_scheme_starting_with_digit() {
381 let bytes = make_locator_bytes("1abc://example.com/");
382 let mut cursor = ManifestCursor::new(&bytes);
383 match parse_locator_entry(&mut cursor) {
384 Err(CoreError::Corrupt { reason }) => {
385 assert!(reason.contains("RFC 3986"), "got: {reason}");
386 }
387 other => panic!("expected Corrupt, got {other:?}"),
388 }
389 }
390
391 #[test]
392 fn rejects_scheme_with_invalid_character() {
393 let bytes = make_locator_bytes("ab c://example.com/");
394 let mut cursor = ManifestCursor::new(&bytes);
395 match parse_locator_entry(&mut cursor) {
396 Err(CoreError::Corrupt { reason }) => {
397 assert!(reason.contains("RFC 3986"), "got: {reason}");
398 }
399 other => panic!("expected Corrupt, got {other:?}"),
400 }
401 }
402
403 #[test]
404 fn rejects_empty_scheme_specific_part() {
405 let bytes = make_locator_bytes("file:");
406 let mut cursor = ManifestCursor::new(&bytes);
407 match parse_locator_entry(&mut cursor) {
408 Err(CoreError::Corrupt { reason }) => {
409 assert!(reason.contains("empty scheme-specific"), "got: {reason}");
410 }
411 other => panic!("expected Corrupt, got {other:?}"),
412 }
413 }
414
415 #[test]
416 fn rejects_truncated_uri_body() {
417 let mut bytes = Vec::new();
418 bytes.extend_from_slice(&100u32.to_le_bytes()); bytes.extend_from_slice(b"file://short"); let mut cursor = ManifestCursor::new(&bytes);
421 match parse_locator_entry(&mut cursor) {
422 Err(CoreError::TooShort { .. }) => {}
423 other => panic!("expected TooShort, got {other:?}"),
424 }
425 }
426
427 #[test]
428 fn rejects_truncated_length_prefix() {
429 let bytes = [0u8; 3];
430 let mut cursor = ManifestCursor::new(&bytes);
431 match parse_locator_entry(&mut cursor) {
432 Err(CoreError::TooShort { .. }) => {}
433 other => panic!("expected TooShort, got {other:?}"),
434 }
435 }
436
437 #[test]
438 fn parses_two_consecutive_entries() {
439 let mut bytes = Vec::new();
440 bytes.extend(make_locator_bytes("file:///a.bin"));
441 bytes.extend(make_locator_bytes("https://cdn/b.bin"));
442 let mut cursor = ManifestCursor::new(&bytes);
443 let first = parse_locator_entry(&mut cursor).expect("first parses");
444 let second = parse_locator_entry(&mut cursor).expect("second parses");
445 assert_eq!(first.scheme(), Some("file"));
446 assert_eq!(second.scheme(), Some("https"));
447 assert_eq!(cursor.position(), bytes.len());
448 }
449
450 #[test]
451 fn parse_locator_entries_returns_all_in_order() {
452 let mut bytes = Vec::new();
453 bytes.extend(make_locator_bytes("file:///a.bin"));
454 bytes.extend(make_locator_bytes("https://cdn/b.bin"));
455 bytes.extend(make_locator_bytes("s3://bucket/c.bin"));
456 let mut cursor = ManifestCursor::new(&bytes);
457 let entries = parse_locator_entries(&mut cursor, 3).expect("three parse");
458 assert_eq!(entries.len(), 3);
459 assert_eq!(entries[0].scheme(), Some("file"));
460 assert_eq!(entries[1].scheme(), Some("https"));
461 assert_eq!(entries[2].scheme(), Some("s3"));
462 assert_eq!(cursor.position(), bytes.len());
463 }
464
465 #[test]
466 fn parse_locator_entries_handles_zero() {
467 let bytes = Vec::new();
468 let mut cursor = ManifestCursor::new(&bytes);
469 let entries = parse_locator_entries(&mut cursor, 0).expect("zero parses");
470 assert!(entries.is_empty());
471 }
472
473 #[test]
474 fn parse_locator_entries_rejects_count_that_overruns_buffer() {
475 let bytes = make_locator_bytes("file:///a.bin");
477 let mut cursor = ManifestCursor::new(&bytes);
478 match parse_locator_entries(&mut cursor, 10) {
479 Err(CoreError::TooShort { have, need }) => {
480 assert!(need > have, "need {need} should exceed have {have}");
481 }
482 other => panic!("expected TooShort, got {other:?}"),
483 }
484 }
485
486 #[test]
487 fn parse_locator_entries_annotates_inner_error_with_index() {
488 let mut bytes = Vec::new();
490 bytes.extend(make_locator_bytes("file:///a.bin"));
491 bytes.extend(make_locator_bytes("abcde")); let mut cursor = ManifestCursor::new(&bytes);
493 match parse_locator_entries(&mut cursor, 2) {
494 Err(CoreError::Corrupt { reason }) => {
495 assert!(reason.contains("entry 1"), "got: {reason}");
496 assert!(reason.contains("separator"));
497 }
498 other => panic!("expected Corrupt, got {other:?}"),
499 }
500 }
501}
502
503#[cfg(test)]
504mod local_sidecar_tests {
505 use super::local_sidecar_name;
506
507 #[test]
508 fn flat_names_pass() {
509 assert_eq!(local_sidecar_name("file:slab-0.bin").unwrap(), "slab-0.bin");
510 assert_eq!(
511 local_sidecar_name("file:metadata.bin").unwrap(),
512 "metadata.bin"
513 );
514 assert_eq!(local_sidecar_name("file:a.bin").unwrap(), "a.bin");
515 }
516
517 #[test]
518 fn traversal_is_refused() {
519 for evil in [
522 "file:../evil.bin",
523 "file:../../etc/passwd",
524 "file:/etc/passwd",
525 "file://etc/passwd",
526 "file:///var/lib/x",
527 "file:sub/dir/slab.bin",
528 "file:.\\..\\evil",
529 "file:C:\\Windows\\evil",
530 "file:.",
531 "file:..",
532 "file:",
533 ] {
534 let err = local_sidecar_name(evil)
535 .err()
536 .unwrap_or_else(|| panic!("{evil:?} must be refused"));
537 assert!(
538 err.to_string().contains("flat file name"),
539 "{evil:?}: {err}"
540 );
541 }
542 }
543
544 #[test]
545 fn non_file_schemes_are_refused_for_local_access() {
546 for uri in [
547 "https://example.com/x",
548 "s3://bucket/k",
549 "ipfs:cid",
550 "plain",
551 ] {
552 assert!(local_sidecar_name(uri).is_err(), "{uri:?} must be refused");
553 }
554 }
555}