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(Capability { 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(pub Vec<Capability>);
331
332impl Capabilities {
333    /// Returns true if the list contains `capability`.
334    pub fn contains(&self, capability: &Capability) -> bool {
335        self.0.contains(capability)
336    }
337
338    /// Returns `true` if the list is empty.
339    pub fn is_empty(&self) -> bool {
340        self.0.is_empty()
341    }
342
343    /// Returns the number of entries.
344    pub fn len(&self) -> usize {
345        self.0.len()
346    }
347
348    /// Returns an iterator over the slice of [Capability].
349    pub fn iter(&self) -> std::slice::Iter<'_, Capability> {
350        self.0.iter()
351    }
352
353    /// Parse capabilities from the `caps` query parameter.
354    ///
355    /// Expects a comma-separated list of capability strings, e.g.:
356    /// `?caps=/pub/my-cool-app/:rw,/foo:r`
357    ///
358    /// Invalid entries are ignored.
359    ///
360    /// # Examples
361    /// ```
362    /// # use url::Url;
363    /// # use pubky_common::capabilities::Capabilities;
364    /// let url = Url::parse("https://example/app?caps=/pub/my-cool-app/:rw,/foo:r").unwrap();
365    /// let caps = Capabilities::from_url(&url);
366    /// assert!(!caps.is_empty());
367    /// ```
368    pub fn from_url(url: &Url) -> Self {
369        // Get the first `caps` entry if present.
370        let value = url
371            .query_pairs()
372            .find_map(|(k, v)| (k == "caps").then(|| v.to_string()))
373            .unwrap_or_default();
374
375        // Parse comma-separated capabilities, skipping invalid pieces.
376        let caps = value
377            .split(',')
378            .filter_map(|s| Capability::try_from(s).ok())
379            .collect();
380
381        Capabilities(sanitize_caps(caps))
382    }
383
384    /// Start a fluent builder for multiple capabilities.
385    ///
386    /// ```
387    /// use pubky_common::capabilities::Capabilities;
388    /// let caps = Capabilities::builder().read_write("/").finish();
389    /// assert_eq!(caps.to_string(), "/:rw");
390    /// ```
391    pub fn builder() -> CapsBuilder {
392        CapsBuilder::default()
393    }
394
395    /// Borrow the inner capabilities as a slice without allocating.
396    ///
397    /// Constant-time; returns a view into the existing buffer.
398    ///
399    /// # Examples
400    /// ```
401    /// use pubky_common::capabilities::{Capability, Capabilities};
402    ///
403    /// let caps = Capabilities(vec![
404    ///     Capability::read("/foo"),
405    ///     Capability::write("/bar/"),
406    /// ]);
407    /// let slice: &[Capability] = caps.as_slice();
408    /// assert_eq!(slice.len(), 2);
409    /// ```
410    #[inline]
411    pub fn as_slice(&self) -> &[Capability] {
412        &self.0
413    }
414
415    /// Clone the inner capabilities into an owned `Vec<Capability>`.
416    ///
417    /// Allocates and performs an `O(n)` clone of the elements. Use when
418    /// ownership is required by downstream APIs.
419    ///
420    /// ```
421    /// use pubky_common::capabilities::{Capability, Capabilities};
422    ///
423    /// let caps = Capabilities(vec![Capability::read("/")]);
424    /// let owned: Vec<Capability> = caps.to_vec();
425    /// assert_eq!(owned, vec![Capability::read("/")]);
426    /// ```
427    #[inline]
428    pub fn to_vec(&self) -> Vec<Capability> {
429        self.0.clone()
430    }
431}
432
433/// Fluent builder for multiple [`Capability`] entries.
434///
435/// Build with high-level helpers (`.read()/.write()/.read_write()`), or push prebuilt
436/// capabilities with `.cap()`, or use `.capability(scope, |b| ...)` to build inline.
437#[derive(Default, Debug)]
438pub struct CapsBuilder {
439    caps: Vec<Capability>,
440}
441
442impl CapsBuilder {
443    /// Create a new empty builder.
444    pub fn new() -> Self {
445        Self::default()
446    }
447
448    /// Push a prebuilt capability
449    pub fn cap(mut self, cap: Capability) -> Self {
450        self.caps.push(cap);
451        self
452    }
453
454    /// Build a capability inline and push it:
455    ///
456    /// ```
457    /// use pubky_common::capabilities::Capabilities;
458    /// let caps = Capabilities::builder()
459    ///     .capability("/pub/my-cool-app/", |b| b.read().write())
460    ///     .finish();
461    /// assert_eq!(caps.to_string(), "/pub/my-cool-app/:rw");
462    /// ```
463    pub fn capability<F>(mut self, scope: impl Into<String>, f: F) -> Self
464    where
465        F: FnOnce(CapabilityBuilder) -> CapabilityBuilder,
466    {
467        let cap = f(Capability::builder(scope)).finish();
468        self.caps.push(cap);
469        self
470    }
471
472    /// Add a read-only capability for `scope`.
473    pub fn read(mut self, scope: impl Into<String>) -> Self {
474        self.caps.push(Capability::read(scope));
475        self
476    }
477
478    /// Add a write-only capability for `scope`.
479    pub fn write(mut self, scope: impl Into<String>) -> Self {
480        self.caps.push(Capability::write(scope));
481        self
482    }
483
484    /// Add a read+write capability for `scope`.
485    pub fn read_write(mut self, scope: impl Into<String>) -> Self {
486        self.caps.push(Capability::read_write(scope));
487        self
488    }
489
490    /// Extend with an iterator of capabilities.
491    pub fn extend<I: IntoIterator<Item = Capability>>(mut self, iter: I) -> Self {
492        self.caps.extend(iter);
493        self
494    }
495
496    /// Finalize and produce the [`Capabilities`] list.
497    pub fn finish(self) -> Capabilities {
498        Capabilities(sanitize_caps(self.caps))
499    }
500}
501
502impl From<Vec<Capability>> for Capabilities {
503    fn from(value: Vec<Capability>) -> Self {
504        Self(value)
505    }
506}
507
508impl From<Capabilities> for Vec<Capability> {
509    fn from(value: Capabilities) -> Self {
510        value.0
511    }
512}
513
514impl TryFrom<&str> for Capabilities {
515    type Error = Error;
516
517    fn try_from(value: &str) -> Result<Self, Self::Error> {
518        let mut caps = vec![];
519
520        for s in value.split(',') {
521            if let Ok(cap) = Capability::try_from(s) {
522                caps.push(cap);
523            };
524        }
525
526        Ok(Capabilities(sanitize_caps(caps)))
527    }
528}
529
530/// Allow `Capabilities::from(&url)` using the default `caps` key.
531impl From<&Url> for Capabilities {
532    fn from(url: &Url) -> Self {
533        Capabilities::from_url(url)
534    }
535}
536
537/// Allow `Capabilities::from(url)` (by value) using the default `caps` key.
538impl From<Url> for Capabilities {
539    fn from(url: Url) -> Self {
540        Capabilities::from_url(&url)
541    }
542}
543
544impl Display for Capabilities {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        let string = self
547            .0
548            .iter()
549            .map(|c| c.to_string())
550            .collect::<Vec<_>>()
551            .join(",");
552
553        write!(f, "{string}")
554    }
555}
556
557impl Serialize for Capabilities {
558    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
559    where
560        S: serde::Serializer,
561    {
562        self.to_string().serialize(serializer)
563    }
564}
565
566impl<'de> Deserialize<'de> for Capabilities {
567    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
568    where
569        D: serde::Deserializer<'de>,
570    {
571        let string: String = Deserialize::deserialize(deserializer)?;
572
573        let mut caps = vec![];
574
575        for s in string.split(',') {
576            if let Ok(cap) = Capability::try_from(s) {
577                caps.push(cap);
578            };
579        }
580
581        Ok(Capabilities(sanitize_caps(caps)))
582    }
583}
584
585// --- helpers ---
586
587fn normalize_scope(mut s: String) -> String {
588    if !s.starts_with('/') {
589        s.insert(0, '/');
590    }
591    s
592}
593
594fn scope_covers(parent: &str, child: &str) -> bool {
595    if parent == child {
596        return true;
597    }
598
599    if !parent.ends_with('/') {
600        return false;
601    }
602
603    child.starts_with(parent)
604}
605
606fn sanitize_caps(caps: Vec<Capability>) -> Vec<Capability> {
607    let mut merged: Vec<Capability> = Vec::new();
608
609    for mut cap in caps {
610        if let Some(existing) = merged
611            .iter_mut()
612            .find(|existing| existing.scope == cap.scope)
613        {
614            let actions: BTreeSet<Action> = existing
615                .actions
616                .iter()
617                .copied()
618                .chain(cap.actions.iter().copied())
619                .collect();
620            existing.actions = actions.into_iter().collect();
621            continue;
622        }
623
624        let actions: BTreeSet<Action> = cap.actions.iter().copied().collect();
625        cap.actions = actions.into_iter().collect();
626        merged.push(cap);
627    }
628
629    let mut sanitized: Vec<Capability> = Vec::new();
630
631    'outer: for cap in merged.into_iter() {
632        if sanitized.iter().any(|existing| existing.covers(&cap)) {
633            continue 'outer;
634        }
635
636        sanitized.retain(|existing| !cap.covers(existing));
637        sanitized.push(cap);
638    }
639
640    sanitized
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use url::Url;
647
648    #[test]
649    fn pubky_caps() {
650        let cap = Capability {
651            scope: "/pub/pubky.app/".to_string(),
652            actions: vec![Action::Read, Action::Write],
653        };
654
655        // Read and write within directory `/pub/pubky.app/`.
656        let expected_string = "/pub/pubky.app/:rw";
657
658        assert_eq!(cap.to_string(), expected_string);
659
660        assert_eq!(Capability::try_from(expected_string), Ok(cap))
661    }
662
663    #[test]
664    fn root_capability_helper() {
665        let cap = Capability::root();
666        assert_eq!(cap.scope, "/");
667        assert_eq!(cap.actions, vec![Action::Read, Action::Write]);
668        assert_eq!(cap.to_string(), "/:rw");
669        // And it round-trips through the string form:
670        assert_eq!(Capability::try_from("/:rw"), Ok(cap));
671    }
672
673    #[test]
674    fn single_capability_via_builder_and_shortcuts() {
675        // Full builder:
676        let cap1 = Capability::builder("/pub/my-cool-app/")
677            .read()
678            .write()
679            .finish();
680        assert_eq!(cap1.to_string(), "/pub/my-cool-app/:rw");
681
682        // Shortcuts:
683        let cap_rw = Capability::read_write("/pub/my-cool-app/");
684        let cap_r = Capability::read("/pub/file.txt");
685        let cap_w = Capability::write("/pub/uploads/");
686
687        assert_eq!(cap_rw, cap1);
688        assert_eq!(cap_r.to_string(), "/pub/file.txt:r");
689        assert_eq!(cap_w.to_string(), "/pub/uploads/:w");
690    }
691
692    #[test]
693    fn multiple_caps_with_capsbuilder() {
694        let caps = Capabilities::builder()
695            .read("/pub/my-cool-app/") // "/pub/my-cool-app/:r"
696            .write("/pub/uploads/") // "/pub/uploads/:w"
697            .read_write("/pub/my-cool-app/data/") // "/pub/my-cool-app/data/:rw"
698            .finish();
699
700        // String form is comma-separated, in insertion order:
701        assert_eq!(
702            caps.to_string(),
703            "/pub/my-cool-app/:r,/pub/uploads/:w,/pub/my-cool-app/data/:rw"
704        );
705
706        // Contains checks:
707        assert!(caps.contains(&Capability::read("/pub/my-cool-app/")));
708        assert!(caps.contains(&Capability::write("/pub/uploads/")));
709        assert!(caps.contains(&Capability::read_write("/pub/my-cool-app/data/")));
710        assert!(!caps.contains(&Capability::write("/nope")));
711    }
712
713    #[test]
714    fn build_with_inline_capability_closure() {
715        // Build a capability inline with fine-grained control, then push it:
716        let caps = Capabilities::builder()
717            .capability("/pub/my-cool-app/", |c| c.read().write())
718            .finish();
719
720        assert_eq!(caps.to_string(), "/pub/my-cool-app/:rw");
721    }
722
723    #[test]
724    fn action_dedup_and_order_are_stable() {
725        // Insert actions in noisy order; builder dedups & sorts (Read < Write).
726        let cap = Capability::builder("/")
727            .write()
728            .read()
729            .read()
730            .write()
731            .finish();
732        assert_eq!(cap.actions, vec![Action::Read, Action::Write]);
733        assert_eq!(cap.to_string(), "/:rw");
734    }
735
736    #[test]
737    fn normalize_scope_adds_leading_slash() {
738        // No leading slash? The helpers normalize it.
739        let cap = Capability::read("pub/my.app");
740        assert_eq!(cap.scope, "/pub/my.app");
741        assert_eq!(cap.to_string(), "/pub/my.app:r");
742
743        // CapsBuilder helpers also normalize:
744        let caps = Capabilities::builder()
745            .read_write("pub/my-cool-app/data")
746            .finish();
747        assert_eq!(caps.to_string(), "/pub/my-cool-app/data:rw");
748    }
749
750    #[test]
751    fn parse_from_string_list() {
752        // From a comma-separated string:
753        let parsed = Capabilities::try_from("/:rw,/pub/my-cool-app/:r").unwrap();
754        let built = Capabilities::builder()
755            .read_write("/") // "/:rw"
756            .read("/pub/my-cool-app/") // "/pub/my-cool-app/:r"
757            .finish();
758
759        assert_eq!(parsed, built);
760    }
761
762    #[test]
763    fn parse_errors_are_informative() {
764        // Invalid scope (doesn't start with '/'):
765        let e = Capability::try_from("not/abs:rw").unwrap_err();
766        assert!(matches!(e, Error::InvalidScope));
767
768        // Invalid format (missing ':'):
769        let e = Capability::try_from("/pub/my.app").unwrap_err();
770        assert!(matches!(e, Error::InvalidFormat));
771
772        // Invalid action:
773        let e = Capability::try_from("/pub/my.app:rx").unwrap_err();
774        assert!(matches!(e, Error::InvalidAction));
775    }
776
777    #[test]
778    fn redundant_capabilities_builder_dedup() {
779        let caps = Capabilities::builder()
780            .read_write("/pub/example.com/")
781            .read_write("/pub/example.com/")
782            .write("/pub/example.com/subfolder")
783            .finish();
784
785        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
786    }
787
788    #[test]
789    fn redundant_capabilities_string_dedup() {
790        let parsed = Capabilities::try_from(
791            "/pub/example.com/:rw,/pub/example.com/:rw,/pub/example.com/subfolder:w",
792        )
793        .unwrap();
794
795        let caps = Capabilities::builder()
796            .read_write("/pub/example.com/")
797            .finish();
798
799        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
800        assert_eq!(parsed, caps);
801    }
802
803    #[test]
804    fn redundant_capabilities_from_url_dedup() {
805        let url = Url::parse(
806            "https://example.test?caps=/pub/example.com/:rw,/pub/example.com/documents:w",
807        )
808        .unwrap();
809        let caps = Capabilities::from_url(&url);
810
811        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
812    }
813
814    #[test]
815    fn redundant_capabilities_merge_actions() {
816        let caps = Capabilities::builder()
817            .read("/pub/example.com/")
818            .write("/pub/example.com/")
819            .finish();
820
821        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
822    }
823
824    #[test]
825    fn capabilities_len_and_is_empty() {
826        let empty = Capabilities::builder().finish();
827        assert!(empty.is_empty());
828        assert_eq!(empty.len(), 0);
829
830        let one = Capabilities::builder().read("/").finish();
831        assert!(!one.is_empty());
832        assert_eq!(one.len(), 1);
833    }
834
835    // Requires dev-dependency: serde_json
836    #[test]
837    fn serde_roundtrip_as_string() {
838        let caps = Capabilities::builder()
839            .read_write("/pub/my-cool-app/")
840            .read("/pub/file.txt")
841            .finish();
842
843        let json = serde_json::to_string(&caps).unwrap();
844        // Serialized as a single string:
845        assert_eq!(json, "\"/pub/my-cool-app/:rw,/pub/file.txt:r\"");
846
847        let back: Capabilities = serde_json::from_str(&json).unwrap();
848        assert_eq!(back, caps);
849    }
850}