Skip to main content

pubky_common/
capabilities.rs

1//! Capabilities define *what* a bearer can access (a scoped path) and *how* (a set of actions).
2//!
3//! ## String format
4//!
5//! A single capability is serialized as: `"<scope>:<actions>"`
6//!
7//! - `scope` must start with `/` (e.g. `"/pub/my-cool-app/"`, `"/"`).
8//! - `actions` is a compact string of letters, currently:
9//!   - `r` => read (GET)
10//!   - `w` => write (PUT/POST/DELETE)
11//!
12//! Examples:
13//!
14//! - Read+write everything: `"/:rw"`
15//! - Read-only a file: `"/pub/foo.txt:r"`
16//! - Read-write a directory: `"/pub/my-cool-app/:rw"`
17//!
18//! Multiple capabilities are serialized as a comma-separated list,
19//! e.g. `"/pub/my-cool-app/:rw,/pub/foo.txt:r"`.
20//!
21//! ## Builder ergonomics
22//!
23//! ```rust
24//! use pubky_common::capabilities::{Capability, Capabilities};
25//!
26//! // Single-cap builder
27//! let cap = Capability::builder("/pub/my-cool-app/")
28//!     .read()
29//!     .write()
30//!     .finish();
31//! assert_eq!(cap.to_string(), "/pub/my-cool-app/:rw");
32//!
33//! // Multiple caps builder
34//! let caps = Capabilities::builder()
35//!     .read_write("/pub/my-cool-app/")
36//!     .read("/pub/foo.txt")
37//!     .finish();
38//! assert_eq!(caps.to_string(), "/pub/my-cool-app/:rw,/pub/foo.txt:r");
39//! ```
40
41use serde::{Deserialize, Serialize};
42use std::{collections::BTreeSet, fmt::Display, str::FromStr};
43use url::Url;
44
45/// A single capability: a `scope` and the allowed `actions` within it.
46///
47/// The wire/string representation is `"<scope>:<actions>"`, see module docs.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Capability {
50    /// Scope of resources (e.g. a directory or file). Must start with `/`.
51    pub scope: String,
52    /// Allowed actions within `scope`. Serialized as a compact action string (e.g. `"rw"`).
53    pub actions: Vec<Action>,
54}
55
56impl Capability {
57    /// Shorthand for a root capability at `/` with read+write.
58    ///
59    /// Equivalent to `Capability { scope: "/".into(), actions: vec![Read, Write] }`.
60    ///
61    /// ```
62    /// use pubky_common::capabilities::Capability;
63    /// assert_eq!(Capability::root().to_string(), "/:rw");
64    /// ```
65    pub fn root() -> Self {
66        Capability {
67            scope: "/".to_string(),
68            actions: vec![Action::Read, Action::Write],
69        }
70    }
71
72    // ---- Shortcut constructors
73
74    /// Construct a read-only capability for `scope`.
75    ///
76    /// The scope is normalized to start with `/` if it does not already.
77    ///
78    /// ```
79    /// use pubky_common::capabilities::Capability;
80    /// assert_eq!(Capability::read("pub/my.app").to_string(), "/pub/my.app:r");
81    /// ```
82    #[inline]
83    pub fn read<S: Into<String>>(scope: S) -> Self {
84        Self::builder(scope).read().finish()
85    }
86
87    /// Construct a write-only capability for `scope`.
88    ///
89    /// ```
90    /// use pubky_common::capabilities::Capability;
91    /// assert_eq!(Capability::write("/pub/tmp").to_string(), "/pub/tmp:w");
92    /// ```
93    #[inline]
94    pub fn write<S: Into<String>>(scope: S) -> Self {
95        Self::builder(scope).write().finish()
96    }
97
98    /// Construct a read+write capability for `scope`.
99    ///
100    /// ```
101    /// use pubky_common::capabilities::Capability;
102    /// assert_eq!(Capability::read_write("/").to_string(), "/:rw");
103    /// ```
104    #[inline]
105    pub fn read_write<S: Into<String>>(scope: S) -> Self {
106        Self::builder(scope).read().write().finish()
107    }
108
109    /// Start building a single capability for `scope`.
110    ///
111    /// The scope is normalized to have a leading `/`.
112    ///
113    /// ```
114    /// use pubky_common::capabilities::Capability;
115    /// let cap = Capability::builder("pub/my.app").read().finish();
116    /// assert_eq!(cap.to_string(), "/pub/my.app:r");
117    /// ```
118    pub fn builder<S: Into<String>>(scope: S) -> CapabilityBuilder {
119        CapabilityBuilder {
120            scope: normalize_scope(scope.into()),
121            actions: BTreeSet::new(),
122        }
123    }
124
125    fn covers(&self, other: &Capability) -> bool {
126        if !scope_covers(&self.scope, &other.scope) {
127            return false;
128        }
129
130        other
131            .actions
132            .iter()
133            .all(|action| self.actions.contains(action))
134    }
135}
136
137/// Fluent builder for a single [`Capability`].
138///
139/// Use [`Capability::builder`] to construct, then chain `.read()/.write()` and `.finish()`.
140#[derive(Debug, Default)]
141pub struct CapabilityBuilder {
142    scope: String,
143    actions: BTreeSet<Action>,
144}
145
146impl CapabilityBuilder {
147    /// Allow **read** (GET) within the scope.
148    pub fn read(mut self) -> Self {
149        self.actions.insert(Action::Read);
150        self
151    }
152
153    /// Allow **write** (PUT/POST/DELETE) within the scope.
154    pub fn write(mut self) -> Self {
155        self.actions.insert(Action::Write);
156        self
157    }
158
159    /// Allow a specific action. Useful if more actions are added in the future.
160    pub fn allow(mut self, action: Action) -> Self {
161        self.actions.insert(action);
162        self
163    }
164
165    /// Finalize and produce the [`Capability`].
166    ///
167    /// Actions are de-duplicated and emitted in a stable order.
168    pub fn finish(self) -> Capability {
169        let v: Vec<Action> = self.actions.into_iter().collect();
170        // BTreeSet sorts; keep stable & dedup’d
171        Capability {
172            scope: self.scope,
173            actions: v,
174        }
175    }
176}
177
178/// Actions allowed on a given scope.
179///
180/// Display/serialization encodes these as single characters (`r`, `w`).
181#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
182pub enum Action {
183    /// Can read the scope at the specified path (GET requests).
184    Read,
185    /// Can write to the scope at the specified path (PUT/POST/DELETE requests).
186    Write,
187    /// Unknown ability
188    Unknown(char),
189}
190
191impl From<&Action> for char {
192    fn from(value: &Action) -> Self {
193        match value {
194            Action::Read => 'r',
195            Action::Write => 'w',
196            Action::Unknown(char) => char.to_owned(),
197        }
198    }
199}
200
201impl TryFrom<char> for Action {
202    type Error = Error;
203
204    fn try_from(value: char) -> Result<Self, Error> {
205        match value {
206            'r' => Ok(Self::Read),
207            'w' => Ok(Self::Write),
208            _ => Err(Error::InvalidAction),
209        }
210    }
211}
212
213impl Display for Capability {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        write!(
216            f,
217            "{}:{}",
218            self.scope,
219            self.actions.iter().map(char::from).collect::<String>()
220        )
221    }
222}
223
224impl TryFrom<String> for Capability {
225    type Error = Error;
226
227    fn try_from(value: String) -> Result<Self, Error> {
228        value.as_str().try_into()
229    }
230}
231
232impl FromStr for Capability {
233    type Err = Error;
234
235    fn from_str(s: &str) -> Result<Self, Error> {
236        s.try_into()
237    }
238}
239
240impl TryFrom<&str> for Capability {
241    type Error = Error;
242    /// Parse `"<scope>:<actions>"`. Scope must start with `/`; actions must be valid letters.
243    ///
244    /// ```
245    /// use pubky_common::capabilities::Capability;
246    /// let cap: Capability = "/pub/my-cool-app/:rw".try_into().unwrap();
247    /// assert_eq!(cap.to_string(), "/pub/my-cool-app/:rw");
248    /// ```
249    fn try_from(value: &str) -> Result<Self, Error> {
250        if value.matches(':').count() != 1 {
251            return Err(Error::InvalidFormat);
252        }
253
254        if !value.starts_with('/') {
255            return Err(Error::InvalidScope);
256        }
257
258        let actions_str = value.rsplit(':').next().unwrap_or("");
259
260        let mut actions = Vec::new();
261
262        for char in actions_str.chars() {
263            let ability = Action::try_from(char)?;
264
265            match actions.binary_search_by(|element| char::from(element).cmp(&char)) {
266                Ok(_) => {}
267                Err(index) => {
268                    actions.insert(index, ability);
269                }
270            }
271        }
272
273        let scope = value[0..value.len() - actions_str.len() - 1].to_string();
274
275        Ok(Self { scope, actions })
276    }
277}
278
279impl Serialize for Capability {
280    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
281    where
282        S: serde::Serializer,
283    {
284        let string = self.to_string();
285
286        string.serialize(serializer)
287    }
288}
289
290impl<'de> Deserialize<'de> for Capability {
291    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
292    where
293        D: serde::Deserializer<'de>,
294    {
295        let string: String = Deserialize::deserialize(deserializer)?;
296
297        string.try_into().map_err(serde::de::Error::custom)
298    }
299}
300
301#[derive(thiserror::Error, Debug, PartialEq, Eq)]
302/// Error parsing a [Capability].
303pub enum Error {
304    #[error("Capability: Invalid scope: does not start with `/`")]
305    /// Capability: Invalid scope: does not start with `/`
306    InvalidScope,
307    #[error("Capability: Invalid format should be <scope>:<abilities>")]
308    /// Capability: Invalid format should be `<scope>:<abilities>`
309    InvalidFormat,
310    #[error("Capability: Invalid Action")]
311    /// Capability: Invalid Action
312    InvalidAction,
313    #[error("Capabilities: Invalid capabilities format")]
314    /// Capabilities: Invalid capabilities format
315    InvalidCapabilities,
316}
317
318/// A wrapper around `Vec<Capability>` that controls how capabilities are
319/// serialized and built.
320///
321/// Serialization is a single comma-separated string (e.g. `"/:rw,/pub/my-cool-app/:r"`),
322/// which is convenient for logs, URLs, or compact text payloads. It also comes
323/// with a fluent builder (`Capabilities::builder()`).
324///
325/// Note: this does **not** remove length prefixes in binary encodings; if you
326/// need a varint-free trailing field in a custom binary format, implement a
327/// bespoke encoder/decoder instead of serde.
328#[derive(Clone, Default, Debug, PartialEq, Eq)]
329#[must_use]
330pub struct Capabilities(Vec<Capability>);
331
332impl Capabilities {
333    /// Return a normalized capability list.
334    ///
335    /// Normalization merges duplicate scopes, de-duplicates and sorts actions,
336    /// and removes capabilities already covered by broader capabilities.
337    ///
338    /// # Examples
339    /// ```
340    /// use pubky_common::capabilities::{Capability, Capabilities};
341    ///
342    /// let caps = Capabilities::from(vec![
343    ///     Capability::read("/pub/"),
344    ///     Capability::write("/pub/"),
345    ///     Capability::read("/pub/file.txt"),
346    /// ]);
347    ///
348    /// assert_eq!(caps.normalize().to_string(), "/pub/:rw");
349    /// ```
350    pub fn normalize(self) -> Self {
351        Self(normalize(self.0))
352    }
353
354    /// Returns true if the list contains `capability`.
355    pub fn contains(&self, capability: &Capability) -> bool {
356        self.0.contains(capability)
357    }
358
359    /// Returns `true` if the list is empty.
360    pub fn is_empty(&self) -> bool {
361        self.0.is_empty()
362    }
363
364    /// Returns the number of entries.
365    pub fn len(&self) -> usize {
366        self.0.len()
367    }
368
369    /// Returns an iterator over the slice of [Capability].
370    pub fn iter(&self) -> std::slice::Iter<'_, Capability> {
371        self.0.iter()
372    }
373
374    /// Start a fluent builder for multiple capabilities.
375    ///
376    /// ```
377    /// use pubky_common::capabilities::Capabilities;
378    /// let caps = Capabilities::builder().read_write("/").finish();
379    /// assert_eq!(caps.to_string(), "/:rw");
380    /// ```
381    pub fn builder() -> CapsBuilder {
382        CapsBuilder::default()
383    }
384
385    /// Parse capabilities from the `caps` query parameter of `url`.
386    ///
387    /// Expects a comma-separated list of capability strings, e.g.:
388    /// `?caps=/pub/my-cool-app/:rw,/foo:r`
389    ///
390    /// Invalid entries are ignored.
391    ///
392    /// # Examples
393    /// ```
394    /// # use url::Url;
395    /// # use pubky_common::capabilities::Capabilities;
396    /// let url = Url::parse("https://example/app?caps=/pub/my-cool-app/:rw,/foo:r").unwrap();
397    /// let caps = Capabilities::from_caps_url(&url);
398    /// assert!(!caps.is_empty());
399    /// ```
400    pub fn from_caps_url(url: &Url) -> Self {
401        // Get the first `caps` entry if present.
402        let value = url
403            .query_pairs()
404            .find_map(|(k, v)| (k == "caps").then(|| v.to_string()))
405            .unwrap_or_default();
406
407        // Parse comma-separated capabilities, skipping invalid pieces.
408        let caps: Vec<_> = value
409            .split(',')
410            .filter_map(|s| Capability::try_from(s).ok())
411            .collect();
412
413        Self::from(caps)
414    }
415
416    /// Borrow the inner capabilities as a slice without allocating.
417    ///
418    /// Constant-time; returns a view into the existing buffer.
419    ///
420    /// # Examples
421    /// ```
422    /// use pubky_common::capabilities::{Capability, Capabilities};
423    ///
424    /// let caps = Capabilities::from(vec![
425    ///     Capability::read("/foo"),
426    ///     Capability::write("/bar/"),
427    /// ]);
428    /// let slice: &[Capability] = caps.as_slice();
429    /// assert_eq!(slice.len(), 2);
430    /// ```
431    #[inline]
432    pub fn as_slice(&self) -> &[Capability] {
433        &self.0
434    }
435}
436
437/// Fluent builder for multiple [`Capability`] entries.
438///
439/// Build with high-level helpers (`.read()/.write()/.read_write()`), or push prebuilt
440/// capabilities with `.cap()`, or use `.capability(scope, |b| ...)` to build inline.
441#[derive(Default, Debug)]
442pub struct CapsBuilder {
443    caps: Vec<Capability>,
444}
445
446impl CapsBuilder {
447    /// Create a new empty builder.
448    pub fn new() -> Self {
449        Self::default()
450    }
451
452    /// Push a prebuilt capability
453    pub fn cap(mut self, cap: Capability) -> Self {
454        self.caps.push(cap);
455        self
456    }
457
458    /// Build a capability inline and push it:
459    ///
460    /// ```
461    /// use pubky_common::capabilities::Capabilities;
462    /// let caps = Capabilities::builder()
463    ///     .capability("/pub/my-cool-app/", |b| b.read().write())
464    ///     .finish();
465    /// assert_eq!(caps.to_string(), "/pub/my-cool-app/:rw");
466    /// ```
467    pub fn capability<F>(mut self, scope: impl Into<String>, f: F) -> Self
468    where
469        F: FnOnce(CapabilityBuilder) -> CapabilityBuilder,
470    {
471        let cap = f(Capability::builder(scope)).finish();
472        self.caps.push(cap);
473        self
474    }
475
476    /// Add a read-only capability for `scope`.
477    pub fn read(mut self, scope: impl Into<String>) -> Self {
478        self.caps.push(Capability::read(scope));
479        self
480    }
481
482    /// Add a write-only capability for `scope`.
483    pub fn write(mut self, scope: impl Into<String>) -> Self {
484        self.caps.push(Capability::write(scope));
485        self
486    }
487
488    /// Add a read+write capability for `scope`.
489    pub fn read_write(mut self, scope: impl Into<String>) -> Self {
490        self.caps.push(Capability::read_write(scope));
491        self
492    }
493
494    /// Extend with an iterator of capabilities.
495    pub fn extend<I: IntoIterator<Item = Capability>>(mut self, iter: I) -> Self {
496        self.caps.extend(iter);
497        self
498    }
499
500    /// Finalize and produce the normalized [`Capabilities`] list.
501    pub fn finish(self) -> Capabilities {
502        Capabilities::from(self.caps).normalize()
503    }
504}
505
506impl From<Vec<Capability>> for Capabilities {
507    fn from(value: Vec<Capability>) -> Self {
508        Self(value)
509    }
510}
511
512impl From<Capabilities> for Vec<Capability> {
513    fn from(value: Capabilities) -> Self {
514        value.0
515    }
516}
517
518impl TryFrom<&str> for Capabilities {
519    type Error = Error;
520
521    fn try_from(value: &str) -> Result<Self, Self::Error> {
522        let mut caps = vec![];
523
524        for s in value.split(',') {
525            if let Ok(cap) = Capability::try_from(s) {
526                caps.push(cap);
527            };
528        }
529
530        Ok(Self::from(caps))
531    }
532}
533
534impl Display for Capabilities {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        let string = self
537            .0
538            .iter()
539            .map(|c| c.to_string())
540            .collect::<Vec<_>>()
541            .join(",");
542
543        write!(f, "{string}")
544    }
545}
546
547impl Serialize for Capabilities {
548    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
549    where
550        S: serde::Serializer,
551    {
552        self.to_string().serialize(serializer)
553    }
554}
555
556impl<'de> Deserialize<'de> for Capabilities {
557    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
558    where
559        D: serde::Deserializer<'de>,
560    {
561        let string: String = Deserialize::deserialize(deserializer)?;
562
563        let mut caps = vec![];
564
565        for s in string.split(',') {
566            if let Ok(cap) = Capability::try_from(s) {
567                caps.push(cap);
568            };
569        }
570
571        Ok(Self::from(caps))
572    }
573}
574
575// --- helpers ---
576
577fn normalize_scope(mut s: String) -> String {
578    if !s.starts_with('/') {
579        s.insert(0, '/');
580    }
581    s
582}
583
584fn scope_covers(parent: &str, child: &str) -> bool {
585    if parent == child {
586        return true;
587    }
588
589    if !parent.ends_with('/') {
590        return false;
591    }
592
593    child.starts_with(parent)
594}
595
596fn normalize(caps: Vec<Capability>) -> Vec<Capability> {
597    let mut merged: Vec<Capability> = Vec::new();
598
599    for mut cap in caps {
600        if let Some(existing) = merged
601            .iter_mut()
602            .find(|existing| existing.scope == cap.scope)
603        {
604            let actions: BTreeSet<Action> = existing
605                .actions
606                .iter()
607                .copied()
608                .chain(cap.actions.iter().copied())
609                .collect();
610            existing.actions = actions.into_iter().collect();
611            continue;
612        }
613
614        let actions: BTreeSet<Action> = cap.actions.iter().copied().collect();
615        cap.actions = actions.into_iter().collect();
616        merged.push(cap);
617    }
618
619    let mut sanitized: Vec<Capability> = Vec::new();
620
621    'outer: for cap in merged.into_iter() {
622        if sanitized.iter().any(|existing| existing.covers(&cap)) {
623            continue 'outer;
624        }
625
626        sanitized.retain(|existing| !cap.covers(existing));
627        sanitized.push(cap);
628    }
629
630    sanitized
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use url::Url;
637
638    #[test]
639    fn pubky_caps() {
640        let cap = Capability {
641            scope: "/pub/pubky.app/".to_string(),
642            actions: vec![Action::Read, Action::Write],
643        };
644
645        // Read and write within directory `/pub/pubky.app/`.
646        let expected_string = "/pub/pubky.app/:rw";
647
648        assert_eq!(cap.to_string(), expected_string);
649
650        assert_eq!(Capability::try_from(expected_string), Ok(cap))
651    }
652
653    #[test]
654    fn root_capability_helper() {
655        let cap = Capability::root();
656        assert_eq!(cap.scope, "/");
657        assert_eq!(cap.actions, vec![Action::Read, Action::Write]);
658        assert_eq!(cap.to_string(), "/:rw");
659        // And it round-trips through the string form:
660        assert_eq!(Capability::try_from("/:rw"), Ok(cap));
661    }
662
663    #[test]
664    fn single_capability_via_builder_and_shortcuts() {
665        // Full builder:
666        let cap1 = Capability::builder("/pub/my-cool-app/")
667            .read()
668            .write()
669            .finish();
670        assert_eq!(cap1.to_string(), "/pub/my-cool-app/:rw");
671
672        // Shortcuts:
673        let cap_rw = Capability::read_write("/pub/my-cool-app/");
674        let cap_r = Capability::read("/pub/file.txt");
675        let cap_w = Capability::write("/pub/uploads/");
676
677        assert_eq!(cap_rw, cap1);
678        assert_eq!(cap_r.to_string(), "/pub/file.txt:r");
679        assert_eq!(cap_w.to_string(), "/pub/uploads/:w");
680    }
681
682    #[test]
683    fn multiple_caps_with_capsbuilder() {
684        let caps = Capabilities::builder()
685            .read("/pub/my-cool-app/") // "/pub/my-cool-app/:r"
686            .write("/pub/uploads/") // "/pub/uploads/:w"
687            .read_write("/pub/my-cool-app/data/") // "/pub/my-cool-app/data/:rw"
688            .finish();
689
690        // String form is comma-separated, in insertion order:
691        assert_eq!(
692            caps.to_string(),
693            "/pub/my-cool-app/:r,/pub/uploads/:w,/pub/my-cool-app/data/:rw"
694        );
695
696        // Contains checks:
697        assert!(caps.contains(&Capability::read("/pub/my-cool-app/")));
698        assert!(caps.contains(&Capability::write("/pub/uploads/")));
699        assert!(caps.contains(&Capability::read_write("/pub/my-cool-app/data/")));
700        assert!(!caps.contains(&Capability::write("/nope")));
701    }
702
703    #[test]
704    fn build_with_inline_capability_closure() {
705        // Build a capability inline with fine-grained control, then push it:
706        let caps = Capabilities::builder()
707            .capability("/pub/my-cool-app/", |c| c.read().write())
708            .finish();
709
710        assert_eq!(caps.to_string(), "/pub/my-cool-app/:rw");
711    }
712
713    #[test]
714    fn action_dedup_and_order_are_stable() {
715        // Insert actions in noisy order; builder dedups & sorts (Read < Write).
716        let cap = Capability::builder("/")
717            .write()
718            .read()
719            .read()
720            .write()
721            .finish();
722        assert_eq!(cap.actions, vec![Action::Read, Action::Write]);
723        assert_eq!(cap.to_string(), "/:rw");
724    }
725
726    #[test]
727    fn normalize_scope_adds_leading_slash() {
728        // No leading slash? The helpers normalize it.
729        let cap = Capability::read("pub/my.app");
730        assert_eq!(cap.scope, "/pub/my.app");
731        assert_eq!(cap.to_string(), "/pub/my.app:r");
732
733        // CapsBuilder helpers also normalize:
734        let caps = Capabilities::builder()
735            .read_write("pub/my-cool-app/data")
736            .finish();
737        assert_eq!(caps.to_string(), "/pub/my-cool-app/data:rw");
738    }
739
740    #[test]
741    fn parse_from_string_list() {
742        // From a comma-separated string:
743        let parsed = Capabilities::try_from("/:rw,/pub/my-cool-app/:r")
744            .unwrap()
745            .normalize();
746        let built = Capabilities::builder()
747            .read_write("/") // "/:rw"
748            .read("/pub/my-cool-app/") // "/pub/my-cool-app/:r"
749            .finish();
750
751        assert_eq!(parsed, built);
752    }
753
754    #[test]
755    fn parse_errors_are_informative() {
756        // Invalid scope (doesn't start with '/'):
757        let e = Capability::try_from("not/abs:rw").unwrap_err();
758        assert_eq!(e, Error::InvalidScope);
759
760        // Invalid format (missing ':'):
761        let e = Capability::try_from("/pub/my.app").unwrap_err();
762        assert_eq!(e, Error::InvalidFormat);
763
764        // Invalid action:
765        let e = Capability::try_from("/pub/my.app:rx").unwrap_err();
766        assert_eq!(e, Error::InvalidAction);
767    }
768
769    #[test]
770    fn redundant_capabilities_builder_dedup() {
771        let caps = Capabilities::builder()
772            .read_write("/pub/example.com/")
773            .read_write("/pub/example.com/")
774            .write("/pub/example.com/subfolder")
775            .finish()
776            .normalize();
777
778        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
779    }
780
781    #[test]
782    fn redundant_capabilities_string_dedup() {
783        let parsed = Capabilities::try_from(
784            "/pub/example.com/:rw,/pub/example.com/:rw,/pub/example.com/subfolder:w",
785        )
786        .unwrap()
787        .normalize();
788
789        let caps = Capabilities::builder()
790            .read_write("/pub/example.com/")
791            .finish();
792
793        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
794        assert_eq!(parsed, caps);
795    }
796
797    #[test]
798    fn redundant_capabilities_from_url_dedup() {
799        let url = Url::parse(
800            "https://example.test?caps=/pub/example.com/:rw,/pub/example.com/documents:w",
801        )
802        .unwrap();
803        let caps = Capabilities::from_caps_url(&url).normalize();
804
805        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
806    }
807
808    #[test]
809    fn redundant_capabilities_merge_actions() {
810        let caps = Capabilities::builder()
811            .read("/pub/example.com/")
812            .write("/pub/example.com/")
813            .finish()
814            .normalize();
815
816        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
817    }
818
819    #[test]
820    fn capabilities_normalize_dedups_from_vec() {
821        let caps = Capabilities::from(vec![
822            Capability::read_write("/pub/example.com/"),
823            Capability::write("/pub/example.com/subfolder"),
824            Capability::read("/pub/example.com/"),
825        ])
826        .normalize();
827
828        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
829    }
830
831    #[test]
832    fn capabilities_len_and_is_empty() {
833        let empty = Capabilities::builder().finish();
834        assert!(empty.is_empty());
835        assert_eq!(empty.len(), 0);
836
837        let one = Capabilities::builder().read("/").finish();
838        assert!(!one.is_empty());
839        assert_eq!(one.len(), 1);
840    }
841
842    // Requires dev-dependency: serde_json
843    #[test]
844    fn serde_roundtrip_as_string() {
845        let caps = Capabilities::builder()
846            .read_write("/pub/my-cool-app/")
847            .read("/pub/file.txt")
848            .finish();
849
850        let json = serde_json::to_string(&caps).unwrap();
851        // Serialized as a single string:
852        assert_eq!(json, "\"/pub/my-cool-app/:rw,/pub/file.txt:r\"");
853
854        let back: Capabilities = serde_json::from_str(&json).unwrap();
855        assert_eq!(back, caps);
856    }
857}