community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door

This commit is contained in:
KeelrLeek committed 2026-09-02 15:02:06 -05:00
1 parent 984738135a
commit 13c44f731a
11 files changed
+1166 -3

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize};
pub mod slsk_link;
pub mod friend;
pub mod playlist;
pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
+342
View File
@@ -0,0 +1,342 @@
//! Playlist publication — F3's exact shapes: an immutable, content-addressed
//! revision (the portable manifest: entry occurrences with recording ids and
//! display fallbacks, never a path) and an owner-signed pointer to the
//! current revision per (owner, playlist uuid). BYTE-COMPATIBILITY IS THE
//! CONTRACT: both shapes are what the bundled revision/pointer wasm validate
//! (built from research/freenet-f3, code hashes pinned by the app's tests).
//!
//! Signing: the delegate caps payloads, so what it signs is blake3(sig-body)
//! under `soulstream.playlist/v1` — the F3 convention, mirrored here.
use serde::{Deserialize, Serialize};
pub const SIGN_DOMAIN: &str = "soulstream.playlist/v1";
pub const SCHEMA_VERSION: u32 = 1;
pub const REV_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-rev.v1";
pub const PTR_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-ptr.v1";
pub const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
pub const MAX_ENTRIES: usize = 5000;
pub const MAX_NAME_BYTES: usize = 128;
pub const MAX_DESC_BYTES: usize = 2048;
pub const MAX_NOTE_BYTES: usize = 280;
pub const MAX_FIELD_BYTES: usize = 512;
pub const MAX_POINTER_BYTES: usize = 1024;
// ------------------------------------------------------------ manifest
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordingRef {
pub musicbrainz_recording_id: Option<String>,
pub isrcs: Vec<String>,
pub deezer_id: Option<String>,
pub apple_music_id: Option<String>,
pub title: String,
pub artists: Vec<String>,
pub album: String,
pub duration_ms: Option<u32>,
pub release_year: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryV1 {
/// Occurrence id — duplicates of the same recording stay distinct.
pub entry_id: [u8; 16],
pub position: u32,
pub recording: RecordingRef,
pub note: Option<String>,
/// Free-text resolution hint ("artist title"), NEVER a remote path.
pub slsk_hint: Option<String>,
}
/// Bounded artwork REFERENCE — hash + type + size only, no bytes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtworkRef {
pub content_hash: [u8; 32],
pub media_type: String,
pub byte_length: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestV1 {
pub schema_version: u32,
pub owner: [u8; 32],
pub playlist_uuid: [u8; 16],
pub revision_seq: u64,
/// Previous revision's content hash, None for the first.
pub prev_revision_hash: Option<[u8; 32]>,
pub name: String,
pub description: String,
pub artwork: Option<ArtworkRef>,
pub entries: Vec<EntryV1>,
pub sig: Vec<u8>,
}
#[derive(Serialize)]
struct ManifestSigBody<'a> {
schema_version: u32,
owner: &'a [u8; 32],
playlist_uuid: &'a [u8; 16],
revision_seq: u64,
prev_revision_hash: &'a Option<[u8; 32]>,
name: &'a str,
description: &'a str,
artwork: &'a Option<ArtworkRef>,
entries: &'a [EntryV1],
}
/// The 32 bytes the delegate signs: blake3 of the serialized sig body.
pub fn manifest_sign_payload(m: &ManifestV1) -> Vec<u8> {
let body = bincode::serialize(&ManifestSigBody {
schema_version: m.schema_version,
owner: &m.owner,
playlist_uuid: &m.playlist_uuid,
revision_seq: m.revision_seq,
prev_revision_hash: &m.prev_revision_hash,
name: &m.name,
description: &m.description,
artwork: &m.artwork,
entries: &m.entries,
})
.expect("sig body serializes");
blake3::hash(&body).as_bytes().to_vec()
}
fn verify(owner: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
crate::verify_sig_in(SIGN_DOMAIN, owner, payload, sig)
}
#[derive(Debug, PartialEq, Eq)]
pub enum Invalid {
Oversized(usize),
HashMismatch,
Malformed,
WrongSchema(u32),
WrongOwner,
TooManyEntries(usize),
FieldTooLong(&'static str, usize),
BadSignature,
}
impl core::fmt::Display for Invalid {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"),
Invalid::HashMismatch => write!(f, "bytes do not hash to their address"),
Invalid::Malformed => write!(f, "bytes do not parse"),
Invalid::WrongSchema(v) => write!(f, "unknown schema {v}"),
Invalid::WrongOwner => write!(f, "owner differs from the address"),
Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap"),
Invalid::FieldTooLong(w, n) => write!(f, "{w} is {n} bytes, over its cap"),
Invalid::BadSignature => write!(f, "owner signature does not verify"),
}
}
}
pub fn make_rev_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec<u8> {
let mut p = Vec::with_capacity(REV_PARAMS_FAMILY.len() + 64);
p.extend_from_slice(REV_PARAMS_FAMILY);
p.extend_from_slice(owner);
p.extend_from_slice(content_hash);
p
}
/// Strict verification in the plan's order: size → content hash → parse →
/// schema → owner → caps → signature. All BEFORE any preview use.
pub fn validate_revision(
owner: &[u8; 32],
content_hash: &[u8; 32],
bytes: &[u8],
) -> Result<ManifestV1, Invalid> {
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(Invalid::Oversized(bytes.len()));
}
if blake3::hash(bytes).as_bytes() != content_hash {
return Err(Invalid::HashMismatch);
}
let m: ManifestV1 = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?;
if m.schema_version != SCHEMA_VERSION {
return Err(Invalid::WrongSchema(m.schema_version));
}
if &m.owner != owner {
return Err(Invalid::WrongOwner);
}
if m.entries.len() > MAX_ENTRIES {
return Err(Invalid::TooManyEntries(m.entries.len()));
}
if m.name.len() > MAX_NAME_BYTES {
return Err(Invalid::FieldTooLong("name", m.name.len()));
}
if m.description.len() > MAX_DESC_BYTES {
return Err(Invalid::FieldTooLong("description", m.description.len()));
}
for e in &m.entries {
if e.note.as_deref().map(str::len).unwrap_or(0) > MAX_NOTE_BYTES {
return Err(Invalid::FieldTooLong("note", e.note.as_ref().unwrap().len()));
}
for s in [&e.recording.title, &e.recording.album] {
if s.len() > MAX_FIELD_BYTES {
return Err(Invalid::FieldTooLong("recording field", s.len()));
}
}
if e.slsk_hint.as_deref().map(str::len).unwrap_or(0) > MAX_FIELD_BYTES {
return Err(Invalid::FieldTooLong("slsk_hint", e.slsk_hint.as_ref().unwrap().len()));
}
}
if !verify(owner, &manifest_sign_payload(&m), &m.sig) {
return Err(Invalid::BadSignature);
}
Ok(m)
}
// ------------------------------------------------------------ pointer
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevisionRef {
pub content_hash: [u8; 32],
pub revision_seq: u64,
pub entry_count: u32,
pub byte_length: u32,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PointerState {
pub schema_version: u32,
pub owner: [u8; 32],
pub playlist_uuid: [u8; 16],
pub sequence: u64,
/// None + withdrawn = "removed from current state" — old revisions
/// remain fetchable; never claim network erasure.
pub current: Option<RevisionRef>,
pub withdrawn: bool,
pub sig: Vec<u8>,
}
#[derive(Serialize)]
struct PointerSigBody<'a> {
schema_version: u32,
owner: &'a [u8; 32],
playlist_uuid: &'a [u8; 16],
sequence: u64,
current: &'a Option<RevisionRef>,
withdrawn: bool,
}
pub fn pointer_sign_payload(p: &PointerState) -> Vec<u8> {
let body = bincode::serialize(&PointerSigBody {
schema_version: p.schema_version,
owner: &p.owner,
playlist_uuid: &p.playlist_uuid,
sequence: p.sequence,
current: &p.current,
withdrawn: p.withdrawn,
})
.expect("sig body serializes");
blake3::hash(&body).as_bytes().to_vec()
}
pub fn make_ptr_params(owner: &[u8; 32], playlist_uuid: &[u8; 16]) -> Vec<u8> {
let mut p = Vec::with_capacity(PTR_PARAMS_FAMILY.len() + 48);
p.extend_from_slice(PTR_PARAMS_FAMILY);
p.extend_from_slice(owner);
p.extend_from_slice(playlist_uuid);
p
}
pub fn validate_pointer(
owner: &[u8; 32],
uuid: &[u8; 16],
bytes: &[u8],
) -> Result<PointerState, Invalid> {
if bytes.len() > MAX_POINTER_BYTES {
return Err(Invalid::Oversized(bytes.len()));
}
let p: PointerState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?;
if p.schema_version != SCHEMA_VERSION {
return Err(Invalid::WrongSchema(p.schema_version));
}
if &p.owner != owner || &p.playlist_uuid != uuid {
return Err(Invalid::WrongOwner);
}
if let Some(r) = &p.current {
if r.name.len() > MAX_NAME_BYTES {
return Err(Invalid::FieldTooLong("pointer name", r.name.len()));
}
}
if !verify(owner, &pointer_sign_payload(&p), &p.sig) {
return Err(Invalid::BadSignature);
}
Ok(p)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn sign(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec()
}
#[test]
fn a_revision_and_its_pointer_round_trip_and_refuse_tampering() {
let key = SigningKey::from_bytes(&[5u8; 32]);
let owner = key.verifying_key().to_bytes();
let mut m = ManifestV1 {
schema_version: SCHEMA_VERSION,
owner,
playlist_uuid: [9; 16],
revision_seq: 1,
prev_revision_hash: None,
name: "Rainy Window".into(),
description: "long fades".into(),
artwork: None,
entries: vec![EntryV1 {
entry_id: [1; 16],
position: 0,
recording: RecordingRef {
musicbrainz_recording_id: None,
isrcs: vec![],
deezer_id: None,
apple_music_id: None,
title: "Airbag".into(),
artists: vec!["Radiohead".into()],
album: "OK Computer".into(),
duration_ms: Some(284_000),
release_year: Some(1997),
},
note: None,
slsk_hint: Some("radiohead airbag".into()),
}],
sig: vec![],
};
m.sig = sign(&key, &manifest_sign_payload(&m));
let bytes = bincode::serialize(&m).unwrap();
let hash = *blake3::hash(&bytes).as_bytes();
assert_eq!(validate_revision(&owner, &hash, &bytes).unwrap(), m);
// content addressing: a flipped byte trips the hash BEFORE parse
let mut flipped = bytes.clone();
flipped[10] ^= 1;
assert_eq!(validate_revision(&owner, &hash, &flipped), Err(Invalid::HashMismatch));
// a re-signed edit by the wrong key is a bad signature
let mut edited = m.clone();
edited.name = "Sunny Window".into();
edited.sig = sign(&SigningKey::from_bytes(&[6u8; 32]), &manifest_sign_payload(&edited));
let eb = bincode::serialize(&edited).unwrap();
assert_eq!(validate_revision(&owner, blake3::hash(&eb).as_bytes(), &eb), Err(Invalid::BadSignature));
let mut p = PointerState {
schema_version: SCHEMA_VERSION,
owner,
playlist_uuid: [9; 16],
sequence: 1,
current: Some(RevisionRef { content_hash: hash, revision_seq: 1, entry_count: 1, byte_length: bytes.len() as u32, name: "Rainy Window".into() }),
withdrawn: false,
sig: vec![],
};
p.sig = sign(&key, &pointer_sign_payload(&p));
let pb = bincode::serialize(&p).unwrap();
assert_eq!(validate_pointer(&owner, &[9; 16], &pb).unwrap(), p);
assert_eq!(validate_pointer(&owner, &[8; 16], &pb), Err(Invalid::WrongOwner));
}
}