Skip to main content

limnifs_core/
locator.rs

1//! Locator entry (spec §12, `bit-level/37-locator-entry.md`).
2//!
3//! A length-prefixed URI in the form `scheme ":" scheme_specific_part`.
4//! Locator entries appear inside larger sections (metadata reference
5//! §5.3, slab index §5.4). Readers race alternatives per §I9 when
6//! multiple entries exist for one blob.
7
8use crate::cursor::ManifestCursor;
9use crate::error::CoreError;
10
11/// Width of the u32 LE length prefix on every locator entry.
12pub const LOCATOR_LENGTH_PREFIX_LEN: usize = 4;
13
14/// Default per-locator URI byte ceiling. The manifest's parameters
15/// section may override.
16pub const DEFAULT_LOCATOR_MAX_URI_BYTES: u32 = 4 * 1024;
17
18/// Smallest meaningful URI: one-letter scheme plus `://`. Lengths
19/// below this are `Corrupt`.
20pub const MIN_LOCATOR_URI_BYTES: u32 = 4;
21
22/// A parsed locator entry. The URI is owned so the entry outlives the
23/// cursor's borrow.
24#[derive(Clone, Debug, Eq, PartialEq, Hash)]
25pub struct LocatorEntry {
26    pub uri: String,
27}
28
29impl LocatorEntry {
30    /// Extract the scheme (substring before the first `:`). Returns
31    /// `None` if there is no colon — but parsers reject such inputs
32    /// up front, so callers can treat this as infallible for any
33    /// locator that came from [`parse_locator_entry`].
34    #[must_use]
35    pub fn scheme(&self) -> Option<&str> {
36        self.uri.split_once(':').map(|(scheme, _)| scheme)
37    }
38
39    /// Extract the scheme-specific part (everything after the first
40    /// `:`). Returns `None` if there is no colon.
41    #[must_use]
42    pub fn scheme_specific_part(&self) -> Option<&str> {
43        self.uri.split_once(':').map(|(_, rest)| rest)
44    }
45}
46
47/// Parse a single locator entry from the cursor's current position.
48///
49/// Reads the u32 LE length prefix, then the URI bytes. Performs the
50/// structural checks: minimum length, maximum length (default 4 KiB),
51/// UTF-8 validity, presence of a `:` separator, and RFC 3986 scheme
52/// grammar.
53///
54/// Does NOT check whether the scheme is one the reader implements —
55/// that policy belongs to the locator-racing layer (§I9), not the
56/// parser.
57///
58/// # Errors
59///
60/// - [`CoreError::TooShort`] if the cursor has fewer than
61///   `4 + length` bytes.
62/// - [`CoreError::Corrupt`] if `length < 4`, if `length` exceeds the
63///   configured ceiling, if the URI bytes are not valid UTF-8, if no
64///   `:` separator is present, or if the scheme does not match RFC
65///   3986 grammar.
66pub fn parse_locator_entry(cursor: &mut ManifestCursor<'_>) -> Result<LocatorEntry, CoreError> {
67    parse_locator_entry_with_ceiling(cursor, DEFAULT_LOCATOR_MAX_URI_BYTES)
68}
69
70/// Parse `count` consecutive locator entries. Used by sections that
71/// carry a u32 LE locator count followed by N locator entries
72/// (§5.3 metadata reference, §5.4 slab index, future sections).
73///
74/// Performs the pre-allocation `DoS` check: verifies the cursor's
75/// remaining bytes are at least `count × MIN_LOCATOR_URI_BYTES` BEFORE
76/// allocating the result `Vec`. Without this, a malicious `count`
77/// could trigger a multi-GB allocation.
78///
79/// # Errors
80///
81/// Inherits all errors from [`parse_locator_entry`], and additionally
82/// returns [`CoreError::Corrupt`] when `count` overflows usize when
83/// scaled by the minimum entry width, or [`CoreError::TooShort`] when
84/// the remaining buffer cannot hold the declared count.
85pub 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
92/// Same as [`parse_locator_entries`] but with a caller-supplied
93/// per-entry URI byte ceiling.
94///
95/// # Errors
96///
97/// Inherits all errors from [`parse_locator_entries`].
98///
99/// # Panics
100///
101/// Panics if `MIN_LOCATOR_URI_BYTES` somehow does not fit in `usize`.
102/// This is a constant (4) and the panic is unreachable on any
103/// supported platform; the assertion exists only to satisfy the
104/// `u32`→`usize` cast on 32-bit targets.
105pub 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    // Each locator entry needs at least: 4-byte length prefix + MIN_LOCATOR_URI_BYTES.
114    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            // Annotate the error with the entry index so callers get a
131            // precise pointer when debugging.
132            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
144/// Same as [`parse_locator_entry`] but lets the caller supply a
145/// `max_uri_bytes` overriding the 4 KiB default.
146///
147/// # Errors
148///
149/// Inherits all errors from [`parse_locator_entry`].
150pub 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
197/// Extract the local sidecar FILE NAME from a `file:` locator URI,
198/// refusing anything that could escape the image's directory when
199/// joined against the image path.
200///
201/// The URI grammar itself stays permissive (the format allows rich
202/// `file:` paths such as `file:///var/lib/...` for future resolver
203/// backends), so this gate lives at every LOCAL join site: only a
204/// flat name — no `/`, no `\\`, no NUL, no `:` (kills drive letters
205/// and scheme confusion), and not `.`/`..` — may be resolved against
206/// the local filesystem. Without this, a malicious manifest could
207/// point a slab or metadata sidecar at `file:../../etc/passwd` (or
208/// an absolute path, which `Path::join` substitutes wholesale) and
209/// exfiltrate host files through `cat`/`extract` (CWE-22).
210///
211/// Writer-emitted locators are always flat (`slab-0.bin`,
212/// `metadata.bin`), so legitimate images are unaffected.
213///
214/// # Errors
215///
216/// [`CoreError::Corrupt`] if the URI is not `file:`, or its
217/// scheme-specific part is not a flat file name.
218pub 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
244/// RFC 3986 section 3.1: `scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`.
245fn 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        // Scheme `limni-p2p` exercises the `-` grammar; an extra
311        // `+` in the scheme position is also valid per RFC 3986.
312        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:"); // invalid UTF-8 + colon
358        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()); // claim 100 bytes
419        bytes.extend_from_slice(b"file://short"); // only 11 bytes
420        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        // Declare 10 entries but provide only 1.
476        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        // Entry 1 is fine; entry 2 has no colon.
489        let mut bytes = Vec::new();
490        bytes.extend(make_locator_bytes("file:///a.bin"));
491        bytes.extend(make_locator_bytes("abcde")); // missing colon
492        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        // CWE-22: each of these, joined against the image directory,
520        // escapes it (or replaces it wholesale for absolute paths).
521        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}