use crate::error::Error;
use crate::manifest::ShareId;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use iroh::{EndpointAddr, EndpointId};
use iroh_gossip::proto::TopicId;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
const SHARE_CODE_PREFIX: &str = "chr2";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareCode {
pub(crate) id: ShareId,
pub(crate) name: String,
pub(crate) total_size: u64,
pub(crate) peers: Vec<EndpointAddr>,
pub(crate) secret: Option<[u8; 32]>,
}
impl ShareCode {
pub fn id(&self) -> ShareId {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn total_size(&self) -> u64 {
self.total_size
}
pub fn is_private(&self) -> bool {
self.secret.is_some()
}
pub(crate) fn topic(&self) -> TopicId {
topic_for(self.id, self.secret.as_ref())
}
pub fn merge(&mut self, other: &ShareCode) -> Result<(), Error> {
if other.id != self.id || other.secret != self.secret {
return Err(Error::InvalidShareCode(
"those share codes are for different shares".into(),
));
}
for peer in &other.peers {
if !self.peers.iter().any(|p| p.id == peer.id) {
self.peers.push(peer.clone());
}
}
Ok(())
}
pub fn peer_ids(&self) -> Vec<String> {
self.peers.iter().map(|p| p.id.to_string()).collect()
}
}
impl fmt::Display for ShareCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let bytes = postcard::to_allocvec(self).map_err(|_| fmt::Error)?;
write!(f, "{SHARE_CODE_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
}
}
impl FromStr for ShareCode {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let body = s.trim().strip_prefix(SHARE_CODE_PREFIX).ok_or_else(|| {
Error::InvalidShareCode(
"not a chorrent v2 share code (should start with \"chr2\")".into(),
)
})?;
let bytes = URL_SAFE_NO_PAD
.decode(body)
.map_err(|e| Error::InvalidShareCode(format!("not valid base64: {e}")))?;
postcard::from_bytes(&bytes).map_err(|e| Error::InvalidShareCode(format!("corrupted: {e}")))
}
}
pub(crate) fn topic_for(id: ShareId, secret: Option<&[u8; 32]>) -> TopicId {
let bytes = match secret {
None => blake3::keyed_hash(&blake3::derive_key("chorrent v2 public topic", &[]), &id.0),
Some(secret) => blake3::keyed_hash(
&blake3::derive_key("chorrent v2 private topic", secret),
&id.0,
),
};
TopicId::from_bytes(*bytes.as_bytes())
}
pub(crate) fn access_token(secret: &[u8; 32], requester: &EndpointId) -> [u8; 32] {
*blake3::keyed_hash(
&blake3::derive_key("chorrent v2 access", secret),
requester.as_bytes(),
)
.as_bytes()
}
pub(crate) fn new_secret() -> [u8; 32] {
rand::random()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn private_topics_differ_from_public_and_by_secret() {
let id = ShareId([1; 32]);
let public = topic_for(id, None);
assert_ne!(public, topic_for(id, Some(&[2; 32])));
assert_ne!(topic_for(id, Some(&[2; 32])), topic_for(id, Some(&[3; 32])));
}
#[test]
fn share_code_round_trips_and_rejects_v1() {
let code = ShareCode {
id: ShareId([7; 32]),
name: "x".into(),
total_size: 5,
peers: vec![],
secret: Some([1; 32]),
};
let text = code.to_string();
assert!(text.starts_with("chr2"));
assert_eq!(text.parse::<ShareCode>().unwrap(), code);
assert!("kAFlbmRwb2ludA".parse::<ShareCode>().is_err());
}
}