use alloc::string::String;
use alloc::string::ToString;
use alloc::vec::Vec;
pub const LSD_PORT: u16 = 6771;
pub const LSD_GROUP_V4: crate::platform::NetAddr =
crate::platform::NetAddr::V4([239, 192, 152, 143], 6771);
pub const LSD_GROUP_V6: crate::platform::NetAddr = crate::platform::NetAddr::V6(
[
0xff, 0x15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xef, 0xc0, 0x98, 0x8f,
],
6771,
);
pub const LSD_INTERVAL_MS: u64 = 60_000;
pub const LSD_INTERVAL_MIN_MS: u64 = 30_000;
pub const LSD_REPLY_GAP_MS: u64 = 10_000;
pub const LSD_ANNOUNCE_BURST_MAX: usize = 20;
pub const MAX_INFOHASHES_PER_ANNOUNCE: usize = 32;
fn host_header(group: crate::platform::NetAddr) -> String {
match group {
crate::platform::NetAddr::V4(ip, _) => {
let mut s = String::with_capacity(15);
for (i, o) in ip.iter().enumerate() {
if i > 0 {
s.push('.');
}
s.push_str(&o.to_string());
}
s
}
crate::platform::NetAddr::V6(ip, _) => {
let mut s = String::with_capacity(39);
s.push('[');
for (i, g) in ip.chunks(2).enumerate() {
if i > 0 {
s.push(':');
}
let v = u16::from_be_bytes([g[0], g[1]]);
s.push_str(&alloc::format!("{:x}", v));
}
s.push(']');
s
}
}
}
#[derive(Debug, Clone)]
pub struct LsdAnnounce {
pub port: u16,
pub cookie: Option<[u8; 8]>,
pub infohashes: Vec<[u8; 20]>,
}
fn hex_val(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
fn hex_decode(s: &[u8]) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
for pair in s.as_chunks::<2>().0 {
let hi = hex_val(pair[0])?;
let lo = hex_val(pair[1])?;
out.push((hi << 4) | lo);
}
Some(out)
}
fn hex_encode(b: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(b.len() * 2);
for &x in b {
s.push(HEX[(x >> 4) as usize] as char);
s.push(HEX[(x & 0xf) as usize] as char);
}
s
}
pub fn build_announce(
infohash: &[u8; 20],
port: u16,
cookie: Option<&[u8; 8]>,
group: crate::platform::NetAddr,
) -> Vec<u8> {
let mut out = Vec::with_capacity(128);
out.extend_from_slice(b"BT-SEARCH * HTTP/1.1\r\n");
out.extend_from_slice(b"Host: ");
out.extend_from_slice(host_header(group).as_bytes());
out.extend_from_slice(b"\r\n");
out.extend_from_slice(b"Port: ");
out.extend_from_slice(port.to_string().as_bytes());
out.extend_from_slice(b"\r\n");
out.extend_from_slice(b"Infohash: ");
out.extend_from_slice(hex_encode(infohash).as_bytes());
out.extend_from_slice(b"\r\n");
if let Some(c) = cookie {
out.extend_from_slice(b"cookie: ");
out.extend_from_slice(hex_encode(c).as_bytes());
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b"\r\n");
out
}
pub fn parse(data: &[u8]) -> Option<LsdAnnounce> {
if !data.starts_with(b"BT-SEARCH") {
return None;
}
let mut port: Option<u16> = None;
let mut cookie: Option<[u8; 8]> = None;
let mut infohashes: Vec<[u8; 20]> = Vec::new();
let mut first = true;
for line in data.split(|&b| b == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
if first {
first = false;
continue; }
if line.is_empty() {
continue;
}
let Some(colon) = line.iter().position(|&b| b == b':') else {
continue;
};
let name = &line[..colon];
let value = line[colon + 1..]
.iter()
.copied()
.skip_while(|&b| b == b' ' || b == b'\t')
.collect::<Vec<u8>>();
match name {
b"Port" => {
if port.is_none() {
let s = core::str::from_utf8(&value).ok()?;
port = s.trim().parse::<u16>().ok();
}
}
b"cookie" => {
if cookie.is_none() {
if let Some(dec) = hex_decode(&value) {
if dec.len() == 8 {
let mut c = [0u8; 8];
c.copy_from_slice(&dec);
cookie = Some(c);
}
}
}
}
b"Infohash" => {
if infohashes.len() >= MAX_INFOHASHES_PER_ANNOUNCE {
continue; }
if let Some(dec) = hex_decode(&value) {
if dec.len() == 20 {
let mut ih = [0u8; 20];
ih.copy_from_slice(&dec);
infohashes.push(ih);
}
}
}
_ => {}
}
}
if port.is_none() || infohashes.is_empty() {
return None;
}
Some(LsdAnnounce {
port: port?,
cookie,
infohashes,
})
}
#[derive(Debug)]
pub struct LsdScheduler {
pub cookie: [u8; 8],
pub interval_ms: u64,
pub last_announce_at: u64,
pub cursor: usize,
}
impl LsdScheduler {
pub fn new(cookie: [u8; 8], now: u64, interval_ms: u64) -> Self {
LsdScheduler {
cookie,
interval_ms: interval_ms.max(LSD_INTERVAL_MIN_MS),
last_announce_at: now.saturating_sub(interval_ms),
cursor: 0,
}
}
pub fn due(&self, now: u64) -> bool {
now.saturating_sub(self.last_announce_at) >= self.interval_ms
}
pub fn next_announce<'a>(&mut self, active: &'a [[u8; 20]], now: u64) -> Option<&'a [u8; 20]> {
if active.is_empty() {
return None;
}
if now.saturating_sub(self.last_announce_at) < self.interval_ms {
return None;
}
let ih = &active[self.cursor % active.len()];
self.cursor += 1;
self.last_announce_at = now;
Some(ih)
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
#[test]
fn announce_roundtrip() {
let ih = [7u8; 20];
let cookie = [0xde, 0xad, 0xbe, 0xef, 1, 2, 3, 4];
let msg = build_announce(&ih, 6881, Some(&cookie), LSD_GROUP_V4);
assert!(msg.starts_with(b"BT-SEARCH * HTTP/1.1\r\n"));
assert!(msg.windows(10).any(|w| w == b"Port: 6881"));
assert!(msg.windows(12).any(|w| w == b"Infohash: 07"));
let parsed = parse(&msg).expect("parse");
assert_eq!(parsed.port, 6881);
assert_eq!(parsed.cookie, Some(cookie));
assert_eq!(parsed.infohashes, vec![[7u8; 20]]);
}
#[test]
fn host_header_matches_group_family() {
let ih = [1u8; 20];
let raw4 = build_announce(&ih, 6881, None, LSD_GROUP_V4);
let v4 = String::from_utf8_lossy(&raw4);
assert!(
v4.contains("Host: 239.192.152.143"),
"v4 announce must carry the v4 group Host, got {v4:?}"
);
let raw6 = build_announce(&ih, 6881, None, LSD_GROUP_V6);
let v6 = String::from_utf8_lossy(&raw6);
assert!(
v6.contains("Host: [ff15:0:0:0:0:0:efc0:988f]"),
"v6 announce must carry the v6 group Host, got {v6:?}"
);
}
#[test]
fn infohash_cap_bounds_parsing() {
let mut msg = Vec::new();
msg.extend_from_slice(b"BT-SEARCH * HTTP/1.1\r\nPort: 6881\r\n");
let n = MAX_INFOHASHES_PER_ANNOUNCE + 64;
for i in 0..n {
let ih = [i as u8; 20];
msg.extend_from_slice(b"Infohash: ");
msg.extend_from_slice(hex_encode(&ih).as_bytes());
msg.extend_from_slice(b"\r\n");
}
msg.extend_from_slice(b"\r\n");
let parsed = parse(&msg).expect("parse");
assert_eq!(
parsed.infohashes.len(),
MAX_INFOHASHES_PER_ANNOUNCE,
"excess infohashes must be dropped"
);
}
#[test]
fn parse_rejects_garbage() {
assert!(parse(b"GET / HTTP/1.1\r\n\r\n").is_none());
assert!(parse(b"BT-SEARCH * HTTP/1.1\r\n\r\n").is_none());
assert!(parse(b"").is_none());
}
#[test]
fn multiple_infohashes_are_parsed() {
let ih1 = [1u8; 20];
let ih2 = [2u8; 20];
let mut msg = build_announce(&ih1, 6881, None, LSD_GROUP_V4);
let tail = msg.split_off(msg.len() - 2);
msg.extend_from_slice(b"Infohash: ");
msg.extend_from_slice(hex_encode(&ih2).as_bytes());
msg.extend_from_slice(b"\r\n");
msg.extend_from_slice(&tail);
let parsed = parse(&msg).expect("parse");
assert_eq!(parsed.infohashes, vec![ih1, ih2]);
}
#[test]
fn scheduler_rate_limits() {
let cookie = [0u8; 8];
let mut s = LsdScheduler::new(cookie, 1_000_000, 60_000);
let active = [[1u8; 20], [2u8; 20]];
assert!(s.next_announce(&active, 1_000_000).is_some());
assert!(s.next_announce(&active, 1_000_001).is_none());
let ih = s.next_announce(&active, 1_060_001);
assert_eq!(ih, Some(&[2u8; 20]));
}
#[test]
fn scheduler_interval_is_floored() {
let cookie = [0u8; 8];
let mut s = LsdScheduler::new(cookie, 1_000_000, 5_000);
assert_eq!(s.interval_ms, LSD_INTERVAL_MIN_MS);
assert!(s.next_announce(&active_two(), 1_020_000).is_none());
assert!(s.next_announce(&active_two(), 1_031_000).is_some());
}
fn active_two() -> [[u8; 20]; 2] {
[[1u8; 20], [2u8; 20]]
}
}