1use std::{collections::BTreeMap, error::Error, fmt, str::FromStr};
2
3#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
5pub struct OperatorBinding(String);
6
7impl OperatorBinding {
8 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 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#[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#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct Location {
103 binding: OperatorBinding,
104 operation_path: String,
105}
106
107impl Location {
108 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 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 pub fn binding(&self) -> &OperatorBinding {
169 &self.binding
170 }
171
172 pub fn operation_path(&self) -> &str {
174 &self.operation_path
175 }
176
177 pub fn is_root(&self) -> bool {
179 self.operation_path.is_empty()
180 }
181
182 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#[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#[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
354pub trait OperatorResolver {
356 type Error: Error + Send + Sync + 'static;
357
358 fn resolve(&self, binding: &OperatorBinding) -> Result<::opendal::Operator, Self::Error>;
359}
360
361#[derive(Clone)]
399pub struct OperatorBindings {
400 operators: BTreeMap<OperatorBinding, ::opendal::Operator>,
401}
402
403impl OperatorBindings {
404 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 pub fn bindings(&self) -> impl ExactSizeIterator<Item = &OperatorBinding> {
419 self.operators.keys()
420 }
421
422 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#[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#[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}