Skip to repository content374 lines · 13.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:47:10.655Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
identity_profile.rs
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