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