the freenet layer: adapter, persona and profile services, the contracts bundled as commitments

This commit is contained in:
KeelrLeek committed 2026-09-02 13:43:16 -05:00
1 parent 63a0357651
commit 6dce4e2ac7
16 files changed
+3337 -10

No files matched your search

+4
View File
@@ -29,6 +29,8 @@ image.workspace = true
tokio.workspace = true
base64 = "0.22"
bincode = "1"
bs58 = "0.5"
# Shazam signature packets (shazam.rs) carry a CRC-32 of their body.
crc32fast = "1"
# Inflate for the deno release zip (ytdlp.rs) — already in the lock via the
@@ -46,6 +48,8 @@ keyring = { version = "3", features = ["windows-native"] }
sysinfo = "0.33"
soulseek-proto = { path = "../crates/soulseek-proto" }
soulseek-client = { path = "../crates/soulseek-client" }
freenet-adapter = { path = "../crates/freenet-adapter" }
persona-state = { path = "../crates/persona-state" }
player = { path = "../crates/player" }
tagger = { path = "../crates/tagger" }
metadata = { path = "../crates/metadata" }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -148,6 +148,40 @@ pub struct AppConfig {
/// their status and stats stream in while connected.
#[serde(default)]
pub buddies: Vec<String>,
/// The experimental Freenet social layer (crates/freenet-adapter).
/// None → false: strictly opt-in, and flipping it off is the kill
/// switch — the adapter drops its connection and stops retrying. A
/// missing or broken node never affects Soulseek or playback.
#[serde(default)]
pub freenet_enabled: Option<bool>,
/// WebSocket API port of the EXTERNAL local Freenet node. None → 7509.
#[serde(default)]
pub freenet_ws_port: Option<u16>,
/// Whether profile avatars fetched over Freenet may be decoded and
/// shown. None → true (the pipeline sanitizes them regardless); false
/// degrades every remote avatar to the identicon, no fetch at all.
#[serde(default)]
pub freenet_load_remote_images: Option<bool>,
/// The Community area's local view of a Soulseek contact: petname, group,
/// note and the two local marks. Private to this device by design — none
/// of it is ever published or sent to the person it describes.
#[serde(default)]
pub contacts: Vec<ContactMeta>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ContactMeta {
pub username: String,
#[serde(default)]
pub petname: Option<String>,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub trusted: bool,
#[serde(default)]
pub favorite: bool,
}
/// Default longest edge for embedded covers when the user hasn't chosen.
+355
View File
@@ -0,0 +1,355 @@
//! The app's Freenet surface — deliberately tiny.
//!
//! The webview gets STATUS ONLY: whether the experimental layer is enabled,
//! and what the adapter sees (absent / occupied / incompatible / connected).
//! The privileged node API stays entirely inside the Rust boundary
//! (crates/freenet-adapter); no command here moves contract state, keys, or
//! node operations across the bridge. Future Community services call the
//! adapter crate directly from Rust.
//!
//! Off by default. `freenet_set_enabled(true)` is the explicit opt-in and
//! persists to config.json; the kill switch is the same call with `false`
//! and takes effect immediately (drops the connection, ends every retry).
use serde::Serialize;
use tauri::{Emitter, Manager};
use crate::config;
use crate::state::AppState;
#[derive(Serialize)]
pub struct FreenetStatus {
pub enabled: bool,
pub ws_port: u16,
pub status: freenet_adapter::AdapterStatus,
/// Whether remote profile avatars may be fetched and decoded at all.
pub load_remote_images: bool,
}
/// Wire the adapter at startup: apply config (port + opt-in) and forward
/// adapter events to the webview as `freenet:status` — status frames only.
pub fn init(app: &tauri::AppHandle) {
let cfg = config::load(app);
let state = app.state::<AppState>();
let adapter = state.freenet.clone();
adapter.set_ws_port(cfg.freenet_ws_port.unwrap_or(freenet_adapter::DEFAULT_WS_PORT));
let handle = app.clone();
let mut events = adapter.subscribe();
tauri::async_runtime::spawn(async move {
loop {
match events.recv().await {
Ok(freenet_adapter::AdapterEvent::Status(s)) => {
let _ = handle.emit("freenet:status", &s);
}
// Contract updates carry ids/lengths only, and nothing in
// the UI consumes them yet — swallow rather than emit noise.
Ok(freenet_adapter::AdapterEvent::ContractUpdate { .. }) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
if cfg.freenet_enabled.unwrap_or(false) {
adapter.enable();
// The peer the app set up follows the switch (see ensure_node).
tauri::async_runtime::spawn(ensure_node(app.clone()));
}
}
#[tauri::command]
pub fn freenet_status(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> FreenetStatus {
FreenetStatus {
enabled: state.freenet.is_enabled(),
ws_port: state.freenet.ws_port(),
status: state.freenet.status(),
load_remote_images: config::load(&app).freenet_load_remote_images.unwrap_or(true),
}
}
/// The reader-side policy gate for avatars (profile.rs checks it BEFORE any
/// fetch). Off degrades every remote avatar to the identicon.
#[tauri::command]
pub fn freenet_set_load_remote_images(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
on: bool,
) -> Result<FreenetStatus, String> {
let mut cfg = config::load(&app);
cfg.freenet_load_remote_images = Some(on);
config::save(&app, &cfg);
Ok(freenet_status(app, state))
}
/// The explicit opt-in / kill switch. Persisted, then applied immediately.
#[tauri::command]
pub fn freenet_set_enabled(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
enabled: bool,
) -> Result<FreenetStatus, String> {
let mut cfg = config::load(&app);
cfg.freenet_enabled = Some(enabled);
config::save(&app, &cfg);
if enabled {
state.freenet.enable();
tauri::async_runtime::spawn(ensure_node(app.clone()));
} else {
state.freenet.disable();
}
Ok(freenet_status(app, state))
}
/// One-shot detection — works while disabled, touches no adapter state.
#[tauri::command]
pub async fn freenet_detect(
state: tauri::State<'_, AppState>,
) -> Result<freenet_adapter::AdapterStatus, String> {
Ok(state.freenet.detect().await)
}
// ---- the local peer -----------------------------------------------------------
// Starting the installed node from the app, the way the research did it by
// hand (research/freenet-f0, -f2): the peer writes its config.toml on first
// boot with telemetry ON (the alpha default, even in local mode), so a first
// start runs it only until that file exists, stops it, pins
// `telemetry-enabled = false`, and only then launches the real session. The
// peer is a detached background process — it outlives the app on purpose
// (the design's "desktop background peer") and stops with its own button.
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Serialize)]
pub struct NodeInfo {
/// Where the peer binary was found, if anywhere.
pub binary: Option<String>,
pub version: Option<String>,
pub config_dir: String,
pub data_dir: String,
pub config_exists: bool,
pub telemetry_off: bool,
/// True while a node THIS app launched is alive.
pub started_by_app: bool,
}
const TELEMETRY_ON: &str = "telemetry-enabled = true";
const TELEMETRY_OFF: &str = "telemetry-enabled = false";
const FIRST_BOOT_WAIT: Duration = Duration::from_secs(20);
struct NodeDirs {
base: PathBuf,
config: PathBuf,
data: PathBuf,
}
fn node_dirs(app: &tauri::AppHandle) -> Result<NodeDirs, String> {
let base = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("freenet");
Ok(NodeDirs { config: base.join("config"), data: base.join("data"), base })
}
/// No console window for child processes on Windows.
fn quiet(mut c: Command) -> Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
c
}
fn find_binary(app: &tauri::AppHandle) -> Option<PathBuf> {
let name = if cfg!(windows) { "freenet.exe" } else { "freenet" };
if let Ok(home) = app.path().home_dir() {
let p = home.join(".cargo").join("bin").join(name);
if p.is_file() {
return Some(p);
}
}
let on_path = quiet(Command::new("freenet"))
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
on_path.then(|| PathBuf::from("freenet"))
}
fn version_of(bin: &Path) -> Option<String> {
let out = quiet(Command::new(bin)).arg("--version").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().next()?.trim();
let v = line
.trim_start_matches("Freenet version:")
.trim()
.trim_end_matches("()")
.trim();
(!v.is_empty()).then(|| v.to_string())
}
fn telemetry_off(config: &Path) -> bool {
std::fs::read_to_string(config)
.map(|s| s.contains(TELEMETRY_OFF))
.unwrap_or(false)
}
fn pin_telemetry_off(config: &Path) -> Result<(), String> {
let s = std::fs::read_to_string(config).map_err(|e| format!("read config.toml: {e}"))?;
if s.contains(TELEMETRY_OFF) {
return Ok(());
}
if !s.contains(TELEMETRY_ON) {
return Err("config.toml has no telemetry-enabled line to pin".into());
}
std::fs::write(config, s.replace(TELEMETRY_ON, TELEMETRY_OFF))
.map_err(|e| format!("write config.toml: {e}"))
}
fn spawn_node(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result<Child, String> {
let out = std::fs::File::create(log).map_err(|e| format!("node log: {e}"))?;
let err = out.try_clone().map_err(|e| e.to_string())?;
quiet(Command::new(bin))
.arg("network")
.arg("--config-dir")
.arg(&dirs.config)
.arg("--data-dir")
.arg(&dirs.data)
.stdin(Stdio::null())
.stdout(Stdio::from(out))
.stderr(Stdio::from(err))
.spawn()
.map_err(|e| format!("start the peer: {e}"))
}
fn node_info(app: &tauri::AppHandle, state: &AppState) -> Result<NodeInfo, String> {
let dirs = node_dirs(app)?;
let bin = find_binary(app);
let config = dirs.config.join("config.toml");
let started_by_app = {
let mut guard = state.freenet_node.lock().unwrap();
match guard.as_mut().map(|c| c.try_wait()) {
Some(Ok(None)) => true,
_ => {
*guard = None;
false
}
}
};
Ok(NodeInfo {
version: bin.as_deref().and_then(version_of),
binary: bin.map(|p| p.display().to_string()),
config_dir: dirs.config.display().to_string(),
data_dir: dirs.data.display().to_string(),
config_exists: config.is_file(),
telemetry_off: telemetry_off(&config),
started_by_app,
})
}
#[tauri::command]
pub fn freenet_node_info(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<NodeInfo, String> {
node_info(&app, &state)
}
/// Start the peer under the app's folder (the first-boot recipe when its
/// config does not exist yet). Returns the running child.
async fn start_node_inner(app: &tauri::AppHandle) -> Result<Child, String> {
let handle = app.clone();
tauri::async_runtime::spawn_blocking(move || -> Result<Child, String> {
let bin = find_binary(&handle)
.ok_or("no Freenet peer found on this machine — install it (cargo install freenet) and try again")?;
let dirs = node_dirs(&handle)?;
std::fs::create_dir_all(&dirs.config).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dirs.data).map_err(|e| e.to_string())?;
let config = dirs.config.join("config.toml");
if !config.is_file() {
let first_log = dirs.base.join("first-boot.log");
let mut first = spawn_node(&bin, &dirs, &first_log)?;
let deadline = Instant::now() + FIRST_BOOT_WAIT;
while !config.is_file() && Instant::now() < deadline {
if let Ok(Some(status)) = first.try_wait() {
return Err(format!(
"the peer exited during its first boot ({status}) — see {}",
first_log.display()
));
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = first.kill();
let _ = first.wait();
if !config.is_file() {
return Err("the peer wrote no config.toml within 20s".into());
}
log::info!("freenet node: first boot wrote {}", config.display());
}
pin_telemetry_off(&config)?;
let child = spawn_node(&bin, &dirs, &dirs.base.join("node.log"))?;
log::info!("freenet node started (pid {}) from {}", child.id(), bin.display());
Ok(child)
})
.await
.map_err(|e| e.to_string())?
}
/// A peer the app set up follows the switch: whenever the layer is on and
/// the peer is absent, start it — at launch, and when the switch goes on.
/// First-time setup stays explicit (the Start button), so a user who never
/// asked for a peer never gets one; after that it is simply kept running.
/// (A dev-harness restart takes the child down with the app — observed
/// 2026-09-02 — and a reboot does the same; this is what brings it back.)
pub async fn ensure_node(app: tauri::AppHandle) {
let state = app.state::<AppState>();
if !state.freenet.is_enabled() {
return;
}
let Ok(dirs) = node_dirs(&app) else { return };
if !dirs.config.join("config.toml").is_file() {
return;
}
if state.freenet_node.lock().unwrap().is_some() {
return;
}
if !matches!(state.freenet.detect().await, freenet_adapter::AdapterStatus::NodeAbsent) {
return;
}
match start_node_inner(&app).await {
Ok(child) => *state.freenet_node.lock().unwrap() = Some(child),
Err(e) => log::warn!("freenet node: could not start the peer: {e}"),
}
}
#[tauri::command]
pub async fn freenet_node_start(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<NodeInfo, String> {
if node_info(&app, &state)?.started_by_app {
return node_info(&app, &state);
}
let child = start_node_inner(&app).await?;
*state.freenet_node.lock().unwrap() = Some(child);
node_info(&app, &state)
}
/// Stops only a node this app started; a hand-started peer is left alone.
#[tauri::command]
pub async fn freenet_node_stop(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<NodeInfo, String> {
let child = state.freenet_node.lock().unwrap().take();
if let Some(mut c) = child {
let _ = c.kill();
let _ = c.wait();
log::info!("freenet node stopped");
}
node_info(&app, &state)
}
+395
View File
@@ -0,0 +1,395 @@
//! PersonaService — the SoulStream identity, held by the bundled Freenet
//! identity delegate and reached only through the FreenetAdapter.
//!
//! The delegate wasm ships as an app asset (`assets/identity_delegate.wasm`,
//! sha256 84dd8dbb…, the generation every F1–F2b spike validated). Its key
//! namespace is (code hash, params), so THESE BYTES ARE A COMMITMENT:
//! changing them re-keys the delegate and strands every user's persona —
//! and the platform's copy-forward is disabled (GHSA-824h-7x5x-wfmf), so
//! the only migration is export-artifact → new generation → import,
//! verified by reading the identity back (F1's record). Treat any change
//! as a migration event, never a routine rebuild.
//!
//! Privilege rules, same as everywhere on this boundary:
//! - the PRIVATE key never leaves the delegate; what crosses to the webview
//! is the public key, the key version, and status strings;
//! - the passphrase-sealed recovery artifact is written/read by RUST at a
//! caller-chosen path — its bytes never transit the webview;
//! - signing stays Rust-internal (`sign` is pub(crate) for future
//! Community services, not a Tauri command).
//!
//! Wire pin: `InMsg`/`OutMsg` are bincode enums whose VARIANT ORDER must
//! match research/freenet-f1/identity-delegate/src/lib.rs exactly — bincode
//! encodes the variant index, so reordering is a silent protocol break.
use freenet_adapter::FreenetAdapter;
use serde::{Deserialize, Serialize};
use crate::state::AppState;
const DELEGATE_WASM: &[u8] = include_bytes!("../assets/identity_delegate.wasm");
const DELEGATE_PARAMS: &[u8] = b"soulstream-identity-v1";
/// Recovery-artifact KDF defaults: Argon2id inside the delegate's wasm.
/// 64 MiB / 3 passes measured comfortably inside the delegate's caps
/// (8–64 MiB, t ≤ 8 — F1). The artifact itself is 93 bytes.
const EXPORT_M_COST_KIB: u32 = 64 * 1024;
const EXPORT_T_COST: u32 = 3;
// ---- delegate protocol (bincode; variant order is the wire) ----
#[derive(Debug, Serialize, Deserialize)]
enum InMsg {
CreateIdentity { force: bool },
GetPublicKey,
Sign { domain: String, payload: Vec<u8> },
ExportIdentity { passphrase: String, m_cost_kib: u32, t_cost: u32 },
ImportIdentity { artifact: Vec<u8>, passphrase: String, force: bool },
RotateIdentity,
GetTransition,
RemoveIdentity,
}
#[derive(Debug, Serialize, Deserialize)]
enum OutMsg {
Identity { public_key: [u8; 32], key_version: u32, created: bool },
NotInitialized,
Signature { public_key: [u8; 32], key_version: u32, sig: Vec<u8> },
Artifact { bytes: Vec<u8> },
Rotated { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec<u8> },
Transition { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec<u8> },
NoTransition,
Removed,
Refused { reason: String },
}
// ---- public shapes (webview-safe: public data only) ----
#[derive(Debug, Clone, Serialize)]
pub struct PersonaInfo {
pub public_key: String,
pub key_version: u32,
}
#[derive(Debug, Serialize)]
pub struct PersonaStatus {
/// Adapter reachable and enabled — persona ops can run at all.
pub available: bool,
pub adapter: freenet_adapter::AdapterStatus,
/// None when available but no identity exists yet.
pub persona: Option<PersonaInfo>,
/// This build's delegate generation (b58 key) — diagnostics, and the
/// value that must not drift between releases.
pub delegate_key: String,
}
#[derive(Debug, Serialize)]
pub struct RotationInfo {
pub old_public: String,
pub new_public: String,
pub key_version: u32,
}
// ---- core (adapter-facing, testable without Tauri) ----
async fn call(adapter: &FreenetAdapter, msg: InMsg) -> Result<OutMsg, String> {
// Registration is idempotent and cheap; doing it per-op means the
// service self-heals across node restarts and adapter reconnects.
adapter
.delegate_register(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS)
.await
.map_err(|e| e.to_string())?;
let payload = bincode::serialize(&msg).map_err(|e| e.to_string())?;
let reply = adapter
.delegate_message(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS, payload)
.await
.map_err(|e| e.to_string())?;
bincode::deserialize(&reply).map_err(|e| format!("delegate reply did not decode: {e}"))
}
fn info(public_key: [u8; 32], key_version: u32) -> PersonaInfo {
PersonaInfo {
public_key: bs58::encode(public_key).into_string(),
key_version,
}
}
pub(crate) async fn current(adapter: &FreenetAdapter) -> Result<Option<PersonaInfo>, String> {
match call(adapter, InMsg::GetPublicKey).await? {
OutMsg::Identity { public_key, key_version, .. } => Ok(Some(info(public_key, key_version))),
OutMsg::NotInitialized => Ok(None),
OutMsg::Refused { reason } => Err(reason),
other => Err(format!("unexpected delegate reply: {other:?}")),
}
}
pub(crate) async fn create(adapter: &FreenetAdapter) -> Result<PersonaInfo, String> {
match call(adapter, InMsg::CreateIdentity { force: false }).await? {
OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)),
OutMsg::Refused { reason } => Err(reason),
other => Err(format!("unexpected delegate reply: {other:?}")),
}
}
/// Seal the identity to a passphrase and write the artifact at `path`.
/// The bytes go disk-direct from Rust; nothing crosses the webview.
pub(crate) async fn export_to(
adapter: &FreenetAdapter,
passphrase: &str,
path: &str,
) -> Result<usize, String> {
match call(
adapter,
InMsg::ExportIdentity {
passphrase: passphrase.to_string(),
m_cost_kib: EXPORT_M_COST_KIB,
t_cost: EXPORT_T_COST,
},
)
.await?
{
OutMsg::Artifact { bytes } => {
let n = bytes.len();
std::fs::write(path, bytes).map_err(|e| format!("writing artifact: {e}"))?;
Ok(n)
}
OutMsg::NotInitialized => Err("no persona exists yet".into()),
OutMsg::Refused { reason } => Err(reason),
other => Err(format!("unexpected delegate reply: {other:?}")),
}
}
/// Restore from a recovery artifact file. Refuses to overwrite an existing
/// persona unless `force` — same trust-reset semantics the F1 record set.
pub(crate) async fn import_from(
adapter: &FreenetAdapter,
passphrase: &str,
path: &str,
force: bool,
) -> Result<PersonaInfo, String> {
let artifact = std::fs::read(path).map_err(|e| format!("reading artifact: {e}"))?;
match call(
adapter,
InMsg::ImportIdentity { artifact, passphrase: passphrase.to_string(), force },
)
.await?
{
OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)),
OutMsg::Refused { reason } => Err(reason),
other => Err(format!("unexpected delegate reply: {other:?}")),
}
}
pub(crate) async fn rotate(adapter: &FreenetAdapter) -> Result<RotationInfo, String> {
match call(adapter, InMsg::RotateIdentity).await? {
OutMsg::Rotated { old_public, new_public, key_version, .. } => Ok(RotationInfo {
old_public: bs58::encode(old_public).into_string(),
new_public: bs58::encode(new_public).into_string(),
key_version,
}),
OutMsg::NotInitialized => Err("no persona exists yet".into()),
OutMsg::Refused { reason } => Err(reason),
other => Err(format!("unexpected delegate reply: {other:?}")),
}
}
pub(crate) async fn remove(adapter: &FreenetAdapter) -> Result<(), String> {
match call(adapter, InMsg::RemoveIdentity).await? {
OutMsg::Removed => Ok(()),
OutMsg::Refused { reason } => Err(reason),
other => Err(format!("unexpected delegate reply: {other:?}")),
}
}
/// Domain-separated signing for the Rust services (profiles today;
/// playlists, links next). Deliberately not a Tauri command.
pub(crate) async fn sign(
adapter: &FreenetAdapter,
domain: &str,
payload: Vec<u8>,
) -> Result<Vec<u8>, String> {
match call(adapter, InMsg::Sign { domain: domain.to_string(), payload }).await? {
OutMsg::Signature { sig, .. } => Ok(sig),
OutMsg::NotInitialized => Err("no persona exists yet".into()),
OutMsg::Refused { reason } => Err(reason),
other => Err(format!("unexpected delegate reply: {other:?}")),
}
}
// ---- Tauri surface ----
#[tauri::command]
pub async fn persona_status(state: tauri::State<'_, AppState>) -> Result<PersonaStatus, String> {
let adapter = state.freenet.clone();
let delegate_key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS);
let adapter_status = adapter.status();
let available = adapter.is_enabled()
&& adapter_status == freenet_adapter::AdapterStatus::Connected;
let persona = if available { current(&adapter).await.ok().flatten() } else { None };
Ok(PersonaStatus { available, adapter: adapter_status, persona, delegate_key })
}
#[tauri::command]
pub async fn persona_create(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<PersonaInfo, String> {
let info = create(&state.freenet.clone()).await?;
// Minted here: the profile ledger knows nothing can exist on the
// network yet, so no search is ever spent looking for it.
crate::profile::ledger_note_persona(&app, &info.public_key, true);
Ok(info)
}
#[tauri::command]
pub async fn persona_export(
state: tauri::State<'_, AppState>,
passphrase: String,
path: String,
) -> Result<usize, String> {
export_to(&state.freenet.clone(), &passphrase, &path).await
}
#[tauri::command]
pub async fn persona_import(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
passphrase: String,
path: String,
force: bool,
) -> Result<PersonaInfo, String> {
let info = import_from(&state.freenet.clone(), &passphrase, &path, force).await?;
// Imported: another device may have published — the first read asks.
crate::profile::ledger_note_persona(&app, &info.public_key, false);
Ok(info)
}
#[tauri::command]
pub async fn persona_rotate(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<RotationInfo, String> {
let info = rotate(&state.freenet.clone()).await?;
// A new key addresses a new profile contract: nothing published there yet.
crate::profile::ledger_note_persona(&app, &info.new_public, true);
crate::profile::ledger_forget_persona(&app, &info.old_public);
Ok(info)
}
#[tauri::command]
pub async fn persona_remove(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
let adapter = state.freenet.clone();
let gone = current(&adapter).await.ok().flatten();
remove(&adapter).await?;
if let Some(info) = gone {
crate::profile::ledger_forget_persona(&app, &info.public_key);
}
Ok(())
}
// ---- tests ----
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_shapes_roundtrip() {
// The variant ORDER is the wire; this pins encode/decode symmetry
// for the message we depend on most.
let msg = InMsg::ImportIdentity {
artifact: vec![1, 2, 3],
passphrase: "pw".into(),
force: true,
};
let bytes = bincode::serialize(&msg).unwrap();
// Variant index 4 (0-based) leads the encoding as a u32.
assert_eq!(&bytes[..4], &4u32.to_le_bytes());
let out = OutMsg::Identity { public_key: [7; 32], key_version: 3, created: false };
let bytes = bincode::serialize(&out).unwrap();
let back: OutMsg = bincode::deserialize(&bytes).unwrap();
assert!(matches!(back, OutMsg::Identity { key_version: 3, .. }));
}
#[test]
fn bundled_delegate_generation_is_pinned() {
// If this changes, a migration event is happening — see the module
// docs. Update deliberately, never in passing.
let key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS);
// The gen-2 identity delegate — the generation every F2→F2b spike
// validated (research/freenet-f1's upgrade probe minted it).
assert_eq!(key, "EtHTvPUtxbZ8TXGZJrmkj21xjQNqVmWdWAohVgydBgst");
assert_eq!(DELEGATE_WASM.len(), 202026);
}
#[tokio::test]
async fn ops_refuse_while_disabled() {
let adapter = FreenetAdapter::default();
let err = current(&adapter).await.unwrap_err();
assert!(err.contains("disabled"), "{err}");
}
/// Live: needs a local freenet node on 7509.
/// cargo test -p app --release persona_live -- --ignored
/// Mutates the node's persona namespace (create/remove/rotate) — run
/// against a throwaway node, not one holding an identity you keep.
#[tokio::test]
#[ignore = "needs a local freenet node on 7509; mutates its persona"]
async fn persona_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());
}
// Clean slate, then the full lifecycle.
let _ = remove(&adapter).await;
assert!(current(&adapter).await.unwrap().is_none());
let created = create(&adapter).await.expect("create");
assert_eq!(created.key_version, 1);
// No silent overwrite.
let dup = create(&adapter).await;
assert!(dup.is_err());
// Export → remove → import reproduces the fingerprint.
let dir = std::env::temp_dir().join("soulstream-persona-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("recovery.ssid");
let path_str = path.to_string_lossy().to_string();
let n = export_to(&adapter, "correct horse battery", &path_str).await.expect("export");
assert_eq!(n, 93, "the artifact is 93 bytes by construction");
remove(&adapter).await.expect("remove");
let restored = import_from(&adapter, "correct horse battery", &path_str, false)
.await
.expect("import");
assert_eq!(restored.public_key, created.public_key);
// Wrong passphrase refuses.
let bad = import_from(&adapter, "wrong passphrase!", &path_str, true).await;
assert!(bad.is_err());
// Rotation bumps the version and changes the key.
let rot = rotate(&adapter).await.expect("rotate");
assert_eq!(rot.old_public, created.public_key);
assert_eq!(rot.key_version, 2);
let now = current(&adapter).await.unwrap().unwrap();
assert_eq!(now.public_key, rot.new_public);
// Signing works for Rust callers under an allowed domain.
let sig = sign(&adapter, "soulstream.profile/v1", b"payload".to_vec())
.await
.expect("sign");
assert_eq!(sig.len(), 64);
// ...and refuses an unapproved domain with a typed reason.
let refused = sign(&adapter, "evil.domain/v1", b"payload".to_vec()).await;
assert!(refused.is_err());
adapter.disable();
let _ = std::fs::remove_file(&path);
}
}
+786
View File
@@ -0,0 +1,786 @@
//! 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 tauri::Manager;
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 = app
.path()
.app_data_dir()
.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);
}
}
+6
View File
@@ -9,4 +9,10 @@ pub struct AppState {
/// never routes it — so the resolver strips these terms up front instead
/// of discovering the silence by timeout. Lowercased on receipt.
pub excluded_search_phrases: Mutex<Vec<String>>,
/// The experimental Freenet layer's one handle (default: disabled, inert).
/// The privilege boundary: node operations never leave the Rust side.
pub freenet: freenet_adapter::FreenetAdapter,
/// The local Freenet peer THIS app launched (freenet.rs), if any. A node
/// started by hand is not here — the adapter finds it all the same.
pub freenet_node: std::sync::Mutex<Option<std::process::Child>>,
}