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