Skip to main content

typst_pack/opendal/
location.rs

1use std::{collections::BTreeMap, error::Error, fmt, str::FromStr};
2
3/// A caller-defined name for an OpenDAL Operator.
4#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
5pub struct OperatorBinding(String);
6
7impl OperatorBinding {
8    /// Constructs a lowercase RFC scheme-style Operator binding.
9    pub fn new(value: impl AsRef<str>) -> Result<Self, OperatorBindingError> {
10        let value = value.as_ref();
11        if value.is_empty() {
12            return Err(OperatorBindingError::Empty);
13        }
14
15        for (index, character) in value.char_indices() {
16            if character.is_ascii_uppercase() {
17                return Err(OperatorBindingError::NonLowercaseCharacter { index, character });
18            }
19            if index == 0 && !character.is_ascii_lowercase() {
20                return Err(OperatorBindingError::InvalidInitialCharacter { index, character });
21            }
22            if index > 0
23                && !(character.is_ascii_lowercase()
24                    || character.is_ascii_digit()
25                    || matches!(character, '+' | '.' | '-'))
26            {
27                return Err(OperatorBindingError::InvalidCharacter { index, character });
28            }
29        }
30
31        Ok(Self(value.to_owned()))
32    }
33
34    /// Returns this binding's canonical spelling.
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl FromStr for OperatorBinding {
41    type Err = OperatorBindingError;
42
43    fn from_str(value: &str) -> Result<Self, Self::Err> {
44        Self::new(value)
45    }
46}
47
48impl fmt::Display for OperatorBinding {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        formatter.write_str(self.as_str())
51    }
52}
53
54impl fmt::Debug for OperatorBinding {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        fmt::Display::fmt(self, formatter)
57    }
58}
59
60/// A reason an Operator binding is not canonical.
61#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
62#[non_exhaustive]
63pub enum OperatorBindingError {
64    #[error("an Operator binding cannot be empty")]
65    Empty,
66    #[error("invalid initial character {character:?} at byte {index}")]
67    InvalidInitialCharacter { index: usize, character: char },
68    #[error("invalid character {character:?} at byte {index}")]
69    InvalidCharacter { index: usize, character: char },
70    #[error("uppercase character {character:?} at byte {index}")]
71    NonLowercaseCharacter { index: usize, character: char },
72}
73
74/// A canonical location addressed through a caller-supplied Operator binding.
75///
76/// Exact objects are non-root paths without a trailing slash. Prefixes are the
77/// root or non-root paths with a trailing slash.
78///
79/// Import this module instead of the type when [`std::panic::Location`] is also
80/// in scope:
81///
82/// ```
83/// use typst_pack::opendal::location;
84///
85/// let object: location::Location = "archive:/packs/document.typk".parse()?;
86/// # Ok::<(), location::LocationError>(())
87/// ```
88///
89/// ```
90/// use typst_pack::opendal::{Location, OperatorBinding};
91///
92/// let object: Location = "archive:/packs/document.typk".parse()?;
93/// assert_eq!(object.operation_path(), "packs/document.typk");
94/// assert_eq!(object.to_string(), "archive:/packs/document.typk");
95///
96/// let binding = OperatorBinding::new("archive")?;
97/// let prefix = Location::from_operation_path(binding, "packages/café/")?;
98/// assert_eq!(prefix.to_string(), "archive:/packages/caf%C3%A9/");
99/// # Ok::<(), Box<dyn std::error::Error>>(())
100/// ```
101#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct Location {
103    binding: OperatorBinding,
104    operation_path: String,
105}
106
107impl Location {
108    /// Parses a canonical `binding:/path` location.
109    pub fn parse(value: impl AsRef<str>) -> Result<Self, LocationError> {
110        let value = value.as_ref();
111        let Some(binding_end) = value.find(':') else {
112            return Err(LocationError::MissingBindingSeparator);
113        };
114        let binding = OperatorBinding::new(&value[..binding_end])
115            .map_err(|source| LocationError::InvalidBinding { source })?;
116        let suffix_offset = binding_end + 1;
117        let suffix = &value[suffix_offset..];
118
119        if let Some(authority) = suffix.strip_prefix("//") {
120            let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
121            if let Some(index) = authority[..authority_end].find('@') {
122                return Err(LocationError::UserInfoNotAllowed {
123                    index: suffix_offset + 2 + index,
124                });
125            }
126            return Err(LocationError::AuthorityNotAllowed {
127                index: suffix_offset + 1,
128            });
129        }
130
131        let Some(path) = suffix.strip_prefix('/') else {
132            return Err(LocationError::MissingAbsolutePath {
133                index: suffix_offset,
134            });
135        };
136        let operation_path = decode_uri_path(path, suffix_offset + 1)?;
137
138        Ok(Self {
139            binding,
140            operation_path,
141        })
142    }
143
144    /// Constructs a location from a decoded root-relative OpenDAL operation path.
145    pub fn from_operation_path(
146        binding: OperatorBinding,
147        operation_path: impl AsRef<str>,
148    ) -> Result<Self, LocationError> {
149        let operation_path = operation_path.as_ref();
150        if operation_path.is_empty() || operation_path == "/" {
151            return Ok(Self {
152                binding,
153                operation_path: String::new(),
154            });
155        }
156        if operation_path.starts_with('/') {
157            return Err(LocationError::MissingAbsolutePath { index: 0 });
158        }
159        validate_decoded_operation_path(operation_path)?;
160
161        Ok(Self {
162            binding,
163            operation_path: operation_path.to_owned(),
164        })
165    }
166
167    /// Returns the Operator binding used by this location.
168    pub fn binding(&self) -> &OperatorBinding {
169        &self.binding
170    }
171
172    /// Returns the decoded root-relative operation path.
173    pub fn operation_path(&self) -> &str {
174        &self.operation_path
175    }
176
177    /// Reports whether this location names the root.
178    pub fn is_root(&self) -> bool {
179        self.operation_path.is_empty()
180    }
181
182    /// Reports whether this location names a prefix form.
183    pub fn has_trailing_slash(&self) -> bool {
184        self.is_root() || self.operation_path.ends_with('/')
185    }
186
187    pub(crate) fn dispatch_path(&self) -> &str {
188        if self.is_root() {
189            "/"
190        } else {
191            self.operation_path()
192        }
193    }
194
195    pub(crate) fn require_object(&self) -> Result<(), LocationRoleError> {
196        if self.is_root() {
197            Err(LocationRoleError::ObjectAtRoot)
198        } else if self.has_trailing_slash() {
199            Err(LocationRoleError::ObjectHasTrailingSlash)
200        } else {
201            Ok(())
202        }
203    }
204
205    #[allow(dead_code)]
206    pub(crate) fn require_prefix(&self) -> Result<(), LocationRoleError> {
207        if self.has_trailing_slash() {
208            Ok(())
209        } else {
210            Err(LocationRoleError::PrefixMissingTrailingSlash)
211        }
212    }
213
214    #[allow(dead_code)]
215    pub(crate) fn compose(&self, child: &str) -> Result<Self, LocationError> {
216        let mut operation_path = String::with_capacity(self.operation_path.len() + child.len());
217        operation_path.push_str(&self.operation_path);
218        operation_path.push_str(child);
219        Self::from_operation_path(self.binding.clone(), operation_path)
220    }
221
222    #[allow(dead_code)]
223    pub(crate) fn relative_file_path<'a>(
224        &self,
225        candidate: &'a str,
226    ) -> Result<&'a str, PrefixConfinementError> {
227        debug_assert!(self.require_prefix().is_ok());
228
229        if candidate.is_empty() {
230            return Err(PrefixConfinementError::EmptyPath);
231        }
232        if self.is_root() {
233            if candidate == "/" {
234                return Err(PrefixConfinementError::PrefixMarker);
235            }
236            if candidate.starts_with('/') {
237                return Err(PrefixConfinementError::OutsidePrefix);
238            }
239            return Ok(candidate);
240        }
241        if candidate == self.operation_path {
242            return Err(PrefixConfinementError::PrefixMarker);
243        }
244        let Some(relative) = candidate.strip_prefix(&self.operation_path) else {
245            return Err(PrefixConfinementError::OutsidePrefix);
246        };
247        if relative.is_empty() {
248            return Err(PrefixConfinementError::EmptyPath);
249        }
250        Ok(relative)
251    }
252
253    #[cfg(fuzzing)]
254    #[doc(hidden)]
255    pub fn fuzz_role_checks(
256        &self,
257    ) -> (Result<(), LocationRoleError>, Result<(), LocationRoleError>) {
258        (self.require_object(), self.require_prefix())
259    }
260
261    #[cfg(fuzzing)]
262    #[doc(hidden)]
263    pub fn fuzz_compose(&self, child: &str) -> Result<Self, LocationError> {
264        self.compose(child)
265    }
266}
267
268impl FromStr for Location {
269    type Err = LocationError;
270
271    fn from_str(value: &str) -> Result<Self, Self::Err> {
272        Self::parse(value)
273    }
274}
275
276impl fmt::Display for Location {
277    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278        write!(formatter, "{}:/", self.binding)?;
279        for byte in self.operation_path.bytes() {
280            if byte == b'/' || is_pchar(byte) {
281                formatter.write_str(char::from(byte).encode_utf8(&mut [0; 4]))?;
282            } else {
283                write!(formatter, "%{byte:02X}")?;
284            }
285        }
286        Ok(())
287    }
288}
289
290impl fmt::Debug for Location {
291    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
292        fmt::Display::fmt(self, formatter)
293    }
294}
295
296/// A reason a location is unsafe or not canonically spelled.
297#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
298#[non_exhaustive]
299pub enum LocationError {
300    #[error("the location is missing its Operator binding separator")]
301    MissingBindingSeparator,
302    #[error("the location has an invalid Operator binding: {source}")]
303    InvalidBinding { source: OperatorBindingError },
304    #[error("the location is missing its absolute path slash at byte {index}")]
305    MissingAbsolutePath { index: usize },
306    #[error("an authority is not allowed at byte {index}")]
307    AuthorityNotAllowed { index: usize },
308    #[error("userinfo is not allowed at byte {index}")]
309    UserInfoNotAllowed { index: usize },
310    #[error("a query is not allowed at byte {index}")]
311    QueryNotAllowed { index: usize },
312    #[error("a fragment is not allowed at byte {index}")]
313    FragmentNotAllowed { index: usize },
314    #[error("raw non-ASCII input is not allowed at byte {index}")]
315    RawNonAscii { index: usize },
316    #[error("a control character is not allowed at byte {index}")]
317    ControlCharacter { index: usize },
318    #[error("a backslash is not allowed at byte {index}")]
319    Backslash { index: usize },
320    #[error("a malformed percent escape starts at byte {index}")]
321    MalformedPercentEscape { index: usize },
322    #[error("a noncanonical percent escape starts at byte {index}")]
323    NoncanonicalPercentEscape { index: usize },
324    #[error("an encoded path character starts at byte {index}")]
325    EncodedPchar { index: usize },
326    #[error("an encoded path separator starts at byte {index}")]
327    EncodedSeparator { index: usize },
328    #[error("an encoded backslash starts at byte {index}")]
329    EncodedBackslash { index: usize },
330    #[error("percent escapes produce invalid UTF-8 at byte {index}")]
331    InvalidUtf8 { index: usize },
332    #[error("a repeated path separator occurs at byte {index}")]
333    RepeatedSeparator { index: usize },
334    #[error("a dot segment starts at byte {index}")]
335    DotSegment { index: usize },
336    #[error("the path aliases another operation path at byte {index}")]
337    NormalizationAlias { index: usize },
338    #[error("path character {character:?} must be percent-encoded at byte {index}")]
339    NoncanonicalPathCharacter { index: usize, character: char },
340}
341
342/// A reason a location cannot serve an exact-object or prefix role.
343#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
344#[non_exhaustive]
345pub enum LocationRoleError {
346    #[error("an exact object cannot be located at root")]
347    ObjectAtRoot,
348    #[error("an exact object location cannot have a trailing slash")]
349    ObjectHasTrailingSlash,
350    #[error("a non-root prefix location must have a trailing slash")]
351    PrefixMissingTrailingSlash,
352}
353
354/// Resolves an Operator binding without rewriting operation paths.
355pub trait OperatorResolver {
356    type Error: Error + Send + Sync + 'static;
357
358    fn resolve(&self, binding: &OperatorBinding) -> Result<::opendal::Operator, Self::Error>;
359}
360
361/// An immutable lexical map of caller-supplied Operators.
362///
363/// The caller constructs each Operator and may bind clones of one Operator to
364/// distinct names. Consumers can accept the map through [`OperatorResolver`]
365/// without knowing how any backend was configured.
366///
367/// ```
368/// use typst_pack::opendal::{
369///     OperatorBinding, OperatorBindings, OperatorResolver,
370/// };
371///
372/// fn resolve_for_consumer<R: OperatorResolver>(
373///     resolver: &R,
374///     binding: &OperatorBinding,
375/// ) -> Result<opendal::Operator, R::Error> {
376///     resolver.resolve(binding)
377/// }
378///
379/// let operator = opendal::Operator::new(opendal::services::Memory::default())?;
380/// let archive = OperatorBinding::new("archive")?;
381/// let project = OperatorBinding::new("project")?;
382/// let bindings = OperatorBindings::new([
383///     (project, operator.clone()),
384///     (archive.clone(), operator),
385/// ])?;
386///
387/// assert_eq!(
388///     bindings
389///         .bindings()
390///         .map(OperatorBinding::as_str)
391///         .collect::<Vec<_>>(),
392///     ["archive", "project"]
393/// );
394/// let _direct_operator = bindings.operator(&archive).expect("archive is configured");
395/// let _resolved_operator = resolve_for_consumer(&bindings, &archive)?;
396/// # Ok::<(), Box<dyn std::error::Error>>(())
397/// ```
398#[derive(Clone)]
399pub struct OperatorBindings {
400    operators: BTreeMap<OperatorBinding, ::opendal::Operator>,
401}
402
403impl OperatorBindings {
404    /// Builds bindings and rejects duplicate names.
405    pub fn new(
406        entries: impl IntoIterator<Item = (OperatorBinding, ::opendal::Operator)>,
407    ) -> Result<Self, OperatorBindingsError> {
408        let mut operators = BTreeMap::new();
409        for (binding, operator) in entries {
410            if operators.insert(binding.clone(), operator).is_some() {
411                return Err(OperatorBindingsError::DuplicateBinding { binding });
412            }
413        }
414        Ok(Self { operators })
415    }
416
417    /// Lists binding names in lexical order.
418    pub fn bindings(&self) -> impl ExactSizeIterator<Item = &OperatorBinding> {
419        self.operators.keys()
420    }
421
422    /// Returns a cheap clone of the Operator for `binding`.
423    pub fn operator(&self, binding: &OperatorBinding) -> Option<::opendal::Operator> {
424        self.operators.get(binding).cloned()
425    }
426}
427
428impl fmt::Debug for OperatorBindings {
429    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
430        formatter
431            .debug_struct("OperatorBindings")
432            .field("bindings", &DisplayBindings(self.operators.keys()))
433            .finish()
434    }
435}
436
437impl OperatorResolver for OperatorBindings {
438    type Error = OperatorBindingsResolveError;
439
440    fn resolve(&self, binding: &OperatorBinding) -> Result<::opendal::Operator, Self::Error> {
441        self.operator(binding)
442            .ok_or_else(|| OperatorBindingsResolveError::UnknownBinding {
443                binding: binding.clone(),
444            })
445    }
446}
447
448/// A reason an immutable Operator binding map cannot be built.
449#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
450#[non_exhaustive]
451pub enum OperatorBindingsError {
452    #[error("duplicate Operator binding {binding}")]
453    DuplicateBinding { binding: OperatorBinding },
454}
455
456/// A reason an Operator binding cannot be resolved.
457#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
458#[non_exhaustive]
459pub enum OperatorBindingsResolveError {
460    #[error("unknown Operator binding {binding}")]
461    UnknownBinding { binding: OperatorBinding },
462}
463
464struct DisplayBindings<'a>(
465    std::collections::btree_map::Keys<'a, OperatorBinding, ::opendal::Operator>,
466);
467
468impl fmt::Debug for DisplayBindings<'_> {
469    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
470        formatter.debug_list().entries(self.0.clone()).finish()
471    }
472}
473
474#[derive(Clone, Copy, Debug, Eq, PartialEq)]
475#[allow(dead_code)]
476pub(crate) enum PrefixConfinementError {
477    OutsidePrefix,
478    PrefixMarker,
479    EmptyPath,
480}
481
482struct DecodedPath {
483    value: String,
484    source_offsets: Vec<usize>,
485    encoded_bytes: Vec<bool>,
486}
487
488fn decode_uri_path(path: &str, path_offset: usize) -> Result<String, LocationError> {
489    if let Some(index) = path.find('?') {
490        return Err(LocationError::QueryNotAllowed {
491            index: path_offset + index,
492        });
493    }
494    if let Some(index) = path.find('#') {
495        return Err(LocationError::FragmentNotAllowed {
496            index: path_offset + index,
497        });
498    }
499    if let Some((index, _)) = path
500        .char_indices()
501        .find(|(_, character)| !character.is_ascii())
502    {
503        return Err(LocationError::RawNonAscii {
504            index: path_offset + index,
505        });
506    }
507    if let Some((index, _)) = path
508        .char_indices()
509        .find(|(_, character)| character.is_control())
510    {
511        return Err(LocationError::ControlCharacter {
512            index: path_offset + index,
513        });
514    }
515    if let Some(index) = path.find('\\') {
516        return Err(LocationError::Backslash {
517            index: path_offset + index,
518        });
519    }
520
521    for (index, escape) in percent_escapes(path) {
522        let Some([high, low]) = escape else {
523            return Err(LocationError::MalformedPercentEscape {
524                index: path_offset + index,
525            });
526        };
527        if !high.is_ascii_hexdigit() || !low.is_ascii_hexdigit() {
528            return Err(LocationError::MalformedPercentEscape {
529                index: path_offset + index,
530            });
531        }
532    }
533    for (index, escape) in percent_escapes(path) {
534        let [high, low] = escape.expect("escapes were validated above");
535        if high.is_ascii_lowercase() || low.is_ascii_lowercase() {
536            return Err(LocationError::NoncanonicalPercentEscape {
537                index: path_offset + index,
538            });
539        }
540    }
541    for (index, escape) in percent_escapes(path) {
542        let [high, low] = escape.expect("escapes were validated above");
543        let byte = decode_hex(high, low);
544        if byte == b'/' {
545            return Err(LocationError::EncodedSeparator {
546                index: path_offset + index,
547            });
548        }
549    }
550    for (index, escape) in percent_escapes(path) {
551        let [high, low] = escape.expect("escapes were validated above");
552        let byte = decode_hex(high, low);
553        if byte == b'\\' {
554            return Err(LocationError::EncodedBackslash {
555                index: path_offset + index,
556            });
557        }
558    }
559    for (index, escape) in percent_escapes(path) {
560        let [high, low] = escape.expect("escapes were validated above");
561        if is_pchar(decode_hex(high, low)) {
562            return Err(LocationError::EncodedPchar {
563                index: path_offset + index,
564            });
565        }
566    }
567    if let Some(index) = path.as_bytes().windows(2).position(|pair| pair == b"//") {
568        return Err(LocationError::RepeatedSeparator {
569            index: path_offset + index + 1,
570        });
571    }
572
573    let decoded = decode_percent_bytes(path, path_offset)?;
574    validate_decoded_controls(&decoded)?;
575    validate_dot_segments(&decoded.value, &decoded.source_offsets)?;
576    validate_normalization(&decoded.value, &decoded.source_offsets)?;
577    validate_raw_path_characters(&decoded)?;
578    Ok(decoded.value)
579}
580
581fn validate_decoded_operation_path(path: &str) -> Result<(), LocationError> {
582    validate_path_controls(path)?;
583    validate_path_backslash(path)?;
584    validate_repeated_separator(path)?;
585
586    let source_offsets = path
587        .char_indices()
588        .flat_map(|(index, character)| std::iter::repeat_n(index, character.len_utf8()))
589        .collect::<Vec<_>>();
590    validate_dot_segments(path, &source_offsets)?;
591    validate_normalization(path, &source_offsets)
592}
593
594pub(crate) fn validate_decoded_artifact_key_path(path: &str) -> Result<(), LocationError> {
595    validate_repeated_separator(path)?;
596    let source_offsets = path
597        .char_indices()
598        .flat_map(|(index, character)| std::iter::repeat_n(index, character.len_utf8()))
599        .collect::<Vec<_>>();
600    validate_dot_segments(path, &source_offsets)?;
601    validate_path_backslash(path)?;
602    validate_path_controls(path)?;
603    validate_normalization(path, &source_offsets)
604}
605
606fn validate_path_controls(path: &str) -> Result<(), LocationError> {
607    if let Some((index, _)) = path
608        .char_indices()
609        .find(|(_, character)| character.is_control())
610    {
611        return Err(LocationError::ControlCharacter { index });
612    }
613    Ok(())
614}
615
616fn validate_path_backslash(path: &str) -> Result<(), LocationError> {
617    if let Some(index) = path.find('\\') {
618        return Err(LocationError::Backslash { index });
619    }
620    Ok(())
621}
622
623fn validate_repeated_separator(path: &str) -> Result<(), LocationError> {
624    if let Some(index) = path.as_bytes().windows(2).position(|pair| pair == b"//") {
625        return Err(LocationError::RepeatedSeparator { index: index + 1 });
626    }
627    Ok(())
628}
629
630fn decode_percent_bytes(path: &str, path_offset: usize) -> Result<DecodedPath, LocationError> {
631    let bytes = path.as_bytes();
632    let mut decoded = Vec::with_capacity(bytes.len());
633    let mut source_offsets = Vec::with_capacity(bytes.len());
634    let mut encoded_bytes = Vec::with_capacity(bytes.len());
635    let mut index = 0;
636    while index < bytes.len() {
637        if bytes[index] == b'%' {
638            decoded.push(decode_hex(bytes[index + 1], bytes[index + 2]));
639            source_offsets.push(path_offset + index);
640            encoded_bytes.push(true);
641            index += 3;
642        } else {
643            decoded.push(bytes[index]);
644            source_offsets.push(path_offset + index);
645            encoded_bytes.push(false);
646            index += 1;
647        }
648    }
649    let value = String::from_utf8(decoded).map_err(|error| LocationError::InvalidUtf8 {
650        index: source_offsets[error.utf8_error().valid_up_to()],
651    })?;
652    Ok(DecodedPath {
653        value,
654        source_offsets,
655        encoded_bytes,
656    })
657}
658
659fn validate_decoded_controls(decoded: &DecodedPath) -> Result<(), LocationError> {
660    if let Some((index, _)) = decoded
661        .value
662        .char_indices()
663        .find(|(_, character)| character.is_control())
664    {
665        return Err(LocationError::ControlCharacter {
666            index: decoded.source_offsets[index],
667        });
668    }
669    Ok(())
670}
671
672fn validate_dot_segments(path: &str, source_offsets: &[usize]) -> Result<(), LocationError> {
673    let mut start = 0;
674    for segment in path.split('/') {
675        if segment == "." || segment == ".." {
676            return Err(LocationError::DotSegment {
677                index: source_offsets[start],
678            });
679        }
680        start += segment.len() + 1;
681    }
682    Ok(())
683}
684
685fn validate_normalization(path: &str, source_offsets: &[usize]) -> Result<(), LocationError> {
686    if path.is_empty() || vendored_normalize_path(path) == path {
687        return Ok(());
688    }
689
690    let trimmed_start = path.len() - path.trim_start().len();
691    let trimmed_end = path.trim_end().len();
692    let decoded_index = if trimmed_start > 0 {
693        0
694    } else if trimmed_end < path.len() {
695        trimmed_end
696    } else {
697        0
698    };
699    Err(LocationError::NormalizationAlias {
700        index: source_offsets[decoded_index],
701    })
702}
703
704fn validate_raw_path_characters(decoded: &DecodedPath) -> Result<(), LocationError> {
705    for (index, character) in decoded.value.char_indices() {
706        if !decoded.encoded_bytes[index]
707            && character != '/'
708            && !(character.is_ascii() && is_pchar(character as u8))
709        {
710            return Err(LocationError::NoncanonicalPathCharacter {
711                index: decoded.source_offsets[index],
712                character,
713            });
714        }
715    }
716    Ok(())
717}
718
719fn percent_escapes(path: &str) -> impl Iterator<Item = (usize, Option<[u8; 2]>)> + '_ {
720    let bytes = path.as_bytes();
721    bytes
722        .iter()
723        .enumerate()
724        .filter(|(_, byte)| **byte == b'%')
725        .map(move |(index, _)| {
726            (
727                index,
728                (index + 2 < bytes.len()).then(|| [bytes[index + 1], bytes[index + 2]]),
729            )
730        })
731}
732
733fn decode_hex(high: u8, low: u8) -> u8 {
734    (hex_value(high) << 4) | hex_value(low)
735}
736
737fn hex_value(byte: u8) -> u8 {
738    match byte {
739        b'0'..=b'9' => byte - b'0',
740        b'A'..=b'F' => byte - b'A' + 10,
741        b'a'..=b'f' => byte - b'a' + 10,
742        _ => unreachable!("hex input was validated"),
743    }
744}
745
746fn is_pchar(byte: u8) -> bool {
747    byte.is_ascii_alphanumeric()
748        || matches!(
749            byte,
750            b'-' | b'.'
751                | b'_'
752                | b'~'
753                | b'!'
754                | b'$'
755                | b'&'
756                | b'\''
757                | b'('
758                | b')'
759                | b'*'
760                | b'+'
761                | b','
762                | b';'
763                | b'='
764                | b':'
765                | b'@'
766        )
767}
768
769pub(crate) fn vendored_normalize_path(path: &str) -> String {
770    let path = path.trim().trim_start_matches('/');
771    if path.is_empty() {
772        return "/".to_owned();
773    }
774    let has_trailing_slash = path.ends_with('/');
775    let mut normalized = path
776        .split('/')
777        .filter(|segment| !segment.is_empty())
778        .collect::<Vec<_>>()
779        .join("/");
780    if has_trailing_slash {
781        normalized.push('/');
782    }
783    normalized
784}
785
786#[cfg(test)]
787mod tests {
788    use proptest::prelude::*;
789
790    use super::*;
791
792    #[test]
793    fn location_roles_enforce_exact_object_and_prefix_shapes() {
794        let root = Location::parse("store:/").unwrap();
795        let object = Location::parse("store:/archive.typk").unwrap();
796        let prefix = Location::parse("store:/packages/").unwrap();
797
798        assert_eq!(root.require_object(), Err(LocationRoleError::ObjectAtRoot));
799        assert_eq!(root.require_prefix(), Ok(()));
800        assert_eq!(object.require_object(), Ok(()));
801        assert_eq!(
802            object.require_prefix(),
803            Err(LocationRoleError::PrefixMissingTrailingSlash)
804        );
805        assert_eq!(
806            prefix.require_object(),
807            Err(LocationRoleError::ObjectHasTrailingSlash)
808        );
809        assert_eq!(prefix.require_prefix(), Ok(()));
810    }
811
812    #[test]
813    fn root_projection_composition_and_prefix_confinement_are_byte_exact() {
814        let root = Location::parse("store:/").unwrap();
815        let prefix = Location::parse("store:/base/").unwrap();
816
817        assert_eq!(root.operation_path(), "");
818        assert_eq!(root.dispatch_path(), "/");
819        assert_eq!(root.compose("child").unwrap().operation_path(), "child");
820        assert_eq!(
821            prefix.compose("nested/child").unwrap().operation_path(),
822            "base/nested/child"
823        );
824        assert_eq!(
825            prefix.compose("/child"),
826            Err(LocationError::RepeatedSeparator { index: 5 })
827        );
828
829        assert_eq!(root.relative_file_path("child"), Ok("child"));
830        assert_eq!(
831            root.relative_file_path("/"),
832            Err(PrefixConfinementError::PrefixMarker)
833        );
834        assert_eq!(prefix.relative_file_path("base/child"), Ok("child"));
835        assert_eq!(
836            prefix.relative_file_path("base/nested/child"),
837            Ok("nested/child")
838        );
839        assert_eq!(
840            prefix.relative_file_path("base/"),
841            Err(PrefixConfinementError::PrefixMarker)
842        );
843        assert_eq!(
844            prefix.relative_file_path("base"),
845            Err(PrefixConfinementError::OutsidePrefix)
846        );
847        assert_eq!(
848            prefix.relative_file_path("base-sibling/child"),
849            Err(PrefixConfinementError::OutsidePrefix)
850        );
851        assert_eq!(
852            prefix.relative_file_path("base2/child"),
853            Err(PrefixConfinementError::OutsidePrefix)
854        );
855        assert_eq!(
856            prefix.relative_file_path(""),
857            Err(PrefixConfinementError::EmptyPath)
858        );
859    }
860
861    #[test]
862    fn vendored_normalization_preserves_dot_segments_and_non_whitespace_format_chars() {
863        for path in [
864            "",
865            "/",
866            "///",
867            "abc",
868            "abc/",
869            "/abc/def",
870            "abc///def///",
871            " abc/def ",
872            "a/./b",
873            "a/../b",
874            "a\u{feff}",
875            "a\u{200b}",
876        ] {
877            assert_eq!(
878                vendored_normalize_path(path),
879                ::opendal::raw::normalize_path(path),
880                "normalization drift for {path:?}"
881            );
882        }
883        assert_eq!(vendored_normalize_path("a/./b"), "a/./b");
884        assert_eq!(vendored_normalize_path("a\u{feff}"), "a\u{feff}");
885    }
886
887    proptest! {
888        #[test]
889        fn vendored_normalization_agrees_with_opendal_for_arbitrary_utf8(path in any::<String>()) {
890            prop_assert_eq!(
891                vendored_normalize_path(&path),
892                ::opendal::raw::normalize_path(&path),
893                "OpenDAL normalization drift for {:?}",
894                path,
895            );
896        }
897    }
898}