Files
SoulStream/src-tauri/src/profile.rs
T

784 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<image::DynamicImage, String> {
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<Vec<u8>, 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<String>,
/// 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<String>,
/// 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<Option<ps::ProfileState>, 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 <app_data>/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<String, LedgerEntry>,
}
#[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<String>,
/// Unix seconds of the last network search that found nothing.
#[serde(default)]
absent_checked_at: Option<u64>,
}
/// 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<Self, String> {
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<Vec<u8>> {
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<ps::ProfileState>, 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<ps::MediaRefV1>,
local: Option<&Local>,
) -> Result<ps::ProfileState, String> {
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(), &params, bytes.clone())
.await
.map(|_| ())
} else {
adapter
.contract_put(PROFILE_CONTRACT_WASM.to_vec(), &params, 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<Option<ProfileView>, 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<Option<ProfileView>, 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<ProfileView, String> {
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<String>,
force: Option<bool>,
) -> Result<Option<ProfileView>, 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<ProfileView, String> {
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<ProfileView, String> {
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<ProfileView, String> {
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<u8> {
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<u8>, 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"<svg xmlns='x'><script>1</script></svg>".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);
}
}