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` contains at least one action letter, currently:
9//!   - `r` => read (GET)
10//!   - `w` => write (PUT/POST/DELETE)
11//!   - `e` => receive scoped content keys (encryption and decryption)
12//!
13//! `e` does not grant storage access. Grant flows must request `af=v1`
14//! to receive keys; `r` and `w` alone do not deliver keys.
15//!
16//! Examples:
17//!
18//! - Read+write everything: `"/:rw"`
19//! - Read-only a file: `"/pub/foo.txt:r"`
20//! - Read-write a directory: `"/pub/my-cool-app/:rw"`
21//!
22//! Multiple capabilities are serialized as a comma-separated list,
23//! e.g. `"/pub/my-cool-app/:rw,/pub/foo.txt:r"`.
24//!
25//! ## Construction
26//!
27//! ```rust
28//! use pubky_common::capabilities::{Capability, Capabilities};
29//!
30//! let cap = Capability::read_write("/pub/my-cool-app/").unwrap();
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//!     .unwrap()
37//!     .read("/pub/foo.txt")
38//!     .unwrap()
39//!     .finish();
40//! assert_eq!(caps.to_string(), "/pub/my-cool-app/:rw,/pub/foo.txt:r");
41//! ```
42
43use serde::{Deserialize, Serialize};
44use std::{collections::BTreeSet, fmt::Display, str::FromStr};
45use url::Url;
46
47use crate::{StoragePath, StoragePathError};
48
49/// A single capability: a `scope` and the allowed `actions` within it.
50///
51/// The wire/string representation is `"<scope>:<actions>"`, see module docs.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Capability {
54    /// Canonical scope of resources, such as a directory or file.
55    scope: StoragePath,
56    /// Allowed actions within `scope`. Serialized as a compact action string (e.g. `"rw"`).
57    actions: Vec<Action>,
58}
59
60impl Capability {
61    /// Shorthand for a root capability at `/` with read+write.
62    ///
63    /// Equivalent to a capability with scope `/` and both read and write actions.
64    ///
65    /// ```
66    /// use pubky_common::capabilities::Capability;
67    /// assert_eq!(Capability::root().to_string(), "/:rw");
68    /// ```
69    pub fn root() -> Self {
70        Capability {
71            scope: StoragePath::root(),
72            actions: vec![Action::Read, Action::Write],
73        }
74    }
75
76    // ---- Shortcut constructors
77
78    /// Construct a read-only capability for `scope`.
79    ///
80    /// ```
81    /// use pubky_common::capabilities::Capability;
82    /// assert_eq!(Capability::read("/pub/my.app").unwrap().to_string(), "/pub/my.app:r");
83    /// ```
84    #[inline]
85    pub fn read(scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
86        Self::with_actions(scope.as_ref(), vec![Action::Read])
87    }
88
89    /// Construct a write-only capability for `scope`.
90    ///
91    /// ```
92    /// use pubky_common::capabilities::Capability;
93    /// assert_eq!(Capability::write("/pub/tmp").unwrap().to_string(), "/pub/tmp:w");
94    /// ```
95    #[inline]
96    pub fn write(scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
97        Self::with_actions(scope.as_ref(), vec![Action::Write])
98    }
99
100    /// Construct a read+write capability for `scope`.
101    ///
102    /// ```
103    /// use pubky_common::capabilities::Capability;
104    /// assert_eq!(Capability::read_write("/").unwrap().to_string(), "/:rw");
105    /// ```
106    #[inline]
107    pub fn read_write(scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
108        Self::with_actions(scope.as_ref(), vec![Action::Read, Action::Write])
109    }
110
111    /// Request scoped content keys without granting storage read or write access.
112    /// Symmetric keys permit both encryption and decryption. Requires approval V1.
113    pub fn encryption_keys(scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
114        Self::with_actions(scope.as_ref(), vec![Action::EncryptionKeys])
115    }
116
117    fn with_actions(scope: &str, actions: Vec<Action>) -> Result<Self, CapabilityParseError> {
118        Ok(Self {
119            scope: parse_scope(scope)?,
120            actions,
121        })
122    }
123
124    /// Return the resource scope covered by this capability.
125    pub fn scope(&self) -> &StoragePath {
126        &self.scope
127    }
128
129    /// Whether this capability grants scoped encryption and decryption keys.
130    /// Read and write permissions alone do not grant keys.
131    #[must_use]
132    pub fn grants_encryption_keys(&self) -> bool {
133        self.actions.contains(&Action::EncryptionKeys)
134    }
135
136    /// Return the actions allowed by this capability.
137    pub fn actions(&self) -> &[Action] {
138        &self.actions
139    }
140
141    /// Whether this grants root storage read/write access (`/:rw` or `/:rwe`).
142    pub fn is_root(&self) -> bool {
143        self.scope.is_root()
144            && self.actions.contains(&Action::Read)
145            && self.actions.contains(&Action::Write)
146    }
147
148    /// Whether this capability's scope covers the given path.
149    ///
150    /// The trailing `/` on a scope is significant — it distinguishes a
151    /// *directory* scope from a *file* scope:
152    ///
153    /// - **Directory scope** (ends in `/`): covers the directory itself and
154    ///   any path inside it. `/pub/app/` covers `/pub/app/`, `/pub/app/foo`,
155    ///   and `/pub/app/sub/bar`, but NOT `/pub/app` or `/pub/app-evil/foo`.
156    /// - **File scope** (no trailing `/`): covers only the exact path.
157    ///   `/pub/app` covers `/pub/app` and nothing else — not `/pub/app/foo`
158    ///   (that's inside the *directory* `/pub/app/`, a different resource)
159    ///   and not `/pub/app-evil` (no prefix-as-string matching).
160    pub fn scope_covers_path(&self, path: &StoragePath) -> bool {
161        self.scope.covers_path(path)
162    }
163
164    /// Whether this capability fully covers `other` — i.e. the scope is equal or
165    /// broader, and every action in `other` is also present in `self`.
166    fn covers(&self, other: &Capability) -> bool {
167        if !self.scope_covers_path(other.scope()) {
168            return false;
169        }
170
171        other
172            .actions
173            .iter()
174            .all(|action| self.actions.contains(action))
175    }
176}
177
178/// Actions allowed on a given scope.
179///
180/// Display/serialization encodes these as single characters (`r`, `w`, `e`).
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    /// Receive scoped content keys for encryption and decryption, without storage access.
188    EncryptionKeys,
189    /// Unknown ability
190    Unknown(char),
191}
192
193impl From<&Action> for char {
194    fn from(value: &Action) -> Self {
195        match value {
196            Action::Read => 'r',
197            Action::Write => 'w',
198            Action::EncryptionKeys => 'e',
199            Action::Unknown(character) => *character,
200        }
201    }
202}
203
204impl TryFrom<char> for Action {
205    type Error = CapabilityParseError;
206
207    fn try_from(value: char) -> Result<Self, Self::Error> {
208        match value {
209            'r' => Ok(Self::Read),
210            'w' => Ok(Self::Write),
211            'e' => Ok(Self::EncryptionKeys),
212            _ => Err(CapabilityParseError::InvalidAction(value)),
213        }
214    }
215}
216
217impl Display for Capability {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(f, "{}:", self.scope)?;
220        for action in &self.actions {
221            write!(f, "{}", char::from(action))?;
222        }
223        Ok(())
224    }
225}
226
227impl TryFrom<String> for Capability {
228    type Error = CapabilityParseError;
229
230    fn try_from(value: String) -> Result<Self, Self::Error> {
231        value.parse()
232    }
233}
234
235impl FromStr for Capability {
236    type Err = CapabilityParseError;
237
238    /// Parse `"<scope>:<actions>"`.
239    ///
240    /// ```
241    /// use pubky_common::capabilities::Capability;
242    /// let capability: Capability = "/pub/my-cool-app/:rw".parse().unwrap();
243    /// assert_eq!(capability.to_string(), "/pub/my-cool-app/:rw");
244    /// ```
245    fn from_str(value: &str) -> Result<Self, Self::Err> {
246        let (scope, actions_str) = value
247            .split_once(':')
248            .ok_or(CapabilityParseError::InvalidFormat)?;
249
250        if actions_str.contains(':') {
251            return Err(CapabilityParseError::InvalidFormat);
252        }
253
254        if actions_str.is_empty() {
255            return Err(CapabilityParseError::MissingActions);
256        }
257
258        let mut actions = Vec::new();
259
260        for character in actions_str.chars() {
261            let action = Action::try_from(character)?;
262
263            if let Err(index) = actions.binary_search(&action) {
264                actions.insert(index, action);
265            }
266        }
267
268        Ok(Self {
269            scope: parse_scope(scope)?,
270            actions,
271        })
272    }
273}
274
275impl TryFrom<&str> for Capability {
276    type Error = CapabilityParseError;
277
278    fn try_from(value: &str) -> Result<Self, Self::Error> {
279        value.parse()
280    }
281}
282
283impl Serialize for Capability {
284    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
285    where
286        S: serde::Serializer,
287    {
288        let string = self.to_string();
289
290        string.serialize(serializer)
291    }
292}
293
294impl<'de> Deserialize<'de> for Capability {
295    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
296    where
297        D: serde::Deserializer<'de>,
298    {
299        let string: String = Deserialize::deserialize(deserializer)?;
300
301        string.parse().map_err(serde::de::Error::custom)
302    }
303}
304
305/// Error parsing a [Capability].
306#[derive(thiserror::Error, Debug, PartialEq, Eq)]
307pub enum CapabilityParseError {
308    /// The scope is not a canonical WebDAV path.
309    #[error("invalid capability scope: {0}")]
310    InvalidScope(#[source] StoragePathError),
311    /// The scope contains a capability wire-format delimiter.
312    #[error("capability scope contains reserved delimiter `{0}`")]
313    InvalidScopeDelimiter(char),
314    /// The capability does not follow the `<scope>:<actions>` format.
315    #[error("capability must have format `<scope>:<actions>`")]
316    InvalidFormat,
317    /// No actions were provided.
318    #[error("capability must contain at least one action")]
319    MissingActions,
320    /// The action is not supported.
321    #[error("invalid capability action `{0}`")]
322    InvalidAction(char),
323}
324
325/// Backwards-compatible name for [`CapabilityParseError`].
326pub type Error = CapabilityParseError;
327
328/// Error parsing a comma-separated [`Capabilities`] list.
329#[derive(thiserror::Error, Debug, PartialEq, Eq)]
330#[error("invalid capability at position {position} (`{entry}`): {source}")]
331pub struct CapabilitiesParseError {
332    /// One-based position of the invalid entry.
333    pub position: usize,
334    /// The exact invalid entry.
335    pub entry: String,
336    /// The reason the entry is invalid.
337    #[source]
338    pub source: CapabilityParseError,
339}
340
341/// A wrapper around `Vec<Capability>` that controls how capabilities are
342/// serialized and built.
343///
344/// Serialization is a single comma-separated string (e.g. `"/:rw,/pub/my-cool-app/:r"`),
345/// which is convenient for logs, URLs, or compact text payloads. It also comes
346/// with a fluent builder (`Capabilities::builder()`).
347///
348/// Note: this does **not** remove length prefixes in binary encodings; if you
349/// need a varint-free trailing field in a custom binary format, implement a
350/// bespoke encoder/decoder instead of serde.
351#[derive(Clone, Default, Debug, PartialEq, Eq)]
352#[must_use]
353pub struct Capabilities(Vec<Capability>);
354
355impl Capabilities {
356    /// Return a normalized capability list.
357    ///
358    /// Normalization merges duplicate scopes, de-duplicates and sorts actions,
359    /// and removes capabilities already covered by broader capabilities.
360    ///
361    /// # Examples
362    /// ```
363    /// use pubky_common::capabilities::{Capability, Capabilities};
364    ///
365    /// let caps = Capabilities::from(vec![
366    ///     Capability::read("/pub/").unwrap(),
367    ///     Capability::write("/pub/").unwrap(),
368    ///     Capability::read("/pub/file.txt").unwrap(),
369    /// ]);
370    ///
371    /// assert_eq!(caps.normalize().to_string(), "/pub/:rw");
372    /// ```
373    pub fn normalize(self) -> Self {
374        Self(normalize(self.0))
375    }
376
377    /// Returns true if the list contains `capability`.
378    pub fn contains(&self, capability: &Capability) -> bool {
379        self.0.contains(capability)
380    }
381
382    /// Returns `true` if the list is empty.
383    pub fn is_empty(&self) -> bool {
384        self.0.is_empty()
385    }
386
387    /// Returns the number of entries.
388    pub fn len(&self) -> usize {
389        self.0.len()
390    }
391
392    /// Returns an iterator over the slice of [Capability].
393    pub fn iter(&self) -> std::slice::Iter<'_, Capability> {
394        self.0.iter()
395    }
396
397    /// Start a fluent builder for multiple capabilities.
398    ///
399    /// ```
400    /// use pubky_common::capabilities::Capabilities;
401    /// let caps = Capabilities::builder().read_write("/").unwrap().finish();
402    /// assert_eq!(caps.to_string(), "/:rw");
403    /// ```
404    pub fn builder() -> CapsBuilder {
405        CapsBuilder::default()
406    }
407
408    /// Parse capabilities from the `caps` query parameter of `url`.
409    ///
410    /// Expects a comma-separated list of capability strings, e.g.:
411    /// `?caps=/pub/my-cool-app/:rw,/foo:r`
412    ///
413    /// # Examples
414    /// ```
415    /// # use url::Url;
416    /// # use pubky_common::capabilities::Capabilities;
417    /// let url = Url::parse("https://example/app?caps=/pub/my-cool-app/:rw,/foo:r").unwrap();
418    /// let caps = Capabilities::try_from_caps_url(&url).unwrap();
419    /// assert!(!caps.is_empty());
420    /// ```
421    pub fn try_from_caps_url(url: &Url) -> Result<Self, CapabilitiesParseError> {
422        let value = url
423            .query_pairs()
424            .find_map(|(k, v)| (k == "caps").then(|| v.to_string()))
425            .unwrap_or_default();
426
427        value.parse()
428    }
429
430    /// Borrow the inner capabilities as a slice without allocating.
431    ///
432    /// Constant-time; returns a view into the existing buffer.
433    ///
434    /// # Examples
435    /// ```
436    /// use pubky_common::capabilities::{Capability, Capabilities};
437    ///
438    /// let caps = Capabilities::from(vec![
439    ///     Capability::read("/foo").unwrap(),
440    ///     Capability::write("/bar/").unwrap(),
441    /// ]);
442    /// let slice: &[Capability] = caps.as_slice();
443    /// assert_eq!(slice.len(), 2);
444    /// ```
445    #[inline]
446    pub fn as_slice(&self) -> &[Capability] {
447        &self.0
448    }
449
450    /// Clone the inner capability list.
451    pub fn to_vec(&self) -> Vec<Capability> {
452        self.0.clone()
453    }
454}
455
456/// Fluent builder for multiple [`Capability`] entries.
457///
458/// Build with high-level helpers (`.read()/.write()/.read_write()`), or push prebuilt
459/// capabilities with `.cap()`.
460#[derive(Default, Debug)]
461pub struct CapsBuilder {
462    caps: Vec<Capability>,
463}
464
465impl CapsBuilder {
466    /// Create a new empty builder.
467    pub fn new() -> Self {
468        Self::default()
469    }
470
471    /// Push a prebuilt capability
472    pub fn cap(mut self, cap: Capability) -> Self {
473        self.caps.push(cap);
474        self
475    }
476
477    /// Add a read-only capability for `scope`.
478    pub fn read(mut self, scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
479        self.caps.push(Capability::read(scope)?);
480        Ok(self)
481    }
482
483    /// Add a write-only capability for `scope`.
484    pub fn write(mut self, scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
485        self.caps.push(Capability::write(scope)?);
486        Ok(self)
487    }
488
489    /// Add a read+write capability for `scope`.
490    pub fn read_write(mut self, scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
491        self.caps.push(Capability::read_write(scope)?);
492        Ok(self)
493    }
494
495    /// Request scoped content keys without storage access. Requires approval V1.
496    pub fn encryption_keys(mut self, scope: impl AsRef<str>) -> Result<Self, CapabilityParseError> {
497        self.caps.push(Capability::encryption_keys(scope)?);
498        Ok(self)
499    }
500
501    /// Extend with an iterator of capabilities.
502    pub fn extend<I: IntoIterator<Item = Capability>>(mut self, iter: I) -> Self {
503        self.caps.extend(iter);
504        self
505    }
506
507    /// Finalize and produce the normalized [`Capabilities`] list.
508    pub fn finish(self) -> Capabilities {
509        Capabilities::from(self.caps).normalize()
510    }
511}
512
513impl From<Vec<Capability>> for Capabilities {
514    fn from(value: Vec<Capability>) -> Self {
515        Self(value)
516    }
517}
518
519impl From<Capabilities> for Vec<Capability> {
520    fn from(value: Capabilities) -> Self {
521        value.0
522    }
523}
524
525impl TryFrom<&str> for Capabilities {
526    type Error = CapabilitiesParseError;
527
528    fn try_from(value: &str) -> Result<Self, Self::Error> {
529        value.parse()
530    }
531}
532
533impl FromStr for Capabilities {
534    type Err = CapabilitiesParseError;
535
536    fn from_str(value: &str) -> Result<Self, Self::Err> {
537        if value.is_empty() {
538            return Ok(Self::default());
539        }
540
541        value
542            .split(',')
543            .enumerate()
544            .map(|(index, entry)| {
545                entry.parse().map_err(|source| CapabilitiesParseError {
546                    position: index + 1,
547                    entry: entry.to_string(),
548                    source,
549                })
550            })
551            .collect::<Result<Vec<_>, _>>()
552            .map(Self::from)
553    }
554}
555
556impl Display for Capabilities {
557    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558        let string = self
559            .0
560            .iter()
561            .map(|c| c.to_string())
562            .collect::<Vec<_>>()
563            .join(",");
564
565        write!(f, "{string}")
566    }
567}
568
569impl Serialize for Capabilities {
570    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
571    where
572        S: serde::Serializer,
573    {
574        self.to_string().serialize(serializer)
575    }
576}
577
578impl<'de> Deserialize<'de> for Capabilities {
579    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
580    where
581        D: serde::Deserializer<'de>,
582    {
583        let string: String = Deserialize::deserialize(deserializer)?;
584
585        string.parse().map_err(serde::de::Error::custom)
586    }
587}
588
589// --- helpers ---
590
591fn parse_scope(scope: &str) -> Result<StoragePath, CapabilityParseError> {
592    for delimiter in [':', ','] {
593        if scope.contains(delimiter) {
594            return Err(CapabilityParseError::InvalidScopeDelimiter(delimiter));
595        }
596    }
597
598    StoragePath::new(scope).map_err(CapabilityParseError::InvalidScope)
599}
600
601fn normalize(caps: Vec<Capability>) -> Vec<Capability> {
602    let mut merged: Vec<Capability> = Vec::new();
603
604    for mut cap in caps {
605        if let Some(existing) = merged
606            .iter_mut()
607            .find(|existing| existing.scope == cap.scope)
608        {
609            let actions: BTreeSet<Action> = existing
610                .actions
611                .iter()
612                .copied()
613                .chain(cap.actions.iter().copied())
614                .collect();
615            existing.actions = actions.into_iter().collect();
616            continue;
617        }
618
619        let actions: BTreeSet<Action> = cap.actions.iter().copied().collect();
620        cap.actions = actions.into_iter().collect();
621        merged.push(cap);
622    }
623
624    let mut sanitized: Vec<Capability> = Vec::new();
625
626    for cap in merged {
627        if sanitized.iter().any(|existing| existing.covers(&cap)) {
628            continue;
629        }
630
631        sanitized.retain(|existing| !cap.covers(existing));
632        sanitized.push(cap);
633    }
634
635    sanitized
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use url::Url;
642
643    #[test]
644    fn encryption_permission_round_trips_and_does_not_imply_storage_access() {
645        for actions in ["e", "re", "we", "rwe"] {
646            let wire = format!("/pub/chat/:{actions}");
647            let cap: Capability = wire.parse().unwrap();
648            assert_eq!(cap.to_string(), wire);
649            assert!(cap.grants_encryption_keys());
650        }
651        for actions in ["r", "w", "rw"] {
652            let cap: Capability = format!("/pub/chat/:{actions}").parse().unwrap();
653            assert!(!cap.grants_encryption_keys());
654        }
655        let key_only = Capability::encryption_keys("/").unwrap();
656        assert_eq!(key_only.actions(), &[Action::EncryptionKeys]);
657        assert!(!key_only.is_root());
658        assert!("/:rwe".parse::<Capability>().unwrap().is_root());
659
660        let mixed = Capabilities::builder()
661            .read_write("/")
662            .unwrap()
663            .encryption_keys("/pub/chat/")
664            .unwrap()
665            .finish();
666        assert_eq!(mixed.to_string(), "/:rw,/pub/chat/:e");
667        assert!(!Capability::root().covers(&key_only));
668    }
669
670    #[test]
671    fn root_capability_helper() {
672        let cap = Capability::root();
673        assert_eq!(cap.scope().as_str(), "/");
674        assert_eq!(cap.actions, vec![Action::Read, Action::Write]);
675        assert_eq!(cap.to_string(), "/:rw");
676        // And it round-trips through the string form:
677        assert_eq!("/:rw".parse(), Ok(cap));
678    }
679
680    #[test]
681    fn single_capability_constructors() {
682        let cap_rw = Capability::read_write("/pub/my-cool-app/").unwrap();
683        let cap_r = Capability::read("/pub/file.txt").unwrap();
684        let cap_w = Capability::write("/pub/uploads/").unwrap();
685
686        assert_eq!(cap_rw.to_string(), "/pub/my-cool-app/:rw");
687        assert_eq!(cap_r.to_string(), "/pub/file.txt:r");
688        assert_eq!(cap_w.to_string(), "/pub/uploads/:w");
689    }
690
691    #[test]
692    fn multiple_caps_with_capsbuilder() {
693        let caps = Capabilities::builder()
694            .read("/pub/my-cool-app/") // "/pub/my-cool-app/:r"
695            .unwrap()
696            .write("/pub/uploads/") // "/pub/uploads/:w"
697            .unwrap()
698            .read_write("/pub/my-cool-app/data/") // "/pub/my-cool-app/data/:rw"
699            .unwrap()
700            .finish();
701
702        // String form is comma-separated, in insertion order:
703        assert_eq!(
704            caps.to_string(),
705            "/pub/my-cool-app/:r,/pub/uploads/:w,/pub/my-cool-app/data/:rw"
706        );
707
708        // Contains checks:
709        assert!(caps.contains(&Capability::read("/pub/my-cool-app/").unwrap()));
710        assert!(caps.contains(&Capability::write("/pub/uploads/").unwrap()));
711        assert!(caps.contains(&Capability::read_write("/pub/my-cool-app/data/").unwrap()));
712        assert!(!caps.contains(&Capability::write("/nope").unwrap()));
713    }
714
715    #[test]
716    fn action_dedup_and_order_are_stable() {
717        let cap = "/:wrrw".parse::<Capability>().unwrap();
718        assert_eq!(cap.actions(), &[Action::Read, Action::Write]);
719        assert_eq!(cap.to_string(), "/:rw");
720    }
721
722    #[test]
723    fn constructor_wraps_storage_path_errors() {
724        assert_eq!(
725            Capability::read("/pub//my.app").unwrap_err(),
726            CapabilityParseError::InvalidScope(StoragePathError::EmptySegment)
727        );
728        assert_eq!(
729            Capability::read("/priv/report ").unwrap_err(),
730            CapabilityParseError::InvalidScope(StoragePathError::TrailingWhitespace)
731        );
732        assert_eq!(
733            Capability::read("/priv/app\\..\\secret").unwrap_err(),
734            CapabilityParseError::InvalidScope(StoragePathError::Backslash)
735        );
736    }
737
738    #[test]
739    fn capability_scope_rejects_wire_delimiters() {
740        assert_eq!(
741            Capability::read("/pub/a:b").unwrap_err(),
742            CapabilityParseError::InvalidScopeDelimiter(':')
743        );
744        assert_eq!(
745            Capability::read("/pub/a,b").unwrap_err(),
746            CapabilityParseError::InvalidScopeDelimiter(',')
747        );
748    }
749
750    #[test]
751    fn parse_from_string_list() {
752        // From a comma-separated string:
753        let parsed = "/:rw,/pub/my-cool-app/:r"
754            .parse::<Capabilities>()
755            .unwrap()
756            .normalize();
757        let built = Capabilities::builder()
758            .read_write("/") // "/:rw"
759            .unwrap()
760            .read("/pub/my-cool-app/") // "/pub/my-cool-app/:r"
761            .unwrap()
762            .finish();
763
764        assert_eq!(parsed, built);
765    }
766
767    #[test]
768    fn parse_errors_are_informative() {
769        // Invalid scope (doesn't start with '/'):
770        let error = "not/abs:rw".parse::<Capability>().unwrap_err();
771        assert_eq!(
772            error,
773            CapabilityParseError::InvalidScope(StoragePathError::NotAbsolute)
774        );
775
776        // Invalid format (missing ':'):
777        let error = "/pub/my.app".parse::<Capability>().unwrap_err();
778        assert_eq!(error, CapabilityParseError::InvalidFormat);
779
780        // Missing actions:
781        let error = "/pub/my.app:".parse::<Capability>().unwrap_err();
782        assert_eq!(error, CapabilityParseError::MissingActions);
783
784        // Invalid action:
785        let error = "/pub/my.app:rx".parse::<Capability>().unwrap_err();
786        assert_eq!(error, CapabilityParseError::InvalidAction('x'));
787    }
788
789    #[test]
790    fn capabilities_reports_invalid_entry() {
791        let error = "/pub/app/:w,missing-leading-slash:r,/priv/file.txt:x"
792            .parse::<Capabilities>()
793            .unwrap_err();
794
795        assert_eq!(error.position, 2);
796        assert_eq!(error.entry, "missing-leading-slash:r");
797        assert_eq!(
798            error.source,
799            CapabilityParseError::InvalidScope(StoragePathError::NotAbsolute)
800        );
801        assert_eq!(
802            error.to_string(),
803            "invalid capability at position 2 (`missing-leading-slash:r`): invalid capability scope: path must be absolute"
804        );
805    }
806
807    #[test]
808    fn capabilities_rejects_empty_entries() {
809        for input in [",/:r", "/:r,", "/:r,,/:w"] {
810            assert!(input.parse::<Capabilities>().is_err(), "accepted {input}");
811        }
812    }
813
814    #[test]
815    fn capabilities_accepts_empty_list() {
816        assert_eq!("".parse::<Capabilities>(), Ok(Capabilities::default()));
817    }
818
819    #[test]
820    fn caps_builder_finish_normalizes() {
821        let caps = Capabilities::builder()
822            .read("/pub/example.com/")
823            .unwrap()
824            .write("/pub/example.com/")
825            .unwrap()
826            .finish();
827
828        assert_eq!(caps.to_string(), "/pub/example.com/:rw");
829    }
830
831    #[test]
832    fn capabilities_from_url_parses_caps_parameter() {
833        let url = Url::parse(
834            "https://example.test?caps=/pub/example.com/:rw,/pub/example.com/documents:w",
835        )
836        .unwrap();
837        let caps = Capabilities::try_from_caps_url(&url).unwrap();
838
839        assert_eq!(
840            caps.to_string(),
841            "/pub/example.com/:rw,/pub/example.com/documents:w"
842        );
843    }
844
845    #[test]
846    fn capabilities_from_url_rejects_invalid_entry() {
847        let url = Url::parse("https://example.test?caps=/:r,invalid:w").unwrap();
848        let error = Capabilities::try_from_caps_url(&url).unwrap_err();
849
850        assert_eq!(error.position, 2);
851        assert_eq!(error.entry, "invalid:w");
852    }
853
854    #[test]
855    fn normalization_merges_actions_and_removes_covered_scopes() {
856        let caps = Capabilities::from(vec![
857            Capability::read("/pub/example.com/").unwrap(),
858            Capability::write("/pub/example.com/").unwrap(),
859            Capability::write("/pub/example.com/subfolder").unwrap(),
860            Capability::read("/priv/other").unwrap(),
861        ])
862        .normalize();
863
864        assert_eq!(caps.to_string(), "/pub/example.com/:rw,/priv/other:r");
865    }
866
867    #[test]
868    fn capabilities_len_and_is_empty() {
869        let empty = Capabilities::builder().finish();
870        assert!(empty.is_empty());
871        assert_eq!(empty.len(), 0);
872
873        let one = Capabilities::builder().read("/").unwrap().finish();
874        assert!(!one.is_empty());
875        assert_eq!(one.len(), 1);
876    }
877
878    // Requires dev-dependency: serde_json
879    #[test]
880    fn serde_roundtrip_as_string() {
881        let caps = Capabilities::builder()
882            .read_write("/pub/my-cool-app/")
883            .unwrap()
884            .read("/pub/file.txt")
885            .unwrap()
886            .finish();
887
888        let json = serde_json::to_string(&caps).unwrap();
889        // Serialized as a single string:
890        assert_eq!(json, "\"/pub/my-cool-app/:rw,/pub/file.txt:r\"");
891
892        let back: Capabilities = serde_json::from_str(&json).unwrap();
893        assert_eq!(back, caps);
894    }
895
896    #[test]
897    fn serde_rejects_invalid_capability_entry() {
898        let error = serde_json::from_str::<Capabilities>(r#""/:r,invalid:w""#).unwrap_err();
899
900        assert!(error.to_string().contains("invalid:w"));
901    }
902
903    // --- scope_covers_path: trailing slash semantics ---
904    //
905    // The trailing `/` on a scope is significant. A directory scope
906    // (`/pub/app/`) covers itself and any path inside it. A file scope
907    // (`/pub/app`) covers only the exact path — never descendants and never
908    // string-prefix neighbours like `/pub/app-evil`. Regression coverage
909    // for the e2e auth tests, which grant `/pub/pubky.app/:rw` and require
910    // `PUT /pub/pubky.app` to be denied.
911
912    fn dir(scope: &str) -> Capability {
913        Capability::write(scope).unwrap()
914    }
915
916    fn path(value: &str) -> StoragePath {
917        StoragePath::new(value).unwrap()
918    }
919
920    #[test]
921    fn directory_scope_covers_itself() {
922        assert!(dir("/pub/app/").scope_covers_path(&path("/pub/app/")));
923    }
924
925    #[test]
926    fn directory_scope_covers_descendants() {
927        assert!(dir("/pub/app/").scope_covers_path(&path("/pub/app/foo")));
928        assert!(dir("/pub/app/").scope_covers_path(&path("/pub/app/sub/bar.txt")));
929    }
930
931    #[test]
932    fn directory_scope_does_not_cover_parent_path_without_trailing_slash() {
933        // Regression: `/pub/app/` (the directory) is a different resource
934        // from `/pub/app` (a file at the parent level). The e2e auth tests
935        // grant `/pub/pubky.app/:rw` and expect `PUT /pub/pubky.app` to 403.
936        assert!(!dir("/pub/app/").scope_covers_path(&path("/pub/app")));
937        assert!(!dir("/pub/pubky.app/").scope_covers_path(&path("/pub/pubky.app")));
938    }
939
940    #[test]
941    fn directory_scope_does_not_cover_sibling() {
942        assert!(!dir("/pub/app/").scope_covers_path(&path("/pub/other/file")));
943    }
944
945    #[test]
946    fn directory_scope_does_not_cover_string_prefix_sibling() {
947        // Even with a directory scope, a string-prefix sibling like
948        // `/pub/app-evil/...` is not inside `/pub/app/`.
949        assert!(!dir("/pub/app/").scope_covers_path(&path("/pub/app-evil/file")));
950    }
951
952    #[test]
953    fn file_scope_covers_only_exact_path() {
954        assert!(dir("/pub/file.txt").scope_covers_path(&path("/pub/file.txt")));
955    }
956
957    #[test]
958    fn file_scope_does_not_cover_descendants() {
959        // A file scope is not a namespace prefix — granting `/pub/app:rw`
960        // does not grant access to `/pub/app/inside`. To grant the directory,
961        // use `/pub/app/`.
962        assert!(!dir("/pub/app").scope_covers_path(&path("/pub/app/inside")));
963    }
964
965    #[test]
966    fn file_scope_rejects_prefix_attack() {
967        // The original motivation for moving away from `path.starts_with(scope)`.
968        assert!(!dir("/pub/app").scope_covers_path(&path("/pub/app-evil/file")));
969        assert!(!dir("/pub/app").scope_covers_path(&path("/pub/appended")));
970    }
971
972    #[test]
973    fn root_scope_covers_any_path() {
974        let root = Capability::root();
975        assert!(root.scope_covers_path(&StoragePath::root()));
976        assert!(root.scope_covers_path(&path("/pub/anything")));
977        assert!(root.scope_covers_path(&path("/dav/some/file.txt")));
978    }
979}