use crate::cursor::ManifestCursor;
use crate::error::CoreError;
pub const LOCATOR_LENGTH_PREFIX_LEN: usize = 4;
pub const DEFAULT_LOCATOR_MAX_URI_BYTES: u32 = 4 * 1024;
pub const MIN_LOCATOR_URI_BYTES: u32 = 4;
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct LocatorEntry {
pub uri: String,
}
impl LocatorEntry {
#[must_use]
pub fn scheme(&self) -> Option<&str> {
self.uri.split_once(':').map(|(scheme, _)| scheme)
}
#[must_use]
pub fn scheme_specific_part(&self) -> Option<&str> {
self.uri.split_once(':').map(|(_, rest)| rest)
}
}
pub fn parse_locator_entry(cursor: &mut ManifestCursor<'_>) -> Result<LocatorEntry, CoreError> {
parse_locator_entry_with_ceiling(cursor, DEFAULT_LOCATOR_MAX_URI_BYTES)
}
pub fn parse_locator_entries(
cursor: &mut ManifestCursor<'_>,
count: u32,
) -> Result<Vec<LocatorEntry>, CoreError> {
parse_locator_entries_with_ceiling(cursor, count, DEFAULT_LOCATOR_MAX_URI_BYTES)
}
pub fn parse_locator_entries_with_ceiling(
cursor: &mut ManifestCursor<'_>,
count: u32,
max_uri_bytes: u32,
) -> Result<Vec<LocatorEntry>, CoreError> {
let count_us = usize::try_from(count).map_err(|_| CoreError::Corrupt {
reason: format!("locator entry count {count} exceeds usize"),
})?;
let min_uri = usize::try_from(MIN_LOCATOR_URI_BYTES).expect("MIN_LOCATOR_URI_BYTES fits usize");
let min_entry_width = LOCATOR_LENGTH_PREFIX_LEN + min_uri;
let min_total = count_us
.checked_mul(min_entry_width)
.ok_or_else(|| CoreError::Corrupt {
reason: format!("locator entry count {count_us} overflows usize"),
})?;
if cursor.remaining_len() < min_total {
return Err(CoreError::TooShort {
have: cursor.remaining_len(),
need: min_total,
});
}
let mut entries = Vec::with_capacity(count_us);
for index in 0..count_us {
let entry = parse_locator_entry_with_ceiling(cursor, max_uri_bytes).map_err(|err| {
match err {
CoreError::Corrupt { reason } => CoreError::Corrupt {
reason: format!("locator entry {index}: {reason}"),
},
other => other,
}
})?;
entries.push(entry);
}
Ok(entries)
}
pub fn parse_locator_entry_with_ceiling(
cursor: &mut ManifestCursor<'_>,
max_uri_bytes: u32,
) -> Result<LocatorEntry, CoreError> {
let raw_length = cursor.read_u32_le()?;
if raw_length < MIN_LOCATOR_URI_BYTES {
return Err(CoreError::Corrupt {
reason: format!("locator length {raw_length} is below minimum {MIN_LOCATOR_URI_BYTES}"),
});
}
if raw_length > max_uri_bytes {
return Err(CoreError::Corrupt {
reason: format!("locator length {raw_length} exceeds ceiling {max_uri_bytes}"),
});
}
let length = usize::try_from(raw_length).map_err(|_| CoreError::Corrupt {
reason: format!("locator length {raw_length} exceeds usize"),
})?;
let uri_bytes = cursor.read_n(length)?;
let uri = std::str::from_utf8(uri_bytes).map_err(|_| CoreError::Corrupt {
reason: format!("locator URI is not valid UTF-8 ({length} bytes)"),
})?;
let (scheme, rest) = uri.split_once(':').ok_or_else(|| CoreError::Corrupt {
reason: format!("locator URI {uri:?} missing scheme separator ':'"),
})?;
if scheme.is_empty() {
return Err(CoreError::Corrupt {
reason: format!("locator URI {uri:?} has empty scheme"),
});
}
if !is_valid_scheme(scheme) {
return Err(CoreError::Corrupt {
reason: format!(
"locator URI {uri:?} has scheme {scheme:?} that does not match RFC 3986 grammar"
),
});
}
if rest.is_empty() {
return Err(CoreError::Corrupt {
reason: format!("locator URI {uri:?} has empty scheme-specific part"),
});
}
Ok(LocatorEntry {
uri: uri.to_owned(),
})
}
pub fn local_sidecar_name(uri: &str) -> Result<&str, CoreError> {
let rest = uri
.strip_prefix("file:")
.ok_or_else(|| CoreError::Corrupt {
reason: format!(
"locator {uri:?} is not a file: URI; local sidecar access requires one"
),
})?;
if rest.is_empty()
|| rest == "."
|| rest == ".."
|| rest.contains('/')
|| rest.contains('\\')
|| rest.contains('\0')
|| rest.contains(':')
{
return Err(CoreError::Corrupt {
reason: format!(
"locator {uri:?} is not a flat file name; local sidecar access \
refuses paths that could escape the image directory"
),
});
}
Ok(rest)
}
fn is_valid_scheme(scheme: &str) -> bool {
let mut chars = scheme.chars();
let first = chars.next();
if !first.is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_locator_bytes(uri: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(LOCATOR_LENGTH_PREFIX_LEN + uri.len());
let length = u32::try_from(uri.len()).expect("test URI fits u32");
bytes.extend_from_slice(&length.to_le_bytes());
bytes.extend_from_slice(uri.as_bytes());
bytes
}
#[test]
fn parses_file_uri() {
let uri = "file:///var/lib/limnifs/slab-7.bin";
let bytes = make_locator_bytes(uri);
let mut cursor = ManifestCursor::new(&bytes);
let entry = parse_locator_entry(&mut cursor).expect("file URI parses");
assert_eq!(entry.uri, uri);
assert_eq!(entry.scheme(), Some("file"));
assert_eq!(
entry.scheme_specific_part(),
Some("///var/lib/limnifs/slab-7.bin")
);
assert_eq!(cursor.position(), bytes.len());
}
#[test]
fn parses_https_uri_with_query() {
let uri = "https://cdn.example.com/slabs/7.bin?range=0-4095";
let bytes = make_locator_bytes(uri);
let mut cursor = ManifestCursor::new(&bytes);
let entry = parse_locator_entry(&mut cursor).expect("https URI parses");
assert_eq!(entry.scheme(), Some("https"));
}
#[test]
fn parses_s3_uri() {
let uri = "s3://my-bucket/slabs/7.bin?region=us-east-1";
let bytes = make_locator_bytes(uri);
let mut cursor = ManifestCursor::new(&bytes);
let entry = parse_locator_entry(&mut cursor).expect("s3 URI parses");
assert_eq!(entry.scheme(), Some("s3"));
}
#[test]
fn parses_ipfs_uri() {
let uri = "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";
let bytes = make_locator_bytes(uri);
let mut cursor = ManifestCursor::new(&bytes);
let entry = parse_locator_entry(&mut cursor).expect("ipfs URI parses");
assert_eq!(entry.scheme(), Some("ipfs"));
}
#[test]
fn parses_limni_p2p_uri_with_plus_and_dash() {
let uri = "limni-p2p://12D3KooWabc/some-hash";
let bytes = make_locator_bytes(uri);
let mut cursor = ManifestCursor::new(&bytes);
let entry = parse_locator_entry(&mut cursor).expect("limni-p2p URI parses");
assert_eq!(entry.scheme(), Some("limni-p2p"));
}
#[test]
fn rejects_length_below_minimum() {
let bytes = 3u32.to_le_bytes();
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("minimum"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn rejects_length_above_default_ceiling() {
let bytes = (DEFAULT_LOCATOR_MAX_URI_BYTES + 1).to_le_bytes();
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("ceiling"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn custom_ceiling_accepts_longer_uri() {
let long_uri = format!("file:///{}", "a".repeat(8192));
let bytes = make_locator_bytes(&long_uri);
let mut cursor = ManifestCursor::new(&bytes);
let entry = parse_locator_entry_with_ceiling(&mut cursor, 16 * 1024)
.expect("custom ceiling accepts");
assert_eq!(entry.uri, long_uri);
}
#[test]
fn rejects_non_utf8_uri() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&5u32.to_le_bytes());
bytes.extend_from_slice(b"ab\xff\xfe:"); let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("UTF-8"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn rejects_missing_colon() {
let bytes = make_locator_bytes("abcde");
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("separator"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn rejects_scheme_starting_with_digit() {
let bytes = make_locator_bytes("1abc://example.com/");
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("RFC 3986"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn rejects_scheme_with_invalid_character() {
let bytes = make_locator_bytes("ab c://example.com/");
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("RFC 3986"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn rejects_empty_scheme_specific_part() {
let bytes = make_locator_bytes("file:");
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("empty scheme-specific"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn rejects_truncated_uri_body() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&100u32.to_le_bytes()); bytes.extend_from_slice(b"file://short"); let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::TooShort { .. }) => {}
other => panic!("expected TooShort, got {other:?}"),
}
}
#[test]
fn rejects_truncated_length_prefix() {
let bytes = [0u8; 3];
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entry(&mut cursor) {
Err(CoreError::TooShort { .. }) => {}
other => panic!("expected TooShort, got {other:?}"),
}
}
#[test]
fn parses_two_consecutive_entries() {
let mut bytes = Vec::new();
bytes.extend(make_locator_bytes("file:///a.bin"));
bytes.extend(make_locator_bytes("https://cdn/b.bin"));
let mut cursor = ManifestCursor::new(&bytes);
let first = parse_locator_entry(&mut cursor).expect("first parses");
let second = parse_locator_entry(&mut cursor).expect("second parses");
assert_eq!(first.scheme(), Some("file"));
assert_eq!(second.scheme(), Some("https"));
assert_eq!(cursor.position(), bytes.len());
}
#[test]
fn parse_locator_entries_returns_all_in_order() {
let mut bytes = Vec::new();
bytes.extend(make_locator_bytes("file:///a.bin"));
bytes.extend(make_locator_bytes("https://cdn/b.bin"));
bytes.extend(make_locator_bytes("s3://bucket/c.bin"));
let mut cursor = ManifestCursor::new(&bytes);
let entries = parse_locator_entries(&mut cursor, 3).expect("three parse");
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].scheme(), Some("file"));
assert_eq!(entries[1].scheme(), Some("https"));
assert_eq!(entries[2].scheme(), Some("s3"));
assert_eq!(cursor.position(), bytes.len());
}
#[test]
fn parse_locator_entries_handles_zero() {
let bytes = Vec::new();
let mut cursor = ManifestCursor::new(&bytes);
let entries = parse_locator_entries(&mut cursor, 0).expect("zero parses");
assert!(entries.is_empty());
}
#[test]
fn parse_locator_entries_rejects_count_that_overruns_buffer() {
let bytes = make_locator_bytes("file:///a.bin");
let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entries(&mut cursor, 10) {
Err(CoreError::TooShort { have, need }) => {
assert!(need > have, "need {need} should exceed have {have}");
}
other => panic!("expected TooShort, got {other:?}"),
}
}
#[test]
fn parse_locator_entries_annotates_inner_error_with_index() {
let mut bytes = Vec::new();
bytes.extend(make_locator_bytes("file:///a.bin"));
bytes.extend(make_locator_bytes("abcde")); let mut cursor = ManifestCursor::new(&bytes);
match parse_locator_entries(&mut cursor, 2) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("entry 1"), "got: {reason}");
assert!(reason.contains("separator"));
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
}
#[cfg(test)]
mod local_sidecar_tests {
use super::local_sidecar_name;
#[test]
fn flat_names_pass() {
assert_eq!(local_sidecar_name("file:slab-0.bin").unwrap(), "slab-0.bin");
assert_eq!(
local_sidecar_name("file:metadata.bin").unwrap(),
"metadata.bin"
);
assert_eq!(local_sidecar_name("file:a.bin").unwrap(), "a.bin");
}
#[test]
fn traversal_is_refused() {
for evil in [
"file:../evil.bin",
"file:../../etc/passwd",
"file:/etc/passwd",
"file://etc/passwd",
"file:///var/lib/x",
"file:sub/dir/slab.bin",
"file:.\\..\\evil",
"file:C:\\Windows\\evil",
"file:.",
"file:..",
"file:",
] {
let err = local_sidecar_name(evil)
.err()
.unwrap_or_else(|| panic!("{evil:?} must be refused"));
assert!(
err.to_string().contains("flat file name"),
"{evil:?}: {err}"
);
}
}
#[test]
fn non_file_schemes_are_refused_for_local_access() {
for uri in [
"https://example.com/x",
"s3://bucket/k",
"ipfs:cid",
"plain",
] {
assert!(local_sidecar_name(uri).is_err(), "{uri:?} must be refused");
}
}
}