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