Skip to main content

solid_pod_rs_forge/
ownership.rs

1//! Caller identity + repository-owner resolution and the namespace-write
2//! guard.
3//!
4//! An owner is one of two shapes: a **pod username** (an authenticated
5//! agent's local account) or a **64-hex `did:nostr` pubkey** (a podless
6//! nostr agent). The forge's write rule is simple and fail-closed: an
7//! agent may only create/push into a namespace whose first path segment
8//! equals its own owner id. Pushing to another agent's namespace is a
9//! `403` (JSS forge "namespace guard"), cited by behaviour only.
10
11use solid_pod_rs::did_nostr_types::is_valid_hex_pubkey;
12
13/// The resolved caller identity, produced by the server's WAC/auth layer
14/// or by [`crate::auth`]. Mirrors the design's `ForgeAgent`.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ForgeAgent {
17    /// A pod-backed agent. `owner = username`; author id = the WebID.
18    Pod {
19        /// The agent's WebID URI (used as the author id in the spine).
20        webid: String,
21        /// The agent's pod username — the namespace it owns.
22        username: String,
23    },
24    /// A podless nostr agent identified by a 64-hex x-only pubkey.
25    Nostr {
26        /// Lowercase 64-hex BIP-340 x-only pubkey.
27        pubkey_hex: String,
28    },
29    /// No credential resolved.
30    Anonymous,
31}
32
33impl ForgeAgent {
34    /// The namespace this agent owns (its first-path-segment identity),
35    /// or `None` when anonymous.
36    #[must_use]
37    pub fn owner(&self) -> Option<&str> {
38        match self {
39            ForgeAgent::Pod { username, .. } => Some(username),
40            ForgeAgent::Nostr { pubkey_hex } => Some(pubkey_hex),
41            ForgeAgent::Anonymous => None,
42        }
43    }
44
45    /// The stable author id recorded in spine entries and thread
46    /// pointers: a WebID for pod agents, `did:nostr:<hex>` for nostr
47    /// agents, `"anonymous"` otherwise.
48    #[must_use]
49    pub fn author_id(&self) -> String {
50        match self {
51            ForgeAgent::Pod { webid, .. } => webid.clone(),
52            ForgeAgent::Nostr { pubkey_hex } => format!("did:nostr:{pubkey_hex}"),
53            ForgeAgent::Anonymous => "anonymous".to_string(),
54        }
55    }
56
57    /// `true` for a podless nostr agent (bodies live in forge-hosted
58    /// storage rather than a pod).
59    #[must_use]
60    pub fn is_nostr(&self) -> bool {
61        matches!(self, ForgeAgent::Nostr { .. })
62    }
63
64    /// May this agent write (create/push/comment) into the namespace
65    /// owned by `owner_segment`? True only when the agent's own owner id
66    /// matches the target namespace exactly. Anonymous always fails.
67    #[must_use]
68    pub fn can_write_namespace(&self, owner_segment: &str) -> bool {
69        match self.owner() {
70            Some(o) => o == owner_segment,
71            None => false,
72        }
73    }
74}
75
76/// Kind of an owner segment, derived from its shape.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum OwnerKind {
79    /// A pod username (anything that is not a 64-hex pubkey).
80    PodUsername,
81    /// A 64-hex `did:nostr` pubkey namespace.
82    NostrHex,
83}
84
85/// Classify an owner path segment. A 64-char lowercase hex string is a
86/// nostr namespace; everything else is treated as a pod username.
87#[must_use]
88pub fn classify_owner(segment: &str) -> OwnerKind {
89    if is_valid_hex_pubkey(segment) {
90        OwnerKind::NostrHex
91    } else {
92        OwnerKind::PodUsername
93    }
94}
95
96/// Validate that an owner/repo path segment is well-formed: non-empty,
97/// no path separators or `..`, and only characters git and the
98/// filesystem tolerate in a bare-repo directory name. Rejects leading
99/// dots (dotfiles) and control characters.
100#[must_use]
101pub fn valid_name_segment(seg: &str) -> bool {
102    if seg.is_empty() || seg.len() > 100 {
103        return false;
104    }
105    if seg == "." || seg == ".." || seg.starts_with('.') {
106        return false;
107    }
108    if seg.contains("..") {
109        return false;
110    }
111    seg.chars()
112        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn hex64() -> String {
120        "a".repeat(64)
121    }
122
123    #[test]
124    fn pod_agent_owner_and_author() {
125        let a = ForgeAgent::Pod {
126            webid: "https://pod.example/alice/profile/card#me".into(),
127            username: "alice".into(),
128        };
129        assert_eq!(a.owner(), Some("alice"));
130        assert_eq!(a.author_id(), "https://pod.example/alice/profile/card#me");
131        assert!(!a.is_nostr());
132    }
133
134    #[test]
135    fn nostr_agent_owner_and_author() {
136        let a = ForgeAgent::Nostr {
137            pubkey_hex: hex64(),
138        };
139        assert_eq!(a.owner(), Some(hex64().as_str()));
140        assert_eq!(a.author_id(), format!("did:nostr:{}", hex64()));
141        assert!(a.is_nostr());
142    }
143
144    #[test]
145    fn anonymous_cannot_write() {
146        let a = ForgeAgent::Anonymous;
147        assert_eq!(a.owner(), None);
148        assert!(!a.can_write_namespace("alice"));
149        assert_eq!(a.author_id(), "anonymous");
150    }
151
152    #[test]
153    fn namespace_guard_matches_own_namespace_only() {
154        let alice = ForgeAgent::Pod {
155            webid: "w".into(),
156            username: "alice".into(),
157        };
158        assert!(alice.can_write_namespace("alice"));
159        assert!(!alice.can_write_namespace("bob"));
160    }
161
162    #[test]
163    fn classify_distinguishes_hex_from_username() {
164        assert_eq!(classify_owner(&hex64()), OwnerKind::NostrHex);
165        assert_eq!(classify_owner("alice"), OwnerKind::PodUsername);
166        // 63 or 65 hex chars is NOT a valid pubkey → treated as username.
167        assert_eq!(classify_owner(&"a".repeat(63)), OwnerKind::PodUsername);
168    }
169
170    #[test]
171    fn name_segment_validation() {
172        assert!(valid_name_segment("my-repo"));
173        assert!(valid_name_segment("repo_2"));
174        assert!(valid_name_segment("v0.5.0"));
175        assert!(!valid_name_segment(""));
176        assert!(!valid_name_segment(".."));
177        assert!(!valid_name_segment(".hidden"));
178        assert!(!valid_name_segment("a/b"));
179        assert!(!valid_name_segment("a..b"));
180        assert!(!valid_name_segment("space bar"));
181        assert!(!valid_name_segment(&"x".repeat(101)));
182    }
183}