s3_path/
lib.rs

1pub mod error;
2mod validation;
3
4use crate::error::InvalidS3PathComponent;
5use std::borrow::Cow;
6use std::fmt::Formatter;
7use std::ops::Deref;
8use std::path::PathBuf;
9
10/// Var-arg macro to create an S3Path, borrowing from the given string literals.
11///
12/// ```
13/// use s3_path::s3_path;
14///
15/// let path = s3_path!("foo", "bar").unwrap();
16/// ```
17#[macro_export]
18macro_rules! s3_path {
19    ($($component:expr),* $(,)?) => {{
20        let components = &[$(::std::borrow::Cow::Borrowed($component)),*];
21        $crate::S3Path::new(components)
22    }}
23}
24
25/// Var-arg macro to create an S3Path from individual components.
26///
27/// ```
28/// use std::borrow::Cow;
29/// use s3_path::s3_path_buf;
30///
31/// let path = s3_path_buf!("foo", String::from("bar"), Cow::Borrowed("baz")).unwrap();
32/// ```
33///
34/// Every component passed into this macro must either
35/// - be a `&'static str`
36/// - an owned `String`
37/// - or a `Cow<'static, str>`
38///
39/// str-slices of arbitrary lifetime cannot be used, as an S3PathBuf requires ownership,
40/// and this API enforces that any allocation, if needed, is performed at call-site.
41#[macro_export]
42macro_rules! s3_path_buf {
43    ($($component:expr),* $(,)?) => {{
44        #[allow(unused_mut)] // In case zero components are passed in.
45        let mut path = $crate::S3PathBuf::new();
46        #[allow(unused_mut)] // In case zero components are passed in.
47        let mut error = None;
48        $(
49            if error.is_none() {
50                match path.join($component) {
51                    Ok(_) => {},
52                    Err(e) => error = Some(e),
53                }
54            }
55        )*
56        match error {
57            Some(err) => Result::<$crate::S3PathBuf, $crate::error::InvalidS3PathComponent>::Err(err),
58            None => Result::<$crate::S3PathBuf, $crate::error::InvalidS3PathComponent>::Ok(path),
59        }
60    }}
61}
62
63/// A borrowed, unsized S3 storage path.
64///
65// Must be repr(transparent) to safely convert from the slice.
66#[repr(transparent)]
67#[derive(PartialEq, Eq)]
68pub struct S3Path<'i>([Cow<'i, str>]);
69
70/// An owned S3 storage path.
71#[derive(Clone, PartialEq, Eq, Default)]
72pub struct S3PathBuf {
73    components: Vec<Cow<'static, str>>,
74}
75
76/// Allow comparisons between S3Path and S3PathBuf.
77impl PartialEq<S3Path<'_>> for S3PathBuf {
78    fn eq(&self, other: &S3Path<'_>) -> bool {
79        self.as_path() == other
80    }
81}
82
83/// Allow comparisons between S3Path and S3PathBuf.
84impl<'i> PartialEq<&S3Path<'i>> for S3PathBuf {
85    fn eq(&self, other: &&S3Path<'i>) -> bool {
86        self.as_path() == *other
87    }
88}
89
90/// Allow comparisons between S3Path and S3PathBuf.
91impl<'i> PartialEq<&&S3Path<'i>> for S3PathBuf {
92    fn eq(&self, other: &&&S3Path<'i>) -> bool {
93        self.as_path() == **other
94    }
95}
96
97/// Allow comparisons between S3Path and S3PathBuf.
98impl<'i> PartialEq<S3PathBuf> for S3Path<'i> {
99    fn eq(&self, other: &S3PathBuf) -> bool {
100        self == other.as_path()
101    }
102}
103
104/// Allow comparisons between S3Path and S3PathBuf.
105impl<'i> PartialEq<S3PathBuf> for &S3Path<'i> {
106    fn eq(&self, other: &S3PathBuf) -> bool {
107        *self == other.as_path()
108    }
109}
110
111/// Allow comparisons between S3Path and S3PathBuf.
112impl<'i> PartialEq<S3PathBuf> for &&S3Path<'i> {
113    fn eq(&self, other: &S3PathBuf) -> bool {
114        **self == other.as_path()
115    }
116}
117
118impl<'i> AsRef<S3Path<'i>> for S3Path<'i> {
119    fn as_ref(&self) -> &S3Path<'i> {
120        self
121    }
122}
123
124impl<'i> AsRef<S3Path<'i>> for S3PathBuf {
125    fn as_ref(&self) -> &S3Path<'i> {
126        self.deref()
127    }
128}
129
130impl AsRef<S3PathBuf> for S3PathBuf {
131    fn as_ref(&self) -> &S3PathBuf {
132        self
133    }
134}
135
136fn write_components<C: AsRef<str>>(
137    components: impl Iterator<Item = C>,
138    f: &mut Formatter,
139) -> Result<(), std::fmt::Error> {
140    for (i, c) in components.enumerate() {
141        if i > 0 {
142            f.write_str("/")?;
143        }
144        f.write_str(c.as_ref())?;
145    }
146    Ok(())
147}
148
149impl<'i> std::fmt::Display for S3Path<'i> {
150    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
151        write_components(self.0.iter(), f)?;
152        Ok(())
153    }
154}
155
156impl<'i> std::fmt::Debug for S3Path<'i> {
157    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
158        write_components(self.0.iter(), f)?;
159        Ok(())
160    }
161}
162
163impl std::fmt::Display for S3PathBuf {
164    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
165        write_components(self.components.iter(), f)?;
166        Ok(())
167    }
168}
169
170impl std::fmt::Debug for S3PathBuf {
171    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
172        write_components(self.components.iter(), f)?;
173        Ok(())
174    }
175}
176
177impl<'i> S3Path<'i> {
178    /// Create a new S3Path from a slice.
179    pub fn new(components: &'i [Cow<'i, str>]) -> Result<&'i S3Path<'i>, InvalidS3PathComponent> {
180        for component in components {
181            validation::validate_component(component)?;
182        }
183        // Safety: S3Path is repr(transparent) over [Cow<'i, str>].
184        Ok(unsafe { &*(components as *const [Cow<'i, str>] as *const S3Path<'i>) })
185    }
186
187    /// Converts to an owned S3PathBuf.
188    pub fn to_owned(&'i self) -> S3PathBuf {
189        S3PathBuf {
190            components: self.0.iter().map(|it| Cow::Owned(it.to_string())).collect(),
191        }
192    }
193
194    /// Converts to an owned S3PathBuf and tries to append `component`.
195    pub fn join<C: Into<Cow<'static, str>>>(
196        &self,
197        component: C,
198    ) -> Result<S3PathBuf, InvalidS3PathComponent> {
199        let mut path = self.to_owned();
200        path.join(component)?;
201        Ok(path)
202    }
203
204    /// Returns true if this path has no components.
205    pub fn is_empty(&'i self) -> bool {
206        self.0.is_empty()
207    }
208
209    /// Returns the number of components in this path.
210    pub fn len(&'i self) -> usize {
211        self.0.len()
212    }
213
214    /// Returns an iterator over the components of this path.
215    pub fn components(&'i self) -> impl Iterator<Item = &'i str> {
216        self.0.iter().map(move |it| it.as_ref())
217    }
218
219    /// Returns the component at the given index, or None if the index is out of bounds.
220    pub fn get(&'i self, index: usize) -> Option<&'i str> {
221        self.0.get(index).map(|it| it.as_ref())
222    }
223
224    /// Returns the last component of this path, or None if the path is empty.
225    pub fn last(&'i self) -> Option<&'i str> {
226        self.0.last().map(|it| it.as_ref())
227    }
228
229    /// Returns all but the last component of this path, or None if the path is empty.
230    pub fn parent(&'i self) -> Option<&'i S3Path<'i>> {
231        if self.0.is_empty() {
232            None
233        } else {
234            let parent_slice = &self.0[..self.0.len() - 1];
235            Some(
236                // Safety: S3Path is repr(transparent) over [Cow<'i, str>]
237                unsafe { &*(parent_slice as *const [Cow<'i, str>] as *const S3Path<'i>) },
238            )
239        }
240    }
241
242    /// Convert this S3 path to a `std::path::PathBuf`, allowing you to use this S3 path as a
243    /// system file path.
244    ///
245    /// Our strong guarantee that path components only consist of ascii alphanumeric characters,
246    /// '-', '_' and '.' and that no path traversal components ('.' and '..') are allowed, makes
247    /// this a safe operation.
248    pub fn to_std_path_buf(&self) -> PathBuf {
249        let mut path = PathBuf::new();
250        for c in &self.0 {
251            path = path.join(c.as_ref());
252        }
253        path
254    }
255}
256
257// Deref - NameBuf can be automatically converted to &Name<'static>
258impl Deref for S3PathBuf {
259    type Target = S3Path<'static>;
260
261    fn deref(&self) -> &Self::Target {
262        // Safety: This is safe because Name is repr(transparent) over [Cow<'i, str>] and we
263        // store [Cow<'static>, str>], with 'static outliving any lifetime 'i.
264        unsafe {
265            &*(self.components.as_slice() as *const [Cow<'static, str>] as *const S3Path<'static>)
266        }
267    }
268}
269
270impl S3PathBuf {
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    pub fn try_from<C: Into<Cow<'static, str>>, I: IntoIterator<Item = C>>(
276        components: I,
277    ) -> Result<Self, InvalidS3PathComponent> {
278        let mut path = S3PathBuf::new();
279        for component in components {
280            path.join(component)?;
281        }
282        Ok(path)
283    }
284
285    pub fn try_from_str(string: impl AsRef<str>) -> Result<Self, InvalidS3PathComponent> {
286        let mut path = S3PathBuf::new();
287        for c in string.as_ref().split('/') {
288            // Skip empty components from consecutive slashes
289            if !c.is_empty() {
290                path.join(Cow::Owned(c.to_string()))?;
291            }
292        }
293        Ok(path)
294    }
295
296    pub fn join(
297        &mut self,
298        component: impl Into<Cow<'static, str>>,
299    ) -> Result<&mut Self, InvalidS3PathComponent> {
300        let comp = component.into();
301        validation::validate_component(&comp)?;
302        self.components.push(comp);
303        Ok(self)
304    }
305
306    #[must_use]
307    #[inline]
308    pub fn as_path(&self) -> &S3Path<'_> {
309        self.deref()
310    }
311
312    /// Pop the last component from this path, returning true if a component was removed
313    pub fn pop(&mut self) -> Option<Cow<'static, str>> {
314        self.components.pop()
315    }
316}
317
318impl TryFrom<&str> for S3PathBuf {
319    type Error = InvalidS3PathComponent;
320
321    fn try_from(value: &str) -> Result<Self, Self::Error> {
322        let mut path = S3PathBuf::new();
323        for c in value.split('/') {
324            // Skip empty components from consecutive slashes
325            if !c.is_empty() {
326                path.join(Cow::Owned(c.to_string()))?;
327            }
328        }
329        Ok(path)
330    }
331}
332
333impl TryFrom<String> for S3PathBuf {
334    type Error = InvalidS3PathComponent;
335
336    fn try_from(value: String) -> Result<Self, Self::Error> {
337        TryFrom::try_from(value.as_str())
338    }
339}
340
341#[cfg(test)]
342impl assertr::assertions::HasLength for S3PathBuf {
343    fn length(&self) -> usize {
344        self.len()
345    }
346}
347
348#[cfg(test)]
349mod test {
350    use crate::S3PathBuf;
351    use assertr::prelude::*;
352
353    mod s3_path_buf {
354        use crate::S3PathBuf;
355        use assertr::prelude::*;
356
357        #[test]
358        fn new_is_initially_empty() {
359            let path = S3PathBuf::new();
360            assert_that(path).has_display_value("");
361        }
362
363        #[test]
364        fn construct_using_new_and_join_components() {
365            let mut path = S3PathBuf::new();
366            path.join("foo").unwrap();
367            path.join("bar").unwrap();
368            assert_that(path).has_display_value("foo/bar");
369        }
370
371        #[test]
372        fn try_from_str_parses_empty_string_as_empty_path() {
373            let path = S3PathBuf::try_from_str("").unwrap();
374            assert_that(path).has_display_value("");
375        }
376
377        #[test]
378        fn try_from_str_parses_single_slash_as_empty_path() {
379            let path = S3PathBuf::try_from_str("/").unwrap();
380            assert_that(path).has_display_value("");
381        }
382
383        #[test]
384        fn try_from_str_removes_leading_and_trailing_slashes() {
385            let path = S3PathBuf::try_from_str("/foo/bar/").unwrap();
386            assert_that(path).has_display_value("foo/bar");
387        }
388
389        #[test]
390        fn try_from_str_ignores_repeated_slashes() {
391            let path = S3PathBuf::try_from_str("foo/////bar").unwrap();
392            assert_that(path).has_display_value("foo/bar");
393        }
394
395        #[test]
396        fn construct_using_try_from_given_str() {
397            let path = S3PathBuf::try_from_str("foo/bar").unwrap();
398            assert_that(path).has_display_value("foo/bar");
399        }
400
401        #[test]
402        fn construct_using_try_from_given_string() {
403            let path = S3PathBuf::try_from_str("foo/bar".to_string()).unwrap();
404            assert_that(path).has_display_value("foo/bar");
405        }
406
407        #[test]
408        fn try_from_static_str_array() {
409            let path = S3PathBuf::try_from(["foo", "bar"]).unwrap();
410            assert_that(path).has_display_value("foo/bar");
411        }
412
413        #[test]
414        fn try_from_string_array() {
415            let path = S3PathBuf::try_from(["foo".to_string(), "bar".to_string()]).unwrap();
416            assert_that(path).has_display_value("foo/bar");
417        }
418
419        #[test]
420        fn reject_invalid_characters() {
421            let mut path = S3PathBuf::new();
422            let result = path.join("invalid/path");
423            assert_that(result.is_err()).is_true();
424
425            let result = S3PathBuf::try_from_str("foo/bar$baz");
426            assert_that(result.is_err()).is_true();
427        }
428
429        #[test]
430        fn as_path() {
431            let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
432            let path = path_buf.as_path();
433            assert_that(path).has_display_value("foo/bar");
434            let cloned = path.to_owned();
435            drop(path_buf);
436            assert_that(cloned).has_display_value("foo/bar");
437        }
438
439        #[test] // Function `is_empty` inherited through deref to S3Path!
440        fn is_empty_returns_true_when_path_has_no_components() {
441            let path_buf = S3PathBuf::new();
442            assert_that(path_buf.is_empty()).is_true();
443        }
444
445        #[test] // Function `is_empty` inherited through deref to S3Path!
446        fn is_empty_returns_false_when_path_has_components() {
447            let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
448            assert_that(path_buf.is_empty()).is_false();
449        }
450
451        #[test] // Function `len` inherited through deref to S3Path!
452        fn len_returns_number_of_components() {
453            let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
454            assert_that(path_buf.len()).is_equal_to(2);
455        }
456
457        #[test] // Function `components` inherited through deref to S3Path!
458        fn components_iterates_over_all_components() {
459            let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
460            assert_that(path_buf.components()).contains_exactly(&["foo", "bar"]);
461        }
462
463        #[test] // Function `get` inherited through deref to S3Path!
464        fn get_returns_component_at_index() {
465            let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
466            assert_that(path_buf.get(1)).is_some().is_equal_to("bar");
467        }
468
469        #[test] // Function `last` inherited through deref to S3Path!
470        fn last_returns_last_component() {
471            let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
472            assert_that(path_buf.last()).is_some().is_equal_to("bar");
473        }
474
475        #[test] // Function `parent` inherited through deref to S3Path!
476        fn parent_returns_none_when_path_has_no_components() {
477            let path_buf = S3PathBuf::new();
478            assert_that(path_buf.parent()).is_none();
479        }
480
481        #[test] // Function `parent` inherited through deref to S3Path!
482        fn parent_returns_empty_component_when_path_has_only_one_component() {
483            let path_buf = S3PathBuf::try_from(["foo"]).unwrap();
484            assert_that(path_buf.parent())
485                .is_some()
486                .has_display_value("");
487        }
488
489        #[test] // Function `parent` inherited through deref to S3Path!
490        fn parent_returns_view_of_parent_path_when_path_has_multiple_components() {
491            let path_buf = S3PathBuf::try_from(["foo", "bar", "baz"]).unwrap();
492            assert_that(path_buf.parent())
493                .is_some()
494                .has_display_value("foo/bar");
495        }
496
497        #[test] // Function `to_std_path_buf` inherited through deref to S3Path!
498        fn to_std_path_buf_returns_empty_path_buf_when_s3_path_has_zero_components() {
499            let path_buf = S3PathBuf::new();
500            assert_that(path_buf.to_std_path_buf().display()).has_display_value("");
501        }
502
503        #[test] // Function `to_std_path_buf` inherited through deref to S3Path!
504        fn to_std_path_buf_joins_components_with_path_separator_does_not_add_slashes() {
505            let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
506            assert_that(path_buf.to_std_path_buf().display()).has_display_value("foo/bar");
507        }
508
509        mod s3_path_buf_macro {
510            use assertr::prelude::*;
511            use std::borrow::Cow;
512
513            #[test]
514            fn taking_nothing() {
515                let path = s3_path_buf!().unwrap();
516                assert_that(path).has_display_value("");
517            }
518
519            #[test]
520            fn taking_single_static_str() {
521                let path = s3_path_buf!("foo").unwrap();
522                assert_that(path).has_display_value("foo");
523            }
524
525            #[test]
526            fn taking_owned_string_and_static_str() {
527                let foo = String::from("foo");
528                let path = s3_path_buf!(foo, "bar").unwrap();
529                assert_that(path).has_display_value("foo/bar");
530            }
531
532            #[test]
533            fn taking_owned_string_and_static_str_and_cow() {
534                let foo = String::from("foo");
535                let path = s3_path_buf!(foo, "bar", Cow::Borrowed("baz")).unwrap();
536                assert_that(path).has_display_value("foo/bar/baz");
537            }
538
539            #[test]
540            fn allows_a_trailing_comma() {
541                let path = s3_path_buf!("foo",).unwrap();
542                assert_that(path).has_display_value("foo");
543            }
544        }
545    }
546
547    mod s3_path {
548        use crate::S3Path;
549        use assertr::prelude::*;
550        use std::borrow::Cow;
551
552        #[test]
553        fn new_is_initially_empty() {
554            let path = S3Path::new(&[]).unwrap();
555            assert_that(path).has_display_value("");
556        }
557
558        #[test]
559        fn construct_using_new_and_join_components() {
560            let path = S3Path::new(&[Cow::Borrowed("foo"), Cow::Borrowed("bar")]).unwrap();
561            assert_that(path).has_display_value("foo/bar");
562        }
563
564        #[test]
565        fn to_owned_converts_to_owned_path() {
566            let path = s3_path!("foo", "bar").unwrap();
567            let path_owned = path.to_owned();
568            assert_that(path_owned).has_display_value("foo/bar");
569        }
570
571        #[test]
572        fn join_converts_to_owned_path_and_appends_component() {
573            let path = s3_path!("foo", "bar").unwrap();
574            let path_owned = path.join("baz").unwrap();
575            assert_that(path_owned).has_display_value("foo/bar/baz");
576        }
577
578        mod s3_path_macro {
579            use assertr::prelude::*;
580
581            #[test]
582            fn handles_zero_components() {
583                let path = s3_path!().unwrap();
584                assert_that(path).has_display_value("");
585            }
586
587            #[test]
588            fn handles_one_component() {
589                let path = s3_path!("foo").unwrap();
590                assert_that(path).has_display_value("foo");
591            }
592
593            #[test]
594            fn handles_multiple_components() {
595                let path = s3_path!("foo", "bar").unwrap();
596                assert_that(path).has_display_value("foo/bar");
597            }
598
599            #[test]
600            fn allows_a_trailing_comma() {
601                let path = s3_path!("foo",).unwrap();
602                assert_that(path).has_display_value("foo");
603            }
604        }
605    }
606
607    mod validation {
608        use crate::S3PathBuf;
609        use assertr::prelude::*;
610
611        #[test]
612        fn reject_invalid_characters() {
613            assert_that(S3PathBuf::try_from(["foo-bar"]))
614                .is_ok()
615                .has_display_value("foo-bar");
616            assert_that(S3PathBuf::try_from(["foo_bar"]))
617                .is_ok()
618                .has_display_value("foo_bar");
619            assert_that(S3PathBuf::try_from(["foo.bar"]))
620                .is_ok()
621                .has_display_value("foo.bar");
622            assert_that(S3PathBuf::try_from([".test"]))
623                .is_ok()
624                .has_display_value(".test");
625            assert_that(S3PathBuf::try_from(["..test"]))
626                .is_ok()
627                .has_display_value("..test");
628
629            assert_that(S3PathBuf::try_from(["foo:bar"])).is_err();
630            assert_that(S3PathBuf::try_from(["foo;bar"])).is_err();
631            assert_that(S3PathBuf::try_from(["foo$bar"])).is_err();
632            assert_that(S3PathBuf::try_from(["foo&bar"])).is_err();
633            assert_that(S3PathBuf::try_from(["foo#bar"])).is_err();
634            assert_that(S3PathBuf::try_from(["foo/bar"])).is_err();
635            assert_that(S3PathBuf::try_from(["foo|bar"])).is_err();
636            assert_that(S3PathBuf::try_from(["foo\\bar"])).is_err();
637            assert_that(S3PathBuf::try_from(["."])).is_err();
638            assert_that(S3PathBuf::try_from([".."])).is_err();
639        }
640    }
641
642    mod take_any_path {
643        use crate::{S3Path, S3PathBuf};
644
645        fn take_any_path<'p>(path: impl AsRef<S3Path<'p>>) {
646            let _path: &S3Path<'p> = path.as_ref();
647        }
648
649        #[test]
650        fn takes_owned_s3_path_buf() {
651            take_any_path(S3PathBuf::new());
652        }
653
654        #[test]
655        fn takes_borrowed_s3_path_buf() {
656            take_any_path(&S3PathBuf::new());
657        }
658
659        #[test]
660        fn takes_s3_path() {
661            take_any_path(S3Path::new(&[]).unwrap());
662        }
663    }
664
665    #[test]
666    fn comparison_between_types() {
667        let path_buf = S3PathBuf::try_from(["foo", "bar"]).unwrap();
668        let path = path_buf.as_path();
669
670        assert_that(path == path).is_true();
671        assert_that(path_buf == path_buf).is_true();
672
673        assert_that(path == path_buf).is_true();
674        assert_that(path == &path_buf).is_true();
675        assert_that(&path == path_buf).is_true();
676        assert_that(&path == &path_buf).is_true();
677
678        assert_that(path_buf == path).is_true();
679        assert_that(path_buf == &path).is_true();
680        assert_that(&path_buf == path).is_true();
681        assert_that(&path_buf == &path).is_true();
682
683        // Also works with assertr.
684        assert_that(&path_buf).is_equal_to(path);
685        assert_that(s3_path_buf!("foo", "bar"))
686            .is_ok()
687            .is_equal_to(path);
688    }
689}