1use serde::{Deserialize, Serialize};
40use std::{collections::BTreeSet, fmt::Display, str::FromStr};
41use url::Url;
42
43use crate::{StoragePath, StoragePathError};
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Capability {
50 scope: StoragePath,
52 actions: Vec<Action>,
54}
55
56impl Capability {
57 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 #[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 #[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 #[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 pub fn scope(&self) -> &StoragePath {
116 &self.scope
117 }
118
119 pub fn actions(&self) -> &[Action] {
121 &self.actions
122 }
123
124 pub fn is_root(&self) -> bool {
126 *self == Self::root()
127 }
128
129 pub fn scope_covers_path(&self, path: &StoragePath) -> bool {
142 if self.scope == *path {
143 return true;
144 }
145 self.scope.is_directory() && path.as_str().starts_with(self.scope.as_str())
148 }
149
150 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#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
168pub enum Action {
169 Read,
171 Write,
173 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 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#[derive(thiserror::Error, Debug, PartialEq, Eq)]
290pub enum CapabilityParseError {
291 #[error("invalid capability scope: {0}")]
293 InvalidScope(#[source] StoragePathError),
294 #[error("capability scope contains reserved delimiter `{0}`")]
296 InvalidScopeDelimiter(char),
297 #[error("capability must have format `<scope>:<actions>`")]
299 InvalidFormat,
300 #[error("capability must contain at least one action")]
302 MissingActions,
303 #[error("invalid capability action `{0}`")]
305 InvalidAction(char),
306}
307
308pub type Error = CapabilityParseError;
310
311#[derive(thiserror::Error, Debug, PartialEq, Eq)]
313#[error("invalid capability at position {position} (`{entry}`): {source}")]
314pub struct CapabilitiesParseError {
315 pub position: usize,
317 pub entry: String,
319 #[source]
321 pub source: CapabilityParseError,
322}
323
324#[derive(Clone, Default, Debug, PartialEq, Eq)]
335#[must_use]
336pub struct Capabilities(Vec<Capability>);
337
338impl Capabilities {
339 pub fn normalize(self) -> Self {
357 Self(normalize(self.0))
358 }
359
360 pub fn contains(&self, capability: &Capability) -> bool {
362 self.0.contains(capability)
363 }
364
365 pub fn is_empty(&self) -> bool {
367 self.0.is_empty()
368 }
369
370 pub fn len(&self) -> usize {
372 self.0.len()
373 }
374
375 pub fn iter(&self) -> std::slice::Iter<'_, Capability> {
377 self.0.iter()
378 }
379
380 pub fn builder() -> CapsBuilder {
388 CapsBuilder::default()
389 }
390
391 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 #[inline]
429 pub fn as_slice(&self) -> &[Capability] {
430 &self.0
431 }
432
433 pub fn to_vec(&self) -> Vec<Capability> {
435 self.0.clone()
436 }
437}
438
439#[derive(Default, Debug)]
444pub struct CapsBuilder {
445 caps: Vec<Capability>,
446}
447
448impl CapsBuilder {
449 pub fn new() -> Self {
451 Self::default()
452 }
453
454 pub fn cap(mut self, cap: Capability) -> Self {
456 self.caps.push(cap);
457 self
458 }
459
460 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 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 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 pub fn extend<I: IntoIterator<Item = Capability>>(mut self, iter: I) -> Self {
480 self.caps.extend(iter);
481 self
482 }
483
484 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
566fn 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 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/") .unwrap()
646 .write("/pub/uploads/") .unwrap()
648 .read_write("/pub/my-cool-app/data/") .unwrap()
650 .finish();
651
652 assert_eq!(
654 caps.to_string(),
655 "/pub/my-cool-app/:r,/pub/uploads/:w,/pub/my-cool-app/data/:rw"
656 );
657
658 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 let parsed = "/:rw,/pub/my-cool-app/:r"
704 .parse::<Capabilities>()
705 .unwrap()
706 .normalize();
707 let built = Capabilities::builder()
708 .read_write("/") .unwrap()
710 .read("/pub/my-cool-app/") .unwrap()
712 .finish();
713
714 assert_eq!(parsed, built);
715 }
716
717 #[test]
718 fn parse_errors_are_informative() {
719 let error = "not/abs:rw".parse::<Capability>().unwrap_err();
721 assert_eq!(
722 error,
723 CapabilityParseError::InvalidScope(StoragePathError::NotAbsolute)
724 );
725
726 let error = "/pub/my.app".parse::<Capability>().unwrap_err();
728 assert_eq!(error, CapabilityParseError::InvalidFormat);
729
730 let error = "/pub/my.app:".parse::<Capability>().unwrap_err();
732 assert_eq!(error, CapabilityParseError::MissingActions);
733
734 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 #[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 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 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 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 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 assert!(!dir("/pub/app").scope_covers_path(&path("/pub/app/inside")));
913 }
914
915 #[test]
916 fn file_scope_rejects_prefix_attack() {
917 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}