Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:47:10.655Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

identity_profile.rs

374 lines · 13.0 KB · rust
1use std::{
2    fs,
3    io::{self, Read as _, Write as _},
4    path::PathBuf,
5};
6
7use omega_identity::IdentityRef;
8use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
9use thiserror::Error;
10use uuid::Uuid;
11
12const LOCAL_IDENTITY_PROFILE_VERSION: u32 = 1;
13const DISPLAY_NAME_MAX_CHARACTERS: usize = 80;
14const LOCAL_AVATAR_PREFIX: &str = "local-avatar:";
15const LOCAL_AVATAR_TOKEN_MAX_BYTES: usize = 128;
16const LOCAL_AVATAR_MAX_BYTES: u64 = 8 * 1024 * 1024;
17const KVP_KEY_PREFIX: &str = "omega.identity-profile.v1";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20#[serde(deny_unknown_fields)]
21pub(crate) struct LocalIdentityProfile {
22    version: u32,
23    identity_ref: IdentityRef,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    display_name: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    avatar_reference: Option<String>,
28}
29
30impl LocalIdentityProfile {
31    pub(crate) fn new(identity_ref: IdentityRef) -> Self {
32        Self {
33            version: LOCAL_IDENTITY_PROFILE_VERSION,
34            identity_ref,
35            display_name: None,
36            avatar_reference: None,
37        }
38    }
39
40    pub(crate) fn identity_ref(&self) -> &IdentityRef {
41        &self.identity_ref
42    }
43
44    pub(crate) fn display_name(&self) -> Option<&str> {
45        self.display_name.as_deref()
46    }
47
48    pub(crate) fn avatar_reference(&self) -> Option<&str> {
49        self.avatar_reference.as_deref()
50    }
51
52    pub(crate) fn set_display_name(
53        &mut self,
54        display_name: Option<String>,
55    ) -> Result<(), LocalIdentityProfileError> {
56        validate_display_name(display_name.as_deref())?;
57        self.display_name = display_name;
58        Ok(())
59    }
60
61    pub(crate) fn set_avatar_reference(
62        &mut self,
63        avatar_reference: Option<String>,
64    ) -> Result<(), LocalIdentityProfileError> {
65        validate_avatar_reference(avatar_reference.as_deref())?;
66        self.avatar_reference = avatar_reference;
67        Ok(())
68    }
69
70    pub(crate) fn canonical_json(&self) -> Result<String, LocalIdentityProfileError> {
71        Ok(serde_json::to_string(self)?)
72    }
73
74    pub(crate) fn from_canonical_json(
75        json: &str,
76        expected_identity_ref: &IdentityRef,
77    ) -> Result<Self, LocalIdentityProfileError> {
78        let profile: Self = serde_json::from_str(json)?;
79        if profile.identity_ref() != expected_identity_ref {
80            return Err(LocalIdentityProfileError::IdentityMismatch);
81        }
82        Ok(profile)
83    }
84
85    pub(crate) fn kvp_key(identity_ref: &IdentityRef) -> String {
86        format!("{KVP_KEY_PREFIX}.{}", identity_ref.as_str())
87    }
88}
89
90pub(crate) fn install_local_avatar(source: PathBuf) -> Result<String, LocalIdentityProfileError> {
91    let metadata = fs::symlink_metadata(&source)?;
92    if !metadata.is_file()
93        || metadata.file_type().is_symlink()
94        || metadata.len() == 0
95        || metadata.len() > LOCAL_AVATAR_MAX_BYTES
96    {
97        return Err(LocalIdentityProfileError::InvalidAvatarFile);
98    }
99    let extension = source
100        .extension()
101        .and_then(|extension| extension.to_str())
102        .map(str::to_ascii_lowercase)
103        .filter(|extension| matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "webp"))
104        .ok_or(LocalIdentityProfileError::InvalidAvatarFile)?;
105    let token = format!("{}.{}", Uuid::new_v4().simple(), extension);
106    let directory = paths::data_dir().join("identity").join("profile-avatars");
107    fs::create_dir_all(&directory)?;
108    let destination = directory.join(&token);
109
110    let mut source_file = fs::File::open(source)?;
111    let mut destination_file = fs::OpenOptions::new()
112        .create_new(true)
113        .write(true)
114        .open(&destination)?;
115    let copy_result = (|| {
116        let mut limited = (&mut source_file).take(LOCAL_AVATAR_MAX_BYTES + 1);
117        let byte_length = io::copy(&mut limited, &mut destination_file)?;
118        if byte_length == 0 || byte_length > LOCAL_AVATAR_MAX_BYTES {
119            return Err(LocalIdentityProfileError::InvalidAvatarFile);
120        }
121        destination_file.flush()?;
122        destination_file.sync_all()?;
123        Ok(())
124    })();
125    if let Err(error) = copy_result {
126        if let Err(remove_error) = fs::remove_file(&destination) {
127            zlog::error!(
128                "failed to remove incomplete local avatar {}: {remove_error}",
129                destination.display()
130            );
131        }
132        return Err(error);
133    }
134    Ok(format!("{LOCAL_AVATAR_PREFIX}{token}"))
135}
136
137impl<'de> Deserialize<'de> for LocalIdentityProfile {
138    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139    where
140        D: Deserializer<'de>,
141    {
142        #[derive(Deserialize)]
143        #[serde(deny_unknown_fields)]
144        struct LocalIdentityProfileWire {
145            version: u32,
146            identity_ref: IdentityRef,
147            #[serde(default)]
148            display_name: Option<String>,
149            #[serde(default)]
150            avatar_reference: Option<String>,
151        }
152
153        let wire = LocalIdentityProfileWire::deserialize(deserializer)?;
154        if wire.version != LOCAL_IDENTITY_PROFILE_VERSION {
155            return Err(D::Error::custom(
156                "unsupported local identity profile version",
157            ));
158        }
159        validate_display_name(wire.display_name.as_deref()).map_err(D::Error::custom)?;
160        validate_avatar_reference(wire.avatar_reference.as_deref()).map_err(D::Error::custom)?;
161        Ok(Self {
162            version: wire.version,
163            identity_ref: wire.identity_ref,
164            display_name: wire.display_name,
165            avatar_reference: wire.avatar_reference,
166        })
167    }
168}
169
170fn validate_display_name(display_name: Option<&str>) -> Result<(), LocalIdentityProfileError> {
171    let Some(display_name) = display_name else {
172        return Ok(());
173    };
174    if display_name.is_empty()
175        || display_name.trim() != display_name
176        || display_name.chars().count() > DISPLAY_NAME_MAX_CHARACTERS
177        || display_name.chars().any(char::is_control)
178    {
179        return Err(LocalIdentityProfileError::InvalidDisplayName);
180    }
181    Ok(())
182}
183
184fn validate_avatar_reference(
185    avatar_reference: Option<&str>,
186) -> Result<(), LocalIdentityProfileError> {
187    let Some(avatar_reference) = avatar_reference else {
188        return Ok(());
189    };
190    let Some(token) = avatar_reference.strip_prefix(LOCAL_AVATAR_PREFIX) else {
191        return Err(LocalIdentityProfileError::InvalidAvatarReference);
192    };
193    if token.is_empty()
194        || token.len() > LOCAL_AVATAR_TOKEN_MAX_BYTES
195        || !token
196            .bytes()
197            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
198    {
199        return Err(LocalIdentityProfileError::InvalidAvatarReference);
200    }
201    Ok(())
202}
203
204#[derive(Debug, Error)]
205pub(crate) enum LocalIdentityProfileError {
206    #[error("the local display name is invalid")]
207    InvalidDisplayName,
208    #[error("the local avatar reference is invalid")]
209    InvalidAvatarReference,
210    #[error("the local profile belongs to another identity")]
211    IdentityMismatch,
212    #[error("the selected local avatar is invalid")]
213    InvalidAvatarFile,
214    #[error("local avatar storage is unavailable")]
215    Io(#[from] io::Error),
216    #[error("the local identity profile is invalid")]
217    Serialization(#[from] serde_json::Error),
218}
219
220#[cfg(test)]
221mod tests {
222    use serde_json::Value;
223
224    use super::*;
225
226    fn identity_ref(value: &str) -> IdentityRef {
227        IdentityRef::new(value).expect("valid identity reference")
228    }
229
230    #[test]
231    fn canonical_profile_round_trips() {
232        let identity_ref = identity_ref("omega-test-identity");
233        let mut profile = LocalIdentityProfile::new(identity_ref.clone());
234        profile
235            .set_display_name(Some("Ada Lovelace".to_string()))
236            .expect("valid display name");
237        profile
238            .set_avatar_reference(Some("local-avatar:ada_01".to_string()))
239            .expect("valid avatar reference");
240
241        let json = profile.canonical_json().expect("serialize local profile");
242        assert_eq!(
243            json,
244            r#"{"version":1,"identity_ref":"omega-test-identity","display_name":"Ada Lovelace","avatar_reference":"local-avatar:ada_01"}"#
245        );
246        assert_eq!(
247            LocalIdentityProfile::from_canonical_json(&json, &identity_ref)
248                .expect("parse identity-bound profile"),
249            profile
250        );
251    }
252
253    #[test]
254    fn profile_and_kvp_key_are_bound_to_the_public_identity() {
255        let first_identity = identity_ref("omega-first-identity");
256        let second_identity = identity_ref("omega-second-identity");
257        let profile = LocalIdentityProfile::new(first_identity.clone());
258        let json = profile.canonical_json().expect("serialize local profile");
259
260        assert!(matches!(
261            LocalIdentityProfile::from_canonical_json(&json, &second_identity),
262            Err(LocalIdentityProfileError::IdentityMismatch)
263        ));
264        assert_ne!(
265            LocalIdentityProfile::kvp_key(&first_identity),
266            LocalIdentityProfile::kvp_key(&second_identity)
267        );
268        assert_eq!(
269            LocalIdentityProfile::kvp_key(&first_identity),
270            "omega.identity-profile.v1.omega-first-identity"
271        );
272    }
273
274    #[test]
275    fn display_name_enforces_canonical_bounds_and_control_character_rules() {
276        let mut profile = LocalIdentityProfile::new(identity_ref("omega-test-identity"));
277        profile
278            .set_display_name(Some("a".repeat(DISPLAY_NAME_MAX_CHARACTERS)))
279            .expect("maximum display name is accepted");
280
281        for invalid in [
282            String::new(),
283            " leading".to_string(),
284            "trailing ".to_string(),
285            "line\nbreak".to_string(),
286            "a".repeat(DISPLAY_NAME_MAX_CHARACTERS + 1),
287        ] {
288            assert!(matches!(
289                profile.set_display_name(Some(invalid)),
290                Err(LocalIdentityProfileError::InvalidDisplayName)
291            ));
292        }
293    }
294
295    #[test]
296    fn avatar_reference_is_an_opaque_bounded_local_token() {
297        let mut profile = LocalIdentityProfile::new(identity_ref("omega-test-identity"));
298        profile
299            .set_avatar_reference(Some(format!(
300                "{LOCAL_AVATAR_PREFIX}{}",
301                "a".repeat(LOCAL_AVATAR_TOKEN_MAX_BYTES)
302            )))
303            .expect("maximum local avatar token is accepted");
304
305        for invalid in [
306            "https://example.com/avatar.png".to_string(),
307            "local-avatar:".to_string(),
308            "local-avatar:../avatar.png".to_string(),
309            "local-avatar:line\nbreak".to_string(),
310            format!(
311                "{LOCAL_AVATAR_PREFIX}{}",
312                "a".repeat(LOCAL_AVATAR_TOKEN_MAX_BYTES + 1)
313            ),
314        ] {
315            assert!(matches!(
316                profile.set_avatar_reference(Some(invalid)),
317                Err(LocalIdentityProfileError::InvalidAvatarReference)
318            ));
319        }
320    }
321
322    #[test]
323    fn serialized_profile_contains_only_local_public_presentation_fields() {
324        let mut profile = LocalIdentityProfile::new(identity_ref("omega-test-identity"));
325        profile
326            .set_display_name(Some("Ada".to_string()))
327            .expect("valid display name");
328        profile
329            .set_avatar_reference(Some("local-avatar:ada".to_string()))
330            .expect("valid avatar reference");
331        let value: Value =
332            serde_json::from_str(&profile.canonical_json().expect("serialize local profile"))
333                .expect("parse local profile JSON");
334        let fields = value.as_object().expect("profile is a JSON object");
335
336        assert_eq!(
337            fields.keys().map(String::as_str).collect::<Vec<_>>(),
338            [
339                "version",
340                "identity_ref",
341                "display_name",
342                "avatar_reference"
343            ]
344        );
345        for forbidden in [
346            "kind",
347            "kind_0",
348            "relay",
349            "signing",
350            "signature",
351            "secret",
352            "nsec",
353            "private_key",
354            "seed",
355            "mnemonic",
356        ] {
357            assert!(!fields.contains_key(forbidden));
358        }
359    }
360
361    #[test]
362    fn deserialization_rejects_unknown_or_invalid_fields() {
363        let identity_ref = identity_ref("omega-test-identity");
364        for json in [
365            r#"{"version":2,"identity_ref":"omega-test-identity"}"#,
366            r#"{"version":1,"identity_ref":"omega-test-identity","relay":"wss://example.com"}"#,
367            r#"{"version":1,"identity_ref":"omega-test-identity","display_name":"line\nbreak"}"#,
368            r#"{"version":1,"identity_ref":"omega-test-identity","avatar_reference":"https://example.com/avatar.png"}"#,
369        ] {
370            assert!(LocalIdentityProfile::from_canonical_json(json, &identity_ref).is_err());
371        }
372    }
373}
374
Served at tenant.openagents/omega Member data and write actions are omitted.