the freenet layer: adapter, persona and profile services, the contracts bundled as commitments
This commit is contained in:
1 parent
63a0357651
commit
6dce4e2ac7
16 files changed
+3337
-10
No files matched your search
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "freenet-adapter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# The narrow, versioned boundary around a LOCAL Freenet node
|
||||
# (TODO-Feature-Docs/soulseek-social-chat-and-sharing/, supplement design
|
||||
# §5.3), hardened by the F0–F7+F2b spike records in research/freenet-*/.
|
||||
#
|
||||
# Pins: freenet-stdlib =0.8.5 is what node 0.2.132 speaks (bincode wire);
|
||||
# tokio-tungstenite must match the stdlib's minor or the stream type the
|
||||
# WebApi takes will not unify. Bump BOTH together, against a tested node.
|
||||
|
||||
[dependencies]
|
||||
freenet-stdlib = { version = "=0.8.5", features = ["net"] }
|
||||
tokio-tungstenite = "0.27.0"
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
log.workspace = true
|
||||
thiserror.workspace = true
|
||||
bs58 = "0.5"
|
||||
@@ -0,0 +1,789 @@
|
||||
//! The FreenetAdapter — SoulStream's ONLY door to a local Freenet node.
|
||||
//!
|
||||
//! Everything here follows the supplement design (§5.3) and the rules the
|
||||
//! F0–F7+F2b spikes recorded (research/freenet-*/RUN-RECORD.md):
|
||||
//!
|
||||
//! - **Off by default, kill-switchable.** `enable()` is the only thing that
|
||||
//! spawns work; `disable()` stops the supervisor, drops the connection,
|
||||
//! and ends every retry. A missing/stopped/incompatible node must never
|
||||
//! affect Soulseek or playback — nothing in here is on any audio path.
|
||||
//! - **The privilege boundary is this crate.** A loopback Freenet API is
|
||||
//! fully privileged and unauthenticated (F0): whatever can reach it owns
|
||||
//! the node. The webview therefore gets STATUS ONLY through the Tauri
|
||||
//! layer — never a node handle, never raw state bytes.
|
||||
//! - **Detection is cheap and three-valued** (absent / occupied-not-api /
|
||||
//! compatible), with its own short timeouts: a refused loopback connect
|
||||
//! costs ~2 s on Windows (F0), so the TCP probe gets 600 ms. A TYPED host
|
||||
//! error counts as compatible — local mode answers `ConnectedPeers` with
|
||||
//! "not supported" (F0), so refusals prove the protocol.
|
||||
//! - **One request in flight per connection.** The stdlib's `WebApi`
|
||||
//! correlates responses FIFO; the session serializes ops and routes
|
||||
//! unsolicited `UpdateNotification`s to a bounded event bus (lagging
|
||||
//! subscribers lose oldest events, honestly, rather than growing queues).
|
||||
//! - **Write rules from the spikes:** a node refuses the FIRST write to a
|
||||
//! contract it never held ("auto-fetch triggered", F5) — `update` retries
|
||||
//! over ~18 s and callers should prime with a GET (`get_then_update`);
|
||||
//! sequence-bearing states must be read-and-bumped by the caller (F5);
|
||||
//! a restarted node drops out of update propagation (F2) — services
|
||||
//! reconcile by relaying their own state after reconnect.
|
||||
//! - **Redacted diagnostics.** Logs carry ids, lengths and outcomes; never
|
||||
//! state bytes, never key material.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use freenet_stdlib::client_api::{
|
||||
ClientRequest, ContractRequest, ContractResponse, HostResponse, NodeQuery, WebApi,
|
||||
};
|
||||
use freenet_stdlib::prelude::*;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, watch};
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub const DEFAULT_WS_PORT: u16 = 7509;
|
||||
|
||||
/// TCP probe budget — see the F0 note about Windows' 2 s refusal cost.
|
||||
const DETECT_TCP_TIMEOUT: Duration = Duration::from_millis(600);
|
||||
const DETECT_WS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const DETECT_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const OP_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const PUT_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
/// "auto-fetch triggered" retry schedule (F5): 6 attempts, 3 s apart.
|
||||
const AUTOFETCH_RETRIES: u32 = 6;
|
||||
const AUTOFETCH_BACKOFF: Duration = Duration::from_secs(3);
|
||||
/// Reconnect backoff while enabled but the node is missing/lost.
|
||||
const SUPERVISOR_BACKOFF: Duration = Duration::from_secs(5);
|
||||
const CMD_QUEUE: usize = 32;
|
||||
const EVENT_QUEUE: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum AdapterStatus {
|
||||
/// The kill switch position. Nothing runs, nothing retries.
|
||||
Disabled,
|
||||
Detecting,
|
||||
/// Nothing listens on the port. NOT an error — Freenet is optional.
|
||||
NodeAbsent,
|
||||
/// Something listens but does not speak the client API.
|
||||
NodeOccupied,
|
||||
/// Speaks WebSocket but the protocol exchange failed undecipherably.
|
||||
NodeIncompatible { detail: String },
|
||||
Connected,
|
||||
/// Connection lost while enabled; the supervisor is retrying.
|
||||
Lost { detail: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum AdapterEvent {
|
||||
Status(AdapterStatus),
|
||||
/// A subscribed contract changed. Ids and lengths only — the webview
|
||||
/// never receives replicated bytes through the event path.
|
||||
ContractUpdate { instance: String, update_len: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AdapterError {
|
||||
#[error("freenet layer is disabled")]
|
||||
Disabled,
|
||||
#[error("freenet node is not connected ({0:?})")]
|
||||
NotConnected(AdapterStatus),
|
||||
#[error("request timed out")]
|
||||
Timeout,
|
||||
#[error("adapter is shutting down")]
|
||||
ShuttingDown,
|
||||
#[error("node refused: {0}")]
|
||||
Node(String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, AdapterError>;
|
||||
|
||||
/// What `contract_put`/`update` return so callers can address the instance
|
||||
/// later without recomputing.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContractIds {
|
||||
pub instance: String,
|
||||
pub code_hash: String,
|
||||
}
|
||||
|
||||
struct Cmd {
|
||||
req: ClientRequest<'static>,
|
||||
reply: oneshot::Sender<Result<HostResponse>>,
|
||||
op_timeout: Duration,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
ws_port: StdMutex<u16>,
|
||||
status: StdMutex<AdapterStatus>,
|
||||
events: broadcast::Sender<AdapterEvent>,
|
||||
/// Sender into the LIVE session, if any.
|
||||
session: StdMutex<Option<mpsc::Sender<Cmd>>>,
|
||||
/// Kill switch: flipping to false ends the supervisor and its session.
|
||||
enabled: watch::Sender<bool>,
|
||||
/// Distinguishes supervisor generations so a stale task can't clobber
|
||||
/// the status of a newer enable().
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FreenetAdapter {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl Default for FreenetAdapter {
|
||||
fn default() -> Self {
|
||||
let (enabled, _) = watch::channel(false);
|
||||
let (events, _) = broadcast::channel(EVENT_QUEUE);
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
ws_port: StdMutex::new(DEFAULT_WS_PORT),
|
||||
status: StdMutex::new(AdapterStatus::Disabled),
|
||||
events,
|
||||
session: StdMutex::new(None),
|
||||
enabled,
|
||||
generation: AtomicU64::new(0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn on the caller's tokio runtime when there is one (async commands,
|
||||
/// tests), else on a runtime the adapter owns. `enable()` is reached from
|
||||
/// Tauri's event-loop thread — the setup hook, sync commands — where a bare
|
||||
/// `tokio::spawn` is a non-unwinding panic that takes the whole process down
|
||||
/// (0xc0000409, seen live 2026-09-02 on the first toggle of the switch).
|
||||
fn spawn_anywhere<F>(fut: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => {
|
||||
handle.spawn(fut);
|
||||
}
|
||||
Err(_) => {
|
||||
static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
|
||||
let rt = RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("freenet-adapter")
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("freenet adapter runtime")
|
||||
});
|
||||
rt.spawn(fut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl FreenetAdapter {
|
||||
pub fn set_ws_port(&self, port: u16) {
|
||||
*self.inner.ws_port.lock().unwrap() = port;
|
||||
}
|
||||
|
||||
pub fn ws_port(&self) -> u16 {
|
||||
*self.inner.ws_port.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn status(&self) -> AdapterStatus {
|
||||
self.inner.status.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
*self.inner.enabled.borrow()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<AdapterEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
fn set_status(&self, generation: u64, s: AdapterStatus) {
|
||||
if self.inner.generation.load(Ordering::SeqCst) != generation {
|
||||
return; // a newer enable()/disable() owns the status now
|
||||
}
|
||||
*self.inner.status.lock().unwrap() = s.clone();
|
||||
let _ = self.inner.events.send(AdapterEvent::Status(s));
|
||||
}
|
||||
|
||||
/// Turn the layer on: spawns the supervisor, which detects, connects,
|
||||
/// and reconnects with backoff until `disable()`.
|
||||
pub fn enable(&self) {
|
||||
if self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let generation = self.inner.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.inner.enabled.send_replace(true);
|
||||
let this = self.clone();
|
||||
spawn_anywhere(async move {
|
||||
this.supervisor(generation).await;
|
||||
});
|
||||
log::info!("freenet adapter enabled (gen {generation})");
|
||||
}
|
||||
|
||||
/// The kill switch. Ends the supervisor, drops the session, stops every
|
||||
/// retry. Callers see `Disabled` errors immediately.
|
||||
pub fn disable(&self) {
|
||||
let generation = self.inner.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.inner.enabled.send_replace(false);
|
||||
*self.inner.session.lock().unwrap() = None;
|
||||
*self.inner.status.lock().unwrap() = AdapterStatus::Disabled;
|
||||
let _ = self.inner.events.send(AdapterEvent::Status(AdapterStatus::Disabled));
|
||||
log::info!("freenet adapter disabled (gen {generation})");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ detect
|
||||
|
||||
/// Three-valued node detection. Usable while disabled (it opens its own
|
||||
/// short-lived connection and touches no adapter state).
|
||||
pub async fn detect(&self) -> AdapterStatus {
|
||||
let port = self.ws_port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
match timeout(DETECT_TCP_TIMEOUT, tokio::net::TcpStream::connect(&addr)).await {
|
||||
Err(_) | Ok(Err(_)) => return AdapterStatus::NodeAbsent,
|
||||
Ok(Ok(stream)) => drop(stream),
|
||||
}
|
||||
let mut api = match timeout(DETECT_WS_TIMEOUT, connect_ws(port)).await {
|
||||
Err(_) => return AdapterStatus::NodeOccupied,
|
||||
Ok(Err(_)) => return AdapterStatus::NodeOccupied,
|
||||
Ok(Ok(api)) => api,
|
||||
};
|
||||
if api
|
||||
.send(ClientRequest::NodeQueries(NodeQuery::ConnectedPeers))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return AdapterStatus::NodeIncompatible { detail: "send failed".into() };
|
||||
}
|
||||
match timeout(DETECT_QUERY_TIMEOUT, api.recv()).await {
|
||||
Ok(Ok(_)) => AdapterStatus::Connected,
|
||||
// A decoded, typed refusal IS protocol proof (local mode answers
|
||||
// ConnectedPeers with "not supported" — F0).
|
||||
Ok(Err(e)) if e.to_string().contains("not supported") => AdapterStatus::Connected,
|
||||
Ok(Err(e)) => AdapterStatus::NodeIncompatible { detail: truncate(&e.to_string(), 120) },
|
||||
Err(_) => AdapterStatus::NodeIncompatible { detail: "no reply to health query".into() },
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ supervisor
|
||||
|
||||
async fn supervisor(&self, generation: u64) {
|
||||
let mut enabled_rx = self.inner.enabled.subscribe();
|
||||
loop {
|
||||
if self.inner.generation.load(Ordering::SeqCst) != generation || !*enabled_rx.borrow() {
|
||||
return;
|
||||
}
|
||||
self.set_status(generation, AdapterStatus::Detecting);
|
||||
let detected = self.detect().await;
|
||||
match detected {
|
||||
AdapterStatus::Connected => {}
|
||||
other => {
|
||||
self.set_status(generation, other);
|
||||
if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let port = self.ws_port();
|
||||
let api = match timeout(DETECT_WS_TIMEOUT, connect_ws(port)).await {
|
||||
Ok(Ok(api)) => api,
|
||||
_ => {
|
||||
self.set_status(generation, AdapterStatus::Lost { detail: "connect failed".into() });
|
||||
if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (tx, rx) = mpsc::channel::<Cmd>(CMD_QUEUE);
|
||||
*self.inner.session.lock().unwrap() = Some(tx);
|
||||
self.set_status(generation, AdapterStatus::Connected);
|
||||
log::info!("freenet adapter connected to 127.0.0.1:{port}");
|
||||
let reason = self.run_session(api, rx, &mut enabled_rx).await;
|
||||
*self.inner.session.lock().unwrap() = None;
|
||||
if self.inner.generation.load(Ordering::SeqCst) != generation || !*enabled_rx.borrow() {
|
||||
return;
|
||||
}
|
||||
self.set_status(generation, AdapterStatus::Lost { detail: truncate(&reason, 120) });
|
||||
log::warn!("freenet session ended: {}", truncate(&reason, 120));
|
||||
if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection's lifetime. STRICTLY one request in flight (the wire
|
||||
/// correlates FIFO); notifications stream out between and during ops.
|
||||
async fn run_session(
|
||||
&self,
|
||||
mut api: WebApi,
|
||||
mut rx: mpsc::Receiver<Cmd>,
|
||||
enabled_rx: &mut watch::Receiver<bool>,
|
||||
) -> String {
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = enabled_rx.changed() => {
|
||||
if changed.is_err() || !*enabled_rx.borrow() {
|
||||
return "disabled".into();
|
||||
}
|
||||
}
|
||||
maybe_cmd = rx.recv() => {
|
||||
let Some(cmd) = maybe_cmd else { return "handle dropped".into() };
|
||||
if api.send(cmd.req).await.is_err() {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Node("send failed".into())));
|
||||
return "send failed".into();
|
||||
}
|
||||
// Await THIS op's response; unsolicited notifications
|
||||
// arriving in between are forwarded, not misdelivered.
|
||||
let deadline = tokio::time::Instant::now() + cmd.op_timeout;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if left.is_zero() {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Timeout));
|
||||
break;
|
||||
}
|
||||
match timeout(left, api.recv()).await {
|
||||
Err(_) => {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Timeout));
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Node(e.to_string())));
|
||||
break;
|
||||
}
|
||||
Ok(Ok(HostResponse::ContractResponse(
|
||||
ContractResponse::UpdateNotification { key, update },
|
||||
))) => {
|
||||
self.forward_notification(&key, &update);
|
||||
continue;
|
||||
}
|
||||
Ok(Ok(resp)) => {
|
||||
let _ = cmd.reply.send(Ok(resp));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
streamed = api.recv() => {
|
||||
// Idle traffic: subscription notifications, or the end
|
||||
// of the connection.
|
||||
match streamed {
|
||||
Ok(HostResponse::ContractResponse(
|
||||
ContractResponse::UpdateNotification { key, update },
|
||||
)) => self.forward_notification(&key, &update),
|
||||
Ok(_) => { /* stray late response; correlation is FIFO, drop */ }
|
||||
Err(e) => return e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_notification(&self, key: &ContractKey, update: &UpdateData<'static>) {
|
||||
let len = match update {
|
||||
UpdateData::State(s) => s.as_ref().len(),
|
||||
UpdateData::Delta(d) => d.as_ref().len(),
|
||||
UpdateData::StateAndDelta { state, delta } => state.as_ref().len() + delta.as_ref().len(),
|
||||
_ => 0,
|
||||
};
|
||||
// Bounded broadcast: lagging receivers lose oldest events. Honest.
|
||||
let _ = self.inner.events.send(AdapterEvent::ContractUpdate {
|
||||
instance: key.id().encode(),
|
||||
update_len: len,
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ops
|
||||
|
||||
async fn request(&self, req: ClientRequest<'static>, op_timeout: Duration) -> Result<HostResponse> {
|
||||
if !self.is_enabled() {
|
||||
return Err(AdapterError::Disabled);
|
||||
}
|
||||
let tx = {
|
||||
let guard = self.inner.session.lock().unwrap();
|
||||
guard.clone()
|
||||
};
|
||||
let Some(tx) = tx else {
|
||||
return Err(AdapterError::NotConnected(self.status()));
|
||||
};
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
tx.send(Cmd { req, reply: reply_tx, op_timeout })
|
||||
.await
|
||||
.map_err(|_| AdapterError::ShuttingDown)?;
|
||||
reply_rx.await.map_err(|_| AdapterError::ShuttingDown)?
|
||||
}
|
||||
|
||||
fn container(code: Vec<u8>, params: &[u8]) -> (ContractContainer, ContractKey) {
|
||||
let params = Parameters::from(params.to_vec()).into_owned();
|
||||
let c = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
|
||||
Arc::new(ContractCode::from(code)),
|
||||
params,
|
||||
)));
|
||||
let k = c.key();
|
||||
(c, k)
|
||||
}
|
||||
|
||||
/// Compute the ids a (code, params) pair addresses — no network.
|
||||
pub fn contract_ids(code: Vec<u8>, params: &[u8]) -> ContractIds {
|
||||
let (_, key) = Self::container(code, params);
|
||||
ContractIds {
|
||||
instance: key.id().encode(),
|
||||
code_hash: key.encoded_code_hash(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn contract_put(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
state: Vec<u8>,
|
||||
subscribe: bool,
|
||||
) -> Result<ContractIds> {
|
||||
let (container, key) = Self::container(code, params);
|
||||
let ids = ContractIds {
|
||||
instance: key.id().encode(),
|
||||
code_hash: key.encoded_code_hash(),
|
||||
};
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Put {
|
||||
contract: container,
|
||||
state: WrappedState::new(state),
|
||||
related_contracts: RelatedContracts::new(),
|
||||
subscribe,
|
||||
blocking_subscribe: false,
|
||||
}
|
||||
.into(),
|
||||
PUT_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::ContractResponse(ContractResponse::PutResponse { .. }) => Ok(ids),
|
||||
other => Err(unexpected("PutResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn contract_get(
|
||||
&self,
|
||||
instance_b58: &str,
|
||||
subscribe: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
let id = parse_instance(instance_b58)?;
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Get {
|
||||
key: id,
|
||||
return_contract_code: false,
|
||||
subscribe,
|
||||
blocking_subscribe: false,
|
||||
}
|
||||
.into(),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
// Depending on version, a never-put contract comes back either
|
||||
// as a typed NotFound or as an error frame "missing contract:
|
||||
// <id>" (observed on 0.2.132 local mode). Fold both into one
|
||||
// shape so services can match on "contract not found" alone.
|
||||
.map_err(|e| match e {
|
||||
AdapterError::Node(msg) if msg.contains("missing contract") => {
|
||||
AdapterError::Node("contract not found".into())
|
||||
}
|
||||
e => e,
|
||||
})?;
|
||||
match resp {
|
||||
HostResponse::ContractResponse(ContractResponse::GetResponse { state, .. }) => {
|
||||
Ok(state.to_vec())
|
||||
}
|
||||
HostResponse::ContractResponse(ContractResponse::NotFound { .. }) => {
|
||||
Err(AdapterError::Node("contract not found".into()))
|
||||
}
|
||||
other => Err(unexpected("GetResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn contract_subscribe(&self, instance_b58: &str) -> Result<bool> {
|
||||
let id = parse_instance(instance_b58)?;
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Subscribe { key: id, summary: None }.into(),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::ContractResponse(ContractResponse::SubscribeResponse {
|
||||
subscribed,
|
||||
..
|
||||
}) => Ok(subscribed),
|
||||
other => Err(unexpected("SubscribeResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// UPDATE with the recorded first-write behavior handled: a node that
|
||||
/// never held the contract answers "auto-fetch triggered" and asks the
|
||||
/// client to retry; we do, on the spike's schedule. Returns the
|
||||
/// post-merge summary bytes.
|
||||
pub async fn contract_update(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
delta: Vec<u8>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (_, key) = Self::container(code, params);
|
||||
let mut last: Option<AdapterError> = None;
|
||||
for attempt in 0..AUTOFETCH_RETRIES {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(AUTOFETCH_BACKOFF).await;
|
||||
if !self.is_enabled() {
|
||||
return Err(AdapterError::Disabled); // kill switch ends retries
|
||||
}
|
||||
}
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Update {
|
||||
key,
|
||||
data: UpdateData::Delta(StateDelta::from(delta.clone())),
|
||||
}
|
||||
.into(),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
match resp {
|
||||
Ok(HostResponse::ContractResponse(ContractResponse::UpdateResponse {
|
||||
summary,
|
||||
..
|
||||
})) => return Ok(summary.as_ref().to_vec()),
|
||||
Ok(other) => return Err(unexpected("UpdateResponse", &other)),
|
||||
Err(AdapterError::Node(msg)) if msg.contains("auto-fetch triggered") => {
|
||||
log::info!(
|
||||
"freenet: first-write priming on attempt {attempt} (auto-fetch in flight)"
|
||||
);
|
||||
last = Some(AdapterError::Node(msg));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or(AdapterError::Timeout))
|
||||
}
|
||||
|
||||
/// The reliable first-write recipe from F5: one GET primes code+state on
|
||||
/// this node, then the update applies. Use for a contract this node may
|
||||
/// never have held.
|
||||
pub async fn get_then_update(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
delta: Vec<u8>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let ids = Self::contract_ids(code.clone(), params);
|
||||
let _ = self.contract_get(&ids.instance, false).await; // prime; absence is fine
|
||||
self.contract_update(code, params, delta).await
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ delegates
|
||||
|
||||
fn delegate_container(code: Vec<u8>, params: &[u8]) -> (DelegateContainer, DelegateKey) {
|
||||
let params = Parameters::from(params.to_vec()).into_owned();
|
||||
let dc = DelegateCode::from(code);
|
||||
let d = Delegate::from((&dc, ¶ms));
|
||||
let c = DelegateContainer::Wasm(DelegateWasmAPIVersion::V1(d));
|
||||
let k = c.key().clone();
|
||||
(c, k)
|
||||
}
|
||||
|
||||
/// Compute the delegate key a (code, params) pair addresses — no
|
||||
/// network. Lets services pin and log their delegate generation.
|
||||
pub fn delegate_key_b58(code: Vec<u8>, params: &[u8]) -> String {
|
||||
let (_, key) = Self::delegate_container(code, params);
|
||||
bs58::encode(key.bytes()).into_string()
|
||||
}
|
||||
|
||||
/// Register (idempotent per node) and return the delegate key (b58).
|
||||
/// `cipher`/`nonce` are legacy fields the node ignores (core#4140).
|
||||
pub async fn delegate_register(&self, code: Vec<u8>, params: &[u8]) -> Result<String> {
|
||||
use freenet_stdlib::client_api::DelegateRequest;
|
||||
let (container, key) = Self::delegate_container(code, params);
|
||||
let resp = self
|
||||
.request(
|
||||
ClientRequest::DelegateOp(DelegateRequest::RegisterDelegate {
|
||||
delegate: container,
|
||||
cipher: [0u8; 32],
|
||||
nonce: [0u8; 24],
|
||||
}),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::DelegateResponse { .. } => Ok(bs58::encode(key.bytes()).into_string()),
|
||||
other => Err(unexpected("DelegateResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One application message in, the delegate's app-message reply out.
|
||||
/// NOTE (F1): migration across delegate code generations must be
|
||||
/// verified by reading state back — `RegisterDelegateWithPredecessors`
|
||||
/// silently copies nothing in 0.2.132 (GHSA-824h-7x5x-wfmf).
|
||||
pub async fn delegate_message(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Vec<u8>> {
|
||||
use freenet_stdlib::client_api::DelegateRequest;
|
||||
let (_, key) = Self::delegate_container(code.clone(), params);
|
||||
let params_owned = Parameters::from(params.to_vec()).into_owned();
|
||||
let resp = self
|
||||
.request(
|
||||
ClientRequest::DelegateOp(DelegateRequest::ApplicationMessages {
|
||||
key,
|
||||
params: params_owned,
|
||||
inbound: vec![InboundDelegateMsg::ApplicationMessage(
|
||||
ApplicationMessage::new(payload),
|
||||
)],
|
||||
}),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::DelegateResponse { values, .. } => values
|
||||
.into_iter()
|
||||
.find_map(|v| match v {
|
||||
OutboundDelegateMsg::ApplicationMessage(m) => Some(m.payload),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| AdapterError::Node("no app message in delegate reply".into())),
|
||||
other => Err(unexpected("DelegateResponse", &other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
async fn connect_ws(port: u16) -> std::result::Result<WebApi, String> {
|
||||
let uri = format!("ws://127.0.0.1:{port}/v1/contract/command?encodingProtocol=native");
|
||||
let cfg = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
|
||||
.max_message_size(Some(100 * 1024 * 1024))
|
||||
.max_frame_size(Some(16 * 1024 * 1024));
|
||||
let (stream, _) = tokio_tungstenite::connect_async_with_config(&uri, Some(cfg), false)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(WebApi::start(stream))
|
||||
}
|
||||
|
||||
/// Wait out a backoff, returning true if the adapter was disabled meanwhile.
|
||||
async fn wait_or_disabled(enabled_rx: &mut watch::Receiver<bool>, dur: Duration) -> bool {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(dur) => !*enabled_rx.borrow(),
|
||||
changed = enabled_rx.changed() => changed.is_err() || !*enabled_rx.borrow(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instance(b58: &str) -> Result<ContractInstanceId> {
|
||||
ContractInstanceId::try_from(b58.to_string())
|
||||
.map_err(|e| AdapterError::Other(format!("bad instance id: {e}")))
|
||||
}
|
||||
|
||||
fn unexpected(wanted: &str, got: &HostResponse) -> AdapterError {
|
||||
// Display, not Debug: HostResponse Display is summary-shaped and does
|
||||
// not dump state bytes.
|
||||
AdapterError::Node(format!("expected {wanted}, got {}", truncate(&got.to_string(), 120)))
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut cut = n;
|
||||
while cut > 0 && !s.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
format!("{}…", &s[..cut])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The event-loop path: no tokio context at all. Must not panic, must
|
||||
/// flip enabled, and disable() must still end it.
|
||||
#[test]
|
||||
fn enable_works_outside_any_runtime() {
|
||||
std::thread::spawn(|| {
|
||||
assert!(tokio::runtime::Handle::try_current().is_err());
|
||||
let a = FreenetAdapter::default();
|
||||
a.enable();
|
||||
assert!(a.is_enabled());
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
a.disable();
|
||||
assert!(!a.is_enabled());
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
})
|
||||
.join()
|
||||
.expect("no panic on the runtime-less path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_is_the_default_and_ops_refuse() {
|
||||
let a = FreenetAdapter::default();
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
assert!(!a.is_enabled());
|
||||
let err = a.contract_get("11111111111111111111111111111111", false).await;
|
||||
assert!(matches!(err, Err(AdapterError::Disabled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_absent_on_closed_port() {
|
||||
let a = FreenetAdapter::default();
|
||||
a.set_ws_port(1); // nothing listens on port 1
|
||||
assert_eq!(a.detect().await, AdapterStatus::NodeAbsent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_occupied_on_non_api_listener() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { break };
|
||||
use tokio::io::AsyncWriteExt;
|
||||
// Answer like a confused HTTP server, then hang up.
|
||||
let _ = sock.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
}
|
||||
});
|
||||
let a = FreenetAdapter::default();
|
||||
a.set_ws_port(port);
|
||||
assert_eq!(a.detect().await, AdapterStatus::NodeOccupied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_switch_ends_everything() {
|
||||
let a = FreenetAdapter::default();
|
||||
a.set_ws_port(1);
|
||||
a.enable();
|
||||
assert!(a.is_enabled());
|
||||
// Give the supervisor a moment to enter its loop, then kill it.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
a.disable();
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
assert!(!a.is_enabled());
|
||||
let err = a.contract_get("11111111111111111111111111111111", false).await;
|
||||
assert!(matches!(err, Err(AdapterError::Disabled)));
|
||||
// A stale supervisor tick must not resurrect a status.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contract_ids_are_deterministic_offline() {
|
||||
let code = vec![0u8, 1, 2, 3];
|
||||
let a = FreenetAdapter::contract_ids(code.clone(), b"params");
|
||||
let b = FreenetAdapter::contract_ids(code, b"params");
|
||||
assert_eq!(a.instance, b.instance);
|
||||
assert_eq!(a.code_hash, b.code_hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Live integration against a REAL local Freenet node — the house pattern
|
||||
//! (`#[ignore]`d corpus tests inside the crate, run on demand):
|
||||
//!
|
||||
//! 1. start a node: freenet local local --config-dir <dir> --data-dir <dir>
|
||||
//! 2. set SOULSTREAM_F0_WASM to a contract wasm (the F0 spike's
|
||||
//! research/freenet-f0/f0-contract build works verbatim)
|
||||
//! 3. cargo test -p freenet-adapter --release -- --ignored
|
||||
//!
|
||||
//! Exercises the full adapter loop: detect → enable → put → get →
|
||||
//! subscribe → update (with the first-write priming path) → notification
|
||||
//! event → kill switch mid-life.
|
||||
|
||||
use freenet_adapter::{AdapterEvent, AdapterStatus, FreenetAdapter};
|
||||
use std::time::Duration;
|
||||
|
||||
fn wasm() -> Option<Vec<u8>> {
|
||||
let path = std::env::var("SOULSTREAM_F0_WASM").ok()?;
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a local freenet node on 7509 and SOULSTREAM_F0_WASM"]
|
||||
async fn full_loop_against_live_node() {
|
||||
let Some(code) = wasm() else {
|
||||
panic!("set SOULSTREAM_F0_WASM to a contract wasm path");
|
||||
};
|
||||
let a = FreenetAdapter::default();
|
||||
|
||||
// Detection first — a wrong environment should fail here, loudly.
|
||||
let detected = a.detect().await;
|
||||
assert_eq!(detected, AdapterStatus::Connected, "node not reachable: {detected:?}");
|
||||
|
||||
a.enable();
|
||||
// Wait for the supervisor to connect.
|
||||
let mut tries = 0;
|
||||
while a.status() != AdapterStatus::Connected {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tries += 1;
|
||||
assert!(tries < 50, "supervisor never connected: {:?}", a.status());
|
||||
}
|
||||
|
||||
// A fresh instance per run: params carry a nonce.
|
||||
let params = format!("adapter-live-{}", std::process::id()).into_bytes();
|
||||
let ids = FreenetAdapter::contract_ids(code.clone(), ¶ms);
|
||||
|
||||
// PUT initial state.
|
||||
let put_ids = a
|
||||
.contract_put(code.clone(), ¶ms, br#"["alpha"]"#.to_vec(), false)
|
||||
.await
|
||||
.expect("put");
|
||||
assert_eq!(put_ids.instance, ids.instance);
|
||||
|
||||
// GET it back.
|
||||
let state = a.contract_get(&ids.instance, false).await.expect("get");
|
||||
assert_eq!(state, br#"["alpha"]"#);
|
||||
|
||||
// SUBSCRIBE, then watch for the update notification through the
|
||||
// adapter's bounded event bus.
|
||||
let mut events = a.subscribe();
|
||||
assert!(a.contract_subscribe(&ids.instance).await.expect("subscribe"));
|
||||
|
||||
// UPDATE via the primed path (exercises get_then_update's shape even
|
||||
// though this node already holds the contract).
|
||||
let summary = a
|
||||
.get_then_update(code.clone(), ¶ms, br#"["beta"]"#.to_vec())
|
||||
.await
|
||||
.expect("update");
|
||||
assert!(!summary.is_empty(), "update summary should carry bytes");
|
||||
|
||||
// The merged state must be the union.
|
||||
let state = a.contract_get(&ids.instance, false).await.expect("get2");
|
||||
assert_eq!(state, br#"["alpha","beta"]"#);
|
||||
|
||||
// The notification should have crossed the event bus (ids only).
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
let mut saw_update = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_secs(1), events.recv()).await {
|
||||
Ok(Ok(AdapterEvent::ContractUpdate { instance, .. })) if instance == ids.instance => {
|
||||
saw_update = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(saw_update, "no ContractUpdate event reached the bus");
|
||||
|
||||
// Kill switch mid-life: immediate, and ops refuse afterwards.
|
||||
a.disable();
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
let err = a.contract_get(&ids.instance, false).await;
|
||||
assert!(matches!(err, Err(freenet_adapter::AdapterError::Disabled)));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "persona-state"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# The persona profile + avatar-asset state model — the byte-exact shapes
|
||||
# the bundled persona/media contract wasms validate (their sources are the
|
||||
# F2/F2b spike crates; these definitions must stay bincode-identical to
|
||||
# them, field by field, or published state stops verifying).
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
bincode = "1"
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] }
|
||||
blake3 = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Persona profile + avatar-asset state — the shapes the bundled Freenet
|
||||
//! contracts validate.
|
||||
//!
|
||||
//! BYTE-COMPATIBILITY IS THE CONTRACT. `ProfileState` and `MediaAssetV1`
|
||||
//! must stay bincode-identical (field order, types, sig-body layouts) to
|
||||
//! the wasm bundled in `src-tauri/assets/` (built from the F2/F2b spike
|
||||
//! sources, keys pinned by tests there). Change anything here and every
|
||||
//! published profile stops verifying — that is a migration event.
|
||||
//!
|
||||
//! Two signing conventions coexist under `soulstream.profile/v1` (an F2b
|
||||
//! record): PROFILES sign their RAW sig-body; ASSETS sign blake3(sig-body).
|
||||
//! `profile_sign_payload` / `asset_sign_payload` return exactly what the
|
||||
//! identity delegate must be handed for each.
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||
|
||||
pub const PROFILE_PARAMS_FAMILY: &[u8] = b"soulstream.persona.v1";
|
||||
pub const MEDIA_PARAMS_FAMILY: &[u8] = b"soulstream.media.v1";
|
||||
|
||||
pub const MAX_PROFILE_BYTES: usize = 4 * 1024;
|
||||
pub const MAX_NAME_BYTES: usize = 64;
|
||||
pub const MAX_BIO_BYTES: usize = 280;
|
||||
|
||||
/// v1 avatar rendition: one square PNG.
|
||||
pub const V1_MEDIA_TYPE: &str = "image/png";
|
||||
pub const V1_DIMENSION: u32 = 256;
|
||||
pub const MAX_MEDIA_BYTES: usize = 96 * 1024;
|
||||
pub const MAX_MEDIA_STATE_BYTES: usize = 128 * 1024;
|
||||
|
||||
// ------------------------------------------------------------------ profile
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MediaRefV1 {
|
||||
pub content_hash: [u8; 32],
|
||||
pub media_type: String,
|
||||
pub byte_length: u32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileState {
|
||||
pub schema_version: u32,
|
||||
pub owner: [u8; 32],
|
||||
pub sequence: u64,
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub avatar: Option<MediaRefV1>,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProfileSigBody<'a> {
|
||||
schema_version: u32,
|
||||
owner: &'a [u8; 32],
|
||||
sequence: u64,
|
||||
display_name: &'a str,
|
||||
bio: &'a str,
|
||||
avatar: &'a Option<MediaRefV1>,
|
||||
}
|
||||
|
||||
/// What the delegate signs for a profile: the RAW sig-body (F2 convention).
|
||||
pub fn profile_sign_payload(p: &ProfileState) -> Vec<u8> {
|
||||
bincode::serialize(&ProfileSigBody {
|
||||
schema_version: p.schema_version,
|
||||
owner: &p.owner,
|
||||
sequence: p.sequence,
|
||||
display_name: &p.display_name,
|
||||
bio: &p.bio,
|
||||
avatar: &p.avatar,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
|
||||
fn signable(domain: &str, payload: &[u8]) -> Vec<u8> {
|
||||
let mut msg = Vec::new();
|
||||
msg.extend_from_slice(b"soulstream.sig.v1");
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(domain.as_bytes());
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(blake3::hash(payload).as_bytes());
|
||||
msg
|
||||
}
|
||||
|
||||
pub fn verify_sig(public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
|
||||
let Ok(vk) = VerifyingKey::from_bytes(public) else { return false };
|
||||
let Ok(sig) = Signature::from_slice(sig) else { return false };
|
||||
vk.verify(&signable(SIGN_DOMAIN, payload), &sig).is_ok()
|
||||
}
|
||||
|
||||
pub fn make_profile_params(owner: &[u8; 32]) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(PROFILE_PARAMS_FAMILY.len() + 32);
|
||||
p.extend_from_slice(PROFILE_PARAMS_FAMILY);
|
||||
p.extend_from_slice(owner);
|
||||
p
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ProfileInvalid {
|
||||
Oversized(usize),
|
||||
Malformed,
|
||||
WrongSchema(u32),
|
||||
WrongOwner,
|
||||
NameTooLong(usize),
|
||||
BioTooLong(usize),
|
||||
AvatarUnbounded,
|
||||
BadSignature,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for ProfileInvalid {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
ProfileInvalid::Oversized(n) => write!(f, "profile {n} bytes over cap {MAX_PROFILE_BYTES}"),
|
||||
ProfileInvalid::Malformed => write!(f, "bytes do not parse as a profile"),
|
||||
ProfileInvalid::WrongSchema(v) => write!(f, "unknown profile schema {v}"),
|
||||
ProfileInvalid::WrongOwner => write!(f, "profile owner differs from the address owner"),
|
||||
ProfileInvalid::NameTooLong(n) => write!(f, "display name {n} bytes over cap {MAX_NAME_BYTES}"),
|
||||
ProfileInvalid::BioTooLong(n) => write!(f, "bio {n} bytes over cap {MAX_BIO_BYTES}"),
|
||||
ProfileInvalid::AvatarUnbounded => write!(f, "avatar reference outside the v1 bounds"),
|
||||
ProfileInvalid::BadSignature => write!(f, "owner signature does not verify"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn avatar_bounded(r: &MediaRefV1) -> bool {
|
||||
r.media_type == V1_MEDIA_TYPE
|
||||
&& r.byte_length as usize <= MAX_MEDIA_BYTES
|
||||
&& r.width as u32 == V1_DIMENSION
|
||||
&& r.height as u32 == V1_DIMENSION
|
||||
}
|
||||
|
||||
/// Gen-2 validation (avatar-accepting) — the generation the bundled
|
||||
/// contract enforces. Gen-1 states (avatar=None) validate identically.
|
||||
pub fn validate_profile(owner: &[u8; 32], bytes: &[u8]) -> Result<ProfileState, ProfileInvalid> {
|
||||
if bytes.len() > MAX_PROFILE_BYTES {
|
||||
return Err(ProfileInvalid::Oversized(bytes.len()));
|
||||
}
|
||||
let p: ProfileState = bincode::deserialize(bytes).map_err(|_| ProfileInvalid::Malformed)?;
|
||||
if p.schema_version != SCHEMA_VERSION {
|
||||
return Err(ProfileInvalid::WrongSchema(p.schema_version));
|
||||
}
|
||||
if &p.owner != owner {
|
||||
return Err(ProfileInvalid::WrongOwner);
|
||||
}
|
||||
if p.display_name.len() > MAX_NAME_BYTES {
|
||||
return Err(ProfileInvalid::NameTooLong(p.display_name.len()));
|
||||
}
|
||||
if p.bio.len() > MAX_BIO_BYTES {
|
||||
return Err(ProfileInvalid::BioTooLong(p.bio.len()));
|
||||
}
|
||||
if let Some(r) = &p.avatar {
|
||||
if !avatar_bounded(r) {
|
||||
return Err(ProfileInvalid::AvatarUnbounded);
|
||||
}
|
||||
}
|
||||
if !verify_sig(&p.owner, &profile_sign_payload(&p), &p.sig) {
|
||||
return Err(ProfileInvalid::BadSignature);
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ asset
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MediaAssetV1 {
|
||||
pub schema_version: u32,
|
||||
pub owner: [u8; 32],
|
||||
pub asset_id: [u8; 16],
|
||||
pub revision: u32,
|
||||
pub media_type: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sanitized_static_bytes: Vec<u8>,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AssetSigBody<'a> {
|
||||
schema_version: u32,
|
||||
owner: &'a [u8; 32],
|
||||
asset_id: &'a [u8; 16],
|
||||
revision: u32,
|
||||
media_type: &'a str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
content_hash: &'a [u8; 32],
|
||||
}
|
||||
|
||||
fn asset_sig_body(a: &MediaAssetV1) -> Vec<u8> {
|
||||
let content_hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes();
|
||||
bincode::serialize(&AssetSigBody {
|
||||
schema_version: a.schema_version,
|
||||
owner: &a.owner,
|
||||
asset_id: &a.asset_id,
|
||||
revision: a.revision,
|
||||
media_type: &a.media_type,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
content_hash: &content_hash,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
|
||||
/// What the delegate signs for an asset: blake3(sig-body) — 32 bytes.
|
||||
pub fn asset_sign_payload(a: &MediaAssetV1) -> Vec<u8> {
|
||||
blake3::hash(&asset_sig_body(a)).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn make_media_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(MEDIA_PARAMS_FAMILY.len() + 64);
|
||||
p.extend_from_slice(MEDIA_PARAMS_FAMILY);
|
||||
p.extend_from_slice(owner);
|
||||
p.extend_from_slice(content_hash);
|
||||
p
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AssetInvalid {
|
||||
Oversized(usize),
|
||||
HashMismatch,
|
||||
Malformed,
|
||||
WrongSchema(u32),
|
||||
WrongOwner,
|
||||
WrongType(String),
|
||||
WrongDimensions(u32, u32),
|
||||
BytesOverBudget(usize),
|
||||
BadSignature,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for AssetInvalid {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
AssetInvalid::Oversized(n) => write!(f, "asset state {n} bytes over gate {MAX_MEDIA_STATE_BYTES}"),
|
||||
AssetInvalid::HashMismatch => write!(f, "bytes do not hash to the referenced content hash"),
|
||||
AssetInvalid::Malformed => write!(f, "bytes do not parse as a media asset"),
|
||||
AssetInvalid::WrongSchema(v) => write!(f, "unknown media schema {v}"),
|
||||
AssetInvalid::WrongOwner => write!(f, "asset owner differs from the reference owner"),
|
||||
AssetInvalid::WrongType(t) => write!(f, "media type {t:?} not accepted (v1: image/png)"),
|
||||
AssetInvalid::WrongDimensions(w, h) => write!(f, "declared {w}x{h} is not the 256x256 rendition"),
|
||||
AssetInvalid::BytesOverBudget(n) => write!(f, "{n} media bytes over the {MAX_MEDIA_BYTES} budget"),
|
||||
AssetInvalid::BadSignature => write!(f, "owner signature does not verify"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structural verification of a fetched asset against a signed reference —
|
||||
/// everything that must pass BEFORE any image decoding happens.
|
||||
pub fn validate_asset(
|
||||
owner: &[u8; 32],
|
||||
content_hash: &[u8; 32],
|
||||
state_bytes: &[u8],
|
||||
) -> Result<MediaAssetV1, AssetInvalid> {
|
||||
if state_bytes.len() > MAX_MEDIA_STATE_BYTES {
|
||||
return Err(AssetInvalid::Oversized(state_bytes.len()));
|
||||
}
|
||||
let a: MediaAssetV1 = bincode::deserialize(state_bytes).map_err(|_| AssetInvalid::Malformed)?;
|
||||
if a.schema_version != SCHEMA_VERSION {
|
||||
return Err(AssetInvalid::WrongSchema(a.schema_version));
|
||||
}
|
||||
if &a.owner != owner {
|
||||
return Err(AssetInvalid::WrongOwner);
|
||||
}
|
||||
if a.sanitized_static_bytes.len() > MAX_MEDIA_BYTES {
|
||||
return Err(AssetInvalid::BytesOverBudget(a.sanitized_static_bytes.len()));
|
||||
}
|
||||
if blake3::hash(&a.sanitized_static_bytes).as_bytes() != content_hash {
|
||||
return Err(AssetInvalid::HashMismatch);
|
||||
}
|
||||
if a.media_type != V1_MEDIA_TYPE {
|
||||
return Err(AssetInvalid::WrongType(a.media_type.clone()));
|
||||
}
|
||||
if a.width != V1_DIMENSION || a.height != V1_DIMENSION {
|
||||
return Err(AssetInvalid::WrongDimensions(a.width, a.height));
|
||||
}
|
||||
if !verify_sig(owner, &asset_sign_payload(&a), &a.sig) {
|
||||
return Err(AssetInvalid::BadSignature);
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn kp(seed: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[seed; 32])
|
||||
}
|
||||
|
||||
fn sign_raw(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
|
||||
key.sign(&signable(SIGN_DOMAIN, payload)).to_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_roundtrip_both_generations() {
|
||||
let k = kp(1);
|
||||
let owner = k.verifying_key().to_bytes();
|
||||
let mut p = ProfileState {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
owner,
|
||||
sequence: 1,
|
||||
display_name: "Kyle".into(),
|
||||
bio: String::new(),
|
||||
avatar: None,
|
||||
sig: vec![],
|
||||
};
|
||||
p.sig = sign_raw(&k, &profile_sign_payload(&p));
|
||||
let b = bincode::serialize(&p).unwrap();
|
||||
assert!(validate_profile(&owner, &b).is_ok());
|
||||
// With a bounded avatar ref.
|
||||
let mut p2 = p.clone();
|
||||
p2.sequence = 2;
|
||||
p2.avatar = Some(MediaRefV1 {
|
||||
content_hash: [9u8; 32],
|
||||
media_type: V1_MEDIA_TYPE.into(),
|
||||
byte_length: 50_000,
|
||||
width: 256,
|
||||
height: 256,
|
||||
});
|
||||
p2.sig = sign_raw(&k, &profile_sign_payload(&p2));
|
||||
assert!(validate_profile(&owner, &bincode::serialize(&p2).unwrap()).is_ok());
|
||||
// Unbounded avatar refused; tamper refused.
|
||||
let mut p3 = p2.clone();
|
||||
p3.avatar.as_mut().unwrap().width = 4096;
|
||||
p3.sig = sign_raw(&k, &profile_sign_payload(&p3));
|
||||
assert_eq!(
|
||||
validate_profile(&owner, &bincode::serialize(&p3).unwrap()),
|
||||
Err(ProfileInvalid::AvatarUnbounded)
|
||||
);
|
||||
let mut p4 = p2.clone();
|
||||
p4.display_name = "Mallory".into();
|
||||
assert_eq!(
|
||||
validate_profile(&owner, &bincode::serialize(&p4).unwrap()),
|
||||
Err(ProfileInvalid::BadSignature)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_roundtrip_and_hash_binding() {
|
||||
let k = kp(1);
|
||||
let owner = k.verifying_key().to_bytes();
|
||||
let mut a = MediaAssetV1 {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
owner,
|
||||
asset_id: [4u8; 16],
|
||||
revision: 1,
|
||||
media_type: V1_MEDIA_TYPE.into(),
|
||||
width: V1_DIMENSION,
|
||||
height: V1_DIMENSION,
|
||||
sanitized_static_bytes: vec![7u8; 1000],
|
||||
sig: vec![],
|
||||
};
|
||||
a.sig = sign_raw(&k, &asset_sign_payload(&a));
|
||||
let hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes();
|
||||
let b = bincode::serialize(&a).unwrap();
|
||||
assert!(validate_asset(&owner, &hash, &b).is_ok());
|
||||
assert_eq!(validate_asset(&owner, &[0u8; 32], &b), Err(AssetInvalid::HashMismatch));
|
||||
}
|
||||
}
|
||||
|
||||
/// blake3 of raw bytes — the content-addressing hash used across the
|
||||
/// persona/media contract families.
|
||||
pub fn content_hash(bytes: &[u8]) -> [u8; 32] {
|
||||
*blake3::hash(bytes).as_bytes()
|
||||
}
|
||||
Reference in new issue
Block a user