784 lines
30 KiB
Rust
784 lines
30 KiB
Rust
//! 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(), ¶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<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);
|
||
}
|
||
}
|