1use serde::{Deserialize, Serialize};
44use std::{collections::BTreeSet, fmt::Display, str::FromStr};
45use url::Url;
46
47use crate::{StoragePath, StoragePathError};
48
49#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Capability {
54 scope: StoragePath,
56 actions: Vec<Action>,
58}
59
60impl Capability {
61 pub fn root() -> Self {
70 Capability {
71 scope: StoragePath::root(),
72 actions: vec![Action::Read, Action::Write],
73 }
74 }
75
76 #[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 #[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 #[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 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 pub fn scope(&self) -> &StoragePath {
126 &self.scope
127 }
128
129 #[must_use]
132 pub fn grants_encryption_keys(&self) -> bool {
133 self.actions.contains(&Action::EncryptionKeys)
134 }
135
136 pub fn actions(&self) -> &[Action] {
138 &self.actions
139 }
140
141 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 pub fn scope_covers_path(&self, path: &StoragePath) -> bool {
161 self.scope.covers_path(path)
162 }
163
164 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#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
182pub enum Action {
183 Read,
185 Write,
187 EncryptionKeys,
189 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 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#[derive(thiserror::Error, Debug, PartialEq, Eq)]
307pub enum CapabilityParseError {
308 #[error("invalid capability scope: {0}")]
310 InvalidScope(#[source] StoragePathError),
311 #[error("capability scope contains reserved delimiter `{0}`")]
313 InvalidScopeDelimiter(char),
314 #[error("capability must have format `<scope>:<actions>`")]
316 InvalidFormat,
317 #[error("capability must contain at least one action")]
319 MissingActions,
320 #[error("invalid capability action `{0}`")]
322 InvalidAction(char),
323}
324
325pub type Error = CapabilityParseError;
327
328#[derive(thiserror::Error, Debug, PartialEq, Eq)]
330#[error("invalid capability at position {position} (`{entry}`): {source}")]
331pub struct CapabilitiesParseError {
332 pub position: usize,
334 pub entry: String,
336 #[source]
338 pub source: CapabilityParseError,
339}
340
341#[derive(Clone, Default, Debug, PartialEq, Eq)]
352#[must_use]
353pub struct Capabilities(Vec<Capability>);
354
355impl Capabilities {
356 pub fn normalize(self) -> Self {
374 Self(normalize(self.0))
375 }
376
377 pub fn contains(&self, capability: &Capability) -> bool {
379 self.0.contains(capability)
380 }
381
382 pub fn is_empty(&self) -> bool {
384 self.0.is_empty()
385 }
386
387 pub fn len(&self) -> usize {
389 self.0.len()
390 }
391
392 pub fn iter(&self) -> std::slice::Iter<'_, Capability> {
394 self.0.iter()
395 }
396
397 pub fn builder() -> CapsBuilder {
405 CapsBuilder::default()
406 }
407
408 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 #[inline]
446 pub fn as_slice(&self) -> &[Capability] {
447 &self.0
448 }
449
450 pub fn to_vec(&self) -> Vec<Capability> {
452 self.0.clone()
453 }
454}
455
456#[derive(Default, Debug)]
461pub struct CapsBuilder {
462 caps: Vec<Capability>,
463}
464
465impl CapsBuilder {
466 pub fn new() -> Self {
468 Self::default()
469 }
470
471 pub fn cap(mut self, cap: Capability) -> Self {
473 self.caps.push(cap);
474 self
475 }
476
477 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 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 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 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 pub fn extend<I: IntoIterator<Item = Capability>>(mut self, iter: I) -> Self {
503 self.caps.extend(iter);
504 self
505 }
506
507 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
589fn 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 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/") .unwrap()
696 .write("/pub/uploads/") .unwrap()
698 .read_write("/pub/my-cool-app/data/") .unwrap()
700 .finish();
701
702 assert_eq!(
704 caps.to_string(),
705 "/pub/my-cool-app/:r,/pub/uploads/:w,/pub/my-cool-app/data/:rw"
706 );
707
708 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 let parsed = "/:rw,/pub/my-cool-app/:r"
754 .parse::<Capabilities>()
755 .unwrap()
756 .normalize();
757 let built = Capabilities::builder()
758 .read_write("/") .unwrap()
760 .read("/pub/my-cool-app/") .unwrap()
762 .finish();
763
764 assert_eq!(parsed, built);
765 }
766
767 #[test]
768 fn parse_errors_are_informative() {
769 let error = "not/abs:rw".parse::<Capability>().unwrap_err();
771 assert_eq!(
772 error,
773 CapabilityParseError::InvalidScope(StoragePathError::NotAbsolute)
774 );
775
776 let error = "/pub/my.app".parse::<Capability>().unwrap_err();
778 assert_eq!(error, CapabilityParseError::InvalidFormat);
779
780 let error = "/pub/my.app:".parse::<Capability>().unwrap_err();
782 assert_eq!(error, CapabilityParseError::MissingActions);
783
784 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 #[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 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 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 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 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 assert!(!dir("/pub/app").scope_covers_path(&path("/pub/app/inside")));
963 }
964
965 #[test]
966 fn file_scope_rejects_prefix_attack() {
967 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}