//! ProfileService — the persona's publishable face: display name, bio, and //! a sanitized avatar, published through the bundled persona/media //! contracts and signed by the identity delegate (persona.rs). //! //! Contract bytes are commitments like the delegate's (see persona.rs): //! `assets/persona_contract.wasm` (gen-2, avatar-accepting) and //! `assets/media_contract.wasm` (immutable content-addressed images), both //! straight from the F2/F2b validation runs, code hashes pinned by tests. //! //! The avatar pipeline is F2b's, verbatim in spirit: //! - PUBLISH: bounded decode → center-crop → 256×256 CatmullRom → RGB → //! deterministic PNG → budget gate. Metadata cannot survive (pixels in, //! critical chunks out). //! - VIEW: policy gates BEFORE any fetch → structural verification (hash, //! signature, declared bounds vs the signed reference) BEFORE decode → //! bounded decode (sniff, header-dims refusal pre-allocation) → only //! then do the sanitized bytes go to the webview. Any failure degrades //! to "no avatar" with a reason, never an error surface. use std::collections::BTreeMap; use std::io::Cursor; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use freenet_adapter::FreenetAdapter; use image::{imageops::FilterType, ImageEncoder, ImageReader, Limits}; use persona_state as ps; use serde::{Deserialize, Serialize}; use crate::state::AppState; use crate::{config, persona}; const PROFILE_CONTRACT_WASM: &[u8] = include_bytes!("../assets/persona_contract.wasm"); const MEDIA_CONTRACT_WASM: &[u8] = include_bytes!("../assets/media_contract.wasm"); const SOURCE_MAX_BYTES: usize = 16 * 1024 * 1024; const SOURCE_MAX_DIM: u32 = 8192; const READER_MAX_DIM: u32 = 512; const READER_MAX_ALLOC: u64 = 32 * 1024 * 1024; // ---------------------------------------------------------------- pipeline fn limits(max_dim: u32) -> Limits { let mut l = Limits::default(); l.max_image_width = Some(max_dim); l.max_image_height = Some(max_dim); l.max_alloc = Some(READER_MAX_ALLOC); l } fn decode_bounded( bytes: &[u8], max_dim: u32, expect_png: bool, ) -> Result { let mut reader = ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|e| format!("format sniff failed: {e}"))?; let fmt = reader .format() .ok_or_else(|| "unrecognized image format".to_string())?; if expect_png && fmt != image::ImageFormat::Png { return Err(format!("declared image/png but bytes are {fmt:?}")); } reader.limits(limits(max_dim)); reader.decode().map_err(|e| format!("bounded decode refused: {e}")) } /// Publisher normalization — F2b's recipe. Deterministic for a given input. pub(crate) fn normalize_avatar(source: &[u8]) -> Result, String> { if source.len() > SOURCE_MAX_BYTES { return Err(format!("source over {SOURCE_MAX_BYTES} bytes")); } let img = decode_bounded(source, SOURCE_MAX_DIM, false)?; let (w, h) = (img.width(), img.height()); let side = w.min(h); let cropped = img.crop_imm((w - side) / 2, (h - side) / 2, side, side); let resized = cropped.resize_exact(ps::V1_DIMENSION, ps::V1_DIMENSION, FilterType::CatmullRom); let rgb = resized.to_rgb8(); let mut out = Vec::new(); image::codecs::png::PngEncoder::new(&mut out) .write_image(rgb.as_raw(), ps::V1_DIMENSION, ps::V1_DIMENSION, image::ExtendedColorType::Rgb8) .map_err(|e| e.to_string())?; if out.len() > ps::MAX_MEDIA_BYTES { return Err(format!( "normalized avatar is {} bytes, over the {} budget — this image compresses poorly; pick another", out.len(), ps::MAX_MEDIA_BYTES )); } Ok(out) } /// Reader decode — the LAST gate before bytes reach the webview. Runs only /// after structural verification against the signed reference. fn reader_verify_decode(asset: &ps::MediaAssetV1) -> Result<(), String> { let img = decode_bounded(&asset.sanitized_static_bytes, READER_MAX_DIM, true)?; if img.width() != asset.width || img.height() != asset.height { return Err(format!( "decoded {}x{} contradicts declared {}x{}", img.width(), img.height(), asset.width, asset.height )); } Ok(()) } // ---------------------------------------------------------------- service #[derive(Debug, Serialize)] pub struct ProfileView { pub owner: String, pub sequence: u64, pub display_name: String, pub bio: String, /// Base64 PNG of the VERIFIED, sanitized avatar — present only when the /// whole gauntlet passed. pub avatar_png_base64: Option, /// Why there is no avatar image ("none published", policy, or the /// failure reason) — the UI renders its identicon on any of these. pub avatar_note: Option, /// True when this came from the device's own ledger (what it last /// published) rather than a network read. pub from_ledger: bool, } fn decode_owner(b58: &str) -> Result<[u8; 32], String> { bs58::decode(b58) .into_vec() .map_err(|e| format!("bad persona key: {e}"))? .as_slice() .try_into() .map_err(|_| "persona key is not 32 bytes".to_string()) } async fn fetch_profile( adapter: &FreenetAdapter, owner: &[u8; 32], ) -> Result, String> { let ids = FreenetAdapter::contract_ids( PROFILE_CONTRACT_WASM.to_vec(), &ps::make_profile_params(owner), ); match adapter.contract_get(&ids.instance, false).await { Ok(bytes) if bytes.is_empty() => Ok(None), Ok(bytes) => ps::validate_profile(owner, &bytes) .map(Some) .map_err(|e| format!("published profile failed verification: {e}")), Err(e) if e.to_string().contains("not found") => Ok(None), Err(e) => Err(e.to_string()), } } // ---------------------------------------------------------------- the ledger // // What THIS device knows about its own persona's profile, kept beside the // node as /freenet/profile-ledger.json, with verified avatar bytes // cached by content hash next to it. Reading our own profile from the ledger // is instant and works offline. Asking the network for a profile that was // never published is not: every such GET is a 12–30 s search of the ring that // ends in "retry loop exhausted" (observed live 2026-09-02 — three of them // behind one avatar change, a minute of nothing happening). The network is // asked only when it might know more than this device: an imported key never // searched, an UPDATE the network refuses, or an explicit refresh. const ABSENT_RECHECK_SECS: u64 = 3600; #[derive(Default, Serialize, Deserialize)] struct Ledger { #[serde(default)] personas: BTreeMap, } #[derive(Default, Serialize, Deserialize)] struct LedgerEntry { /// Minted on this device: nothing can exist on the network until this /// device publishes it. #[serde(default)] created_here: bool, /// The last state this device published (bincode, base64) — the truth /// for the next read-and-bump while this device is the only writer. #[serde(default)] published: Option, /// Unix seconds of the last network search that found nothing. #[serde(default)] absent_checked_at: Option, } /// Where this device keeps what it knows. `None` in tests: network only. pub struct Local { ledger: PathBuf, avatars: PathBuf, } impl Local { pub fn for_app(app: &tauri::AppHandle) -> Result { let base = crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet"); Ok(Self { ledger: base.join("profile-ledger.json"), avatars: base.join("avatars") }) } fn load(&self) -> Ledger { std::fs::read(&self.ledger) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn save(&self, ledger: &Ledger) { if let Some(dir) = self.ledger.parent() { let _ = std::fs::create_dir_all(dir); } match serde_json::to_vec_pretty(ledger) { Ok(b) => { if let Err(e) = std::fs::write(&self.ledger, b) { log::warn!("profile ledger not saved: {e}"); } } Err(e) => log::warn!("profile ledger not encoded: {e}"), } } fn note_published(&self, owner: &[u8; 32], bytes: &[u8]) { use base64::Engine; let mut l = self.load(); let e = l.personas.entry(owner_b58(owner)).or_default(); e.published = Some(base64::engine::general_purpose::STANDARD.encode(bytes)); e.absent_checked_at = None; self.save(&l); } fn note_absent(&self, owner: &[u8; 32]) { let mut l = self.load(); l.personas.entry(owner_b58(owner)).or_default().absent_checked_at = Some(now_secs()); self.save(&l); } fn avatar_path(&self, hash: &[u8; 32]) -> PathBuf { self.avatars.join(format!("{}.png", hex(hash))) } fn cache_avatar(&self, hash: &[u8; 32], png: &[u8]) { let _ = std::fs::create_dir_all(&self.avatars); let _ = std::fs::write(self.avatar_path(hash), png); } /// Cached bytes only while they still hash to what the signed /// reference says; anything else is simply not used. fn cached_avatar(&self, hash: &[u8; 32]) -> Option> { let bytes = std::fs::read(self.avatar_path(hash)).ok()?; (blake3_hash(&bytes) == *hash).then_some(bytes) } } fn owner_b58(owner: &[u8; 32]) -> String { bs58::encode(owner).into_string() } fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// persona.rs tells the ledger when a key is minted here or imported. pub(crate) fn ledger_note_persona(app: &tauri::AppHandle, public_key_b58: &str, created_here: bool) { let Ok(local) = Local::for_app(app) else { return }; let mut l = local.load(); l.personas.insert( public_key_b58.to_string(), LedgerEntry { created_here, published: None, absent_checked_at: None }, ); local.save(&l); } /// ...and when it is gone. pub(crate) fn ledger_forget_persona(app: &tauri::AppHandle, public_key_b58: &str) { let Ok(local) = Local::for_app(app) else { return }; let mut l = local.load(); if l.personas.remove(public_key_b58).is_some() { local.save(&l); } } /// Our own profile from what this device knows; the network only when it /// might know more (an imported key never searched, or `force`). The bool /// says which one answered. async fn fetch_own_profile( adapter: &FreenetAdapter, owner: &[u8; 32], local: Option<&Local>, force: bool, ) -> Result<(Option, bool), String> { if let (Some(l), false) = (local, force) { let ledger = l.load(); if let Some(e) = ledger.personas.get(&owner_b58(owner)) { if let Some(b64) = &e.published { use base64::Engine; let known = base64::engine::general_purpose::STANDARD .decode(b64) .ok() .and_then(|b| ps::validate_profile(owner, &b).ok()); if let Some(p) = known { return Ok((Some(p), true)); } } else if e.created_here { return Ok((None, true)); } else if e .absent_checked_at .is_some_and(|t| now_secs().saturating_sub(t) < ABSENT_RECHECK_SECS) { return Ok((None, true)); } } } let found = fetch_profile(adapter, owner).await?; if let Some(l) = local { match &found { Some(p) => { if let Ok(b) = bincode::serialize(p) { l.note_published(owner, &b); } } None => l.note_absent(owner), } } Ok((found, false)) } /// Publish a signed profile revision. Read-and-bump (F5's rule) against what /// this device knows; if the network refuses the update because another /// writer moved first, read it for real once and bump past it. async fn publish_profile( adapter: &FreenetAdapter, owner: [u8; 32], display_name: String, bio: String, avatar: Option, local: Option<&Local>, ) -> Result { if display_name.len() > ps::MAX_NAME_BYTES { return Err(format!("display name over {} bytes", ps::MAX_NAME_BYTES)); } if bio.len() > ps::MAX_BIO_BYTES { return Err(format!("bio over {} bytes", ps::MAX_BIO_BYTES)); } let (existing, _) = fetch_own_profile(adapter, &owner, local, false).await?; let mut sequence = existing.as_ref().map(|p| p.sequence + 1).unwrap_or(1); let params = ps::make_profile_params(&owner); let mut retried = false; loop { let mut p = ps::ProfileState { schema_version: ps::SCHEMA_VERSION, owner, sequence, display_name: display_name.clone(), bio: bio.clone(), avatar: avatar.clone(), sig: vec![], }; p.sig = persona::sign(adapter, ps::SIGN_DOMAIN, ps::profile_sign_payload(&p)).await?; let bytes = bincode::serialize(&p).map_err(|e| e.to_string())?; let result = if existing.is_some() { adapter .contract_update(PROFILE_CONTRACT_WASM.to_vec(), ¶ms, bytes.clone()) .await .map(|_| ()) } else { adapter .contract_put(PROFILE_CONTRACT_WASM.to_vec(), ¶ms, bytes.clone(), false) .await .map(|_| ()) }; match result { Ok(_) => { if let Some(l) = local { l.note_published(&owner, &bytes); } return Ok(p); } Err(e) if existing.is_some() && local.is_some() && !retried => { retried = true; match fetch_profile(adapter, &owner).await? { Some(fresh) if fresh.sequence >= sequence => { log::info!( "profile: network is at rev {}, ledger said {} — bumping past it", fresh.sequence, sequence - 1 ); sequence = fresh.sequence + 1; } _ => return Err(e.to_string()), } } Err(e) => return Err(e.to_string()), } } } async fn own_key(adapter: &FreenetAdapter) -> Result<[u8; 32], String> { let info = persona::current(adapter) .await? .ok_or("no persona exists yet — create one first")?; decode_owner(&info.public_key) } fn verify_png_dims(bytes: &[u8], w: u32, h: u32) -> Result<(), String> { let img = decode_bounded(bytes, READER_MAX_DIM, true)?; if img.width() != w || img.height() != h { return Err(format!("decoded {}x{} contradicts declared {w}x{h}", img.width(), img.height())); } Ok(()) } /// The avatar half of the read gauntlet, in the design's order: policy → /// cache by hash → single fetch → verify chain → bounded decode → note. async fn render_view( adapter: &FreenetAdapter, owner: [u8; 32], p: ps::ProfileState, load_remote_images: bool, local: Option<&Local>, from_ledger: bool, ) -> ProfileView { use base64::Engine; let mut v = ProfileView { owner: owner_b58(&owner), sequence: p.sequence, display_name: p.display_name.clone(), bio: p.bio.clone(), avatar_png_base64: None, avatar_note: None, from_ledger, }; let Some(r) = &p.avatar else { v.avatar_note = Some("none published".into()); return v; }; if !load_remote_images { v.avatar_note = Some("remote profile images disabled".into()); return v; } if let Some(png) = local.and_then(|l| l.cached_avatar(&r.content_hash)) { if png.len() as u32 == r.byte_length && verify_png_dims(&png, r.width as u32, r.height as u32).is_ok() { v.avatar_png_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&png)); return v; } } let ids = FreenetAdapter::contract_ids( MEDIA_CONTRACT_WASM.to_vec(), &ps::make_media_params(&owner, &r.content_hash), ); // Single attempt (no retry storm — F2b): failure degrades to a note. let state_bytes = match adapter.contract_get(&ids.instance, false).await { Ok(b) => b, Err(e) => { v.avatar_note = Some(format!("avatar unavailable: {e}")); return v; } }; match ps::validate_asset(&owner, &r.content_hash, &state_bytes) { Ok(asset) => { let matches_ref = asset.media_type == r.media_type && asset.sanitized_static_bytes.len() as u32 == r.byte_length && asset.width == r.width as u32 && asset.height == r.height as u32; if !matches_ref { v.avatar_note = Some("asset contradicts the signed reference".into()); } else if let Err(e) = reader_verify_decode(&asset) { v.avatar_note = Some(format!("decode refused: {e}")); } else { if let Some(l) = local { l.cache_avatar(&r.content_hash, &asset.sanitized_static_bytes); } v.avatar_png_base64 = Some( base64::engine::general_purpose::STANDARD.encode(&asset.sanitized_static_bytes), ); } } Err(e) => v.avatar_note = Some(format!("verification failed: {e}")), } v } /// The full read gauntlet for anyone's profile — always the network. async fn view( adapter: &FreenetAdapter, owner: [u8; 32], load_remote_images: bool, local: Option<&Local>, ) -> Result, String> { let Some(p) = fetch_profile(adapter, &owner).await? else { return Ok(None); }; Ok(Some(render_view(adapter, owner, p, load_remote_images, local, false).await)) } /// Our own profile — what this device knows, unless `force` asks the network. async fn view_own( adapter: &FreenetAdapter, owner: [u8; 32], load_remote_images: bool, local: Option<&Local>, force: bool, ) -> Result, String> { let (p, from_ledger) = fetch_own_profile(adapter, &owner, local, force).await?; let Some(p) = p else { return Ok(None); }; Ok(Some(render_view(adapter, owner, p, load_remote_images, local, from_ledger).await)) } async fn set_avatar( adapter: &FreenetAdapter, image_path: &str, local: Option<&Local>, ) -> Result { let owner = own_key(adapter).await?; let source = std::fs::read(image_path).map_err(|e| format!("reading image: {e}"))?; let png = normalize_avatar(&source)?; let content_hash = blake3_hash(&png); let (current, _) = fetch_own_profile(adapter, &owner, local, false).await?; let revision = current.as_ref().map(|p| p.sequence + 1).unwrap_or(1) as u32; let mut asset_id = [0u8; 16]; asset_id.copy_from_slice(&blake3_hash(&[&owner[..], b"avatar"].concat())[..16]); let mut asset = ps::MediaAssetV1 { schema_version: ps::SCHEMA_VERSION, owner, asset_id, revision, media_type: ps::V1_MEDIA_TYPE.into(), width: ps::V1_DIMENSION, height: ps::V1_DIMENSION, sanitized_static_bytes: png.clone(), sig: vec![], }; asset.sig = persona::sign(adapter, ps::SIGN_DOMAIN, ps::asset_sign_payload(&asset)).await?; let asset_bytes = bincode::serialize(&asset).map_err(|e| e.to_string())?; // Media object FIRST (design order), then the profile that points at it. adapter .contract_put( MEDIA_CONTRACT_WASM.to_vec(), &ps::make_media_params(&owner, &content_hash), asset_bytes, false, ) .await .map_err(|e| e.to_string())?; // Ours by construction — cache it so the view never has to fetch it back. if let Some(l) = local { l.cache_avatar(&content_hash, &png); } let avatar_ref = ps::MediaRefV1 { content_hash, media_type: ps::V1_MEDIA_TYPE.into(), byte_length: png.len() as u32, width: ps::V1_DIMENSION as u16, height: ps::V1_DIMENSION as u16, }; let (name, bio) = current .map(|p| (p.display_name, p.bio)) .unwrap_or_else(|| (String::new(), String::new())); publish_profile(adapter, owner, name, bio, Some(avatar_ref), local).await?; view_own(adapter, owner, true, local, false) .await? .ok_or("profile vanished after publish".into()) } fn blake3_hash(bytes: &[u8]) -> [u8; 32] { // Route through persona-state's blake3 so src-tauri needs no direct dep. ps::content_hash(bytes) } // ---------------------------------------------------------------- commands #[tauri::command] pub async fn profile_get( app: tauri::AppHandle, state: tauri::State<'_, AppState>, persona: Option, force: Option, ) -> Result, String> { let adapter = state.freenet.clone(); let load = config::load(&app).freenet_load_remote_images.unwrap_or(true); let local = Local::for_app(&app)?; match persona { Some(b58) => view(&adapter, decode_owner(&b58)?, load, Some(&local)).await, None => { let owner = own_key(&adapter).await?; view_own(&adapter, owner, load, Some(&local), force.unwrap_or(false)).await } } } #[tauri::command] pub async fn profile_publish( app: tauri::AppHandle, state: tauri::State<'_, AppState>, display_name: String, bio: String, ) -> Result { let adapter = state.freenet.clone(); let local = Local::for_app(&app)?; let owner = own_key(&adapter).await?; let (current, _) = fetch_own_profile(&adapter, &owner, Some(&local), false).await?; let avatar = current.and_then(|p| p.avatar); publish_profile(&adapter, owner, display_name, bio, avatar, Some(&local)).await?; view_own(&adapter, owner, true, Some(&local), false) .await? .ok_or("profile vanished after publish".into()) } #[tauri::command] pub async fn profile_set_avatar( app: tauri::AppHandle, state: tauri::State<'_, AppState>, image_path: String, ) -> Result { let local = Local::for_app(&app)?; set_avatar(&state.freenet.clone(), &image_path, Some(&local)).await } /// Withdraw the avatar pointer. Truthful semantics (F2b): previously /// published copies of the old image remain fetchable on the network — /// this changes what the profile points at, it does not erase. #[tauri::command] pub async fn profile_clear_avatar( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let adapter = state.freenet.clone(); let local = Local::for_app(&app)?; let owner = own_key(&adapter).await?; let (current, _) = fetch_own_profile(&adapter, &owner, Some(&local), false).await?; let current = current.ok_or("no profile published yet")?; publish_profile(&adapter, owner, current.display_name, current.bio, None, Some(&local)).await?; view_own(&adapter, owner, true, Some(&local), false) .await? .ok_or("profile vanished after publish".into()) } #[cfg(test)] mod tests { use super::*; fn photo_jpeg() -> Vec { let w = 900u32; let h = 600u32; let mut rgb = image::RgbImage::new(w, h); for (x, y, p) in rgb.enumerate_pixels_mut() { *p = image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, (((x + y) * 255) / (w + h)) as u8]); } let mut jpeg = Vec::new(); image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 85) .encode_image(&image::DynamicImage::ImageRgb8(rgb)) .unwrap(); // Splice an EXIF APP1 + COM segment after SOI — must not survive. let exif = b"Exif\0\0SPIKEGPS-LAT51-LON7"; let com = b"SECRETCOMMENT-nope"; let mut out = Vec::new(); out.extend_from_slice(&jpeg[..2]); out.extend_from_slice(&[0xFF, 0xE1]); out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); out.extend_from_slice(exif); out.extend_from_slice(&[0xFF, 0xFE]); out.extend_from_slice(&((com.len() + 2) as u16).to_be_bytes()); out.extend_from_slice(com); out.extend_from_slice(&jpeg[2..]); out } #[test] fn contract_generations_are_pinned() { // Commitment bytes, like the delegate's — changing either is a // migration event (copy-forward is disabled upstream). let p = FreenetAdapter::contract_ids(PROFILE_CONTRACT_WASM.to_vec(), b"x"); assert_eq!(p.code_hash, "EeQoECY38qpiHSqMwd4W1KHQMDyRqKqVsq3fV56Me7g4"); let m = FreenetAdapter::contract_ids(MEDIA_CONTRACT_WASM.to_vec(), b"x"); assert_eq!(m.code_hash, "6miQr6YTLEzjnoTCr9kFuapQLMq4z2PVqK6QSQzJpotT"); } #[test] fn normalize_is_deterministic_and_strips_metadata() { let src = photo_jpeg(); let a = normalize_avatar(&src).unwrap(); let b = normalize_avatar(&src).unwrap(); assert_eq!(a, b, "same input must produce identical bytes"); assert!(a.len() <= ps::MAX_MEDIA_BYTES); for pat in [&b"SPIKEGPS"[..], &b"SECRETCOMMENT"[..], &b"Exif"[..]] { assert!(!a.windows(pat.len()).any(|w| w == pat)); } } #[test] fn reader_refuses_lies_and_bombs() { let png = normalize_avatar(&photo_jpeg()).unwrap(); let asset = |bytes: Vec, w: u32, h: u32| ps::MediaAssetV1 { schema_version: ps::SCHEMA_VERSION, owner: [1; 32], asset_id: [2; 16], revision: 1, media_type: ps::V1_MEDIA_TYPE.into(), width: w, height: h, sanitized_static_bytes: bytes, sig: vec![0; 64], }; // dimension lie assert!(reader_verify_decode(&asset(png.clone(), 64, 64)).is_err()); // SVG declared png → sniff refusal let svg = b"".to_vec(); assert!(reader_verify_decode(&asset(svg, 256, 256)).is_err()); // IHDR bomb: tiny bytes claiming 60000x60000 — refused pre-allocation let mut bomb = png.clone(); bomb[16..20].copy_from_slice(&60000u32.to_be_bytes()); bomb[20..24].copy_from_slice(&60000u32.to_be_bytes()); let mut h = crc32fast::Hasher::new(); h.update(&bomb[12..29]); let crc = h.finalize(); bomb[29..33].copy_from_slice(&crc.to_be_bytes()); let t0 = std::time::Instant::now(); assert!(reader_verify_decode(&asset(bomb, 256, 256)).is_err()); assert!(t0.elapsed().as_millis() < 500, "bomb refusal must be immediate"); } /// Live: needs a local freenet node on 7509. Mutates its persona and /// profile namespaces — throwaway node only. /// cargo test -p app --release profile_live -- --ignored #[tokio::test] #[ignore = "needs a local freenet node on 7509; mutates persona+profile"] async fn profile_live_full_loop() { let adapter = FreenetAdapter::default(); adapter.enable(); let mut tries = 0; while adapter.status() != freenet_adapter::AdapterStatus::Connected { tokio::time::sleep(std::time::Duration::from_millis(200)).await; tries += 1; assert!(tries < 50, "adapter never connected: {:?}", adapter.status()); } // Fresh persona for the run. let _ = persona::remove(&adapter).await; let created = persona::create(&adapter).await.expect("create persona"); let owner = decode_owner(&created.public_key).unwrap(); // Publish name + bio; read back through the network. publish_profile(&adapter, owner, "Kyle".into(), "first face".into(), None, None) .await .expect("publish"); let v = view(&adapter, owner, true, None).await.unwrap().expect("view"); assert_eq!(v.display_name, "Kyle"); assert_eq!(v.sequence, 1); assert_eq!(v.avatar_note.as_deref(), Some("none published")); // Avatar: temp jpeg → normalize → publish → view carries the PNG. let dir = std::env::temp_dir().join("soulstream-profile-test"); std::fs::create_dir_all(&dir).unwrap(); let img_path = dir.join("source.jpg"); std::fs::write(&img_path, photo_jpeg()).unwrap(); let v = set_avatar(&adapter, &img_path.to_string_lossy(), None) .await .expect("set avatar"); assert!(v.avatar_png_base64.is_some(), "note: {:?}", v.avatar_note); assert_eq!(v.sequence, 2); // Policy gate: remote images off → no avatar bytes, honest note. let v = view(&adapter, owner, false, None).await.unwrap().unwrap(); assert!(v.avatar_png_base64.is_none()); assert_eq!(v.avatar_note.as_deref(), Some("remote profile images disabled")); // Withdraw: pointer clears; sequence bumps. let owner_key = owner; let cur = fetch_profile(&adapter, &owner_key).await.unwrap().unwrap(); publish_profile(&adapter, owner_key, cur.display_name, cur.bio, None, None) .await .expect("clear"); let v = view(&adapter, owner, true, None).await.unwrap().unwrap(); assert!(v.avatar_png_base64.is_none()); assert_eq!(v.sequence, 3); adapter.disable(); let _ = std::fs::remove_file(&img_path); } }