Add Phase 1 frxd implementation with conformance test suite
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use frxd::commands;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "frx",
|
||||
version,
|
||||
about = "FRX client — local search and broadcast queries"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long, global = true, default_value = "frxd.toml")]
|
||||
config: PathBuf,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
Search {
|
||||
text: String,
|
||||
#[arg(long, default_value_t = 10)]
|
||||
limit: usize,
|
||||
},
|
||||
Query {
|
||||
text: String,
|
||||
#[arg(long)]
|
||||
max_results: Option<usize>,
|
||||
#[arg(long)]
|
||||
timeout_ms: Option<u64>,
|
||||
#[arg(long)]
|
||||
local_only: bool,
|
||||
},
|
||||
Status,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::Search { text, limit } => commands::search(&cli.config, &text, limit).await?,
|
||||
Command::Query {
|
||||
text,
|
||||
max_results,
|
||||
timeout_ms,
|
||||
local_only,
|
||||
} => {
|
||||
commands::query(&cli.config, &text, max_results, timeout_ms, !local_only).await?;
|
||||
}
|
||||
Command::Status => commands::status(&cli.config).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::index::{Collection, LocalIndex, load_collections, save_collections};
|
||||
use crate::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
|
||||
use crate::node;
|
||||
use crate::render;
|
||||
|
||||
pub fn add(
|
||||
config_path: &Path,
|
||||
path: &Path,
|
||||
name: Option<String>,
|
||||
shared: bool,
|
||||
exposure: &str,
|
||||
) -> Result<()> {
|
||||
let config = Config::load(config_path)?;
|
||||
let index = LocalIndex::open(&config.index_dir())?;
|
||||
let name = name.unwrap_or_else(|| {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("collection")
|
||||
.to_string()
|
||||
});
|
||||
let exposure = if exposure == EXPOSURE_FULL {
|
||||
EXPOSURE_FULL
|
||||
} else {
|
||||
EXPOSURE_METADATA
|
||||
};
|
||||
let collection = Collection {
|
||||
name: name.clone(),
|
||||
path: path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("resolving {}", path.display()))?
|
||||
.display()
|
||||
.to_string(),
|
||||
shared,
|
||||
exposure: exposure.to_string(),
|
||||
};
|
||||
let added = index.add_collection(&collection)?;
|
||||
let manifest = config.collections_path();
|
||||
let mut collections = load_collections(&manifest)?;
|
||||
collections.retain(|existing| existing.name != name);
|
||||
collections.push(collection);
|
||||
save_collections(&manifest, &collections)?;
|
||||
println!(
|
||||
"indexed {added} file(s) from {} as collection '{name}' (shared={shared}, exposure={exposure})",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reindex(config_path: &Path) -> Result<()> {
|
||||
let config = Config::load(config_path)?;
|
||||
let index = LocalIndex::open(&config.index_dir())?;
|
||||
let collections = load_collections(&config.collections_path())?;
|
||||
if collections.is_empty() {
|
||||
println!("no collections registered; use `frxd add <path>`");
|
||||
return Ok(());
|
||||
}
|
||||
for collection in collections {
|
||||
let added = index.add_collection(&collection)?;
|
||||
println!(
|
||||
"collection '{}' ({}): {added} file(s)",
|
||||
collection.name, collection.path
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search(config_path: &Path, text: &str, limit: usize) -> Result<()> {
|
||||
let config = Config::load(config_path)?;
|
||||
let index = LocalIndex::open(&config.index_dir())?;
|
||||
let (hits, total) = index.search(text, limit, false)?;
|
||||
render::local_hits(&hits, total);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn query(
|
||||
config_path: &Path,
|
||||
text: &str,
|
||||
max_results: Option<usize>,
|
||||
timeout_ms: Option<u64>,
|
||||
network: bool,
|
||||
) -> Result<()> {
|
||||
let config = Config::load(config_path)?;
|
||||
let base = format!("http://{}", config.node.listen);
|
||||
let outcome = node::control_query(&base, text, max_results, timeout_ms, network).await?;
|
||||
render::query_outcome(&outcome);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn status(config_path: &Path) -> Result<()> {
|
||||
let config = Config::load(config_path)?;
|
||||
let base = format!("http://{}", config.node.listen);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()?;
|
||||
match client.get(format!("{base}/v1/local/status")).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let value: serde_json::Value = response.json().await?;
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
}
|
||||
Ok(response) => println!("node at {base} responded {}", response.status()),
|
||||
Err(_) => {
|
||||
println!("node not running at {base}");
|
||||
println!("config: {}", config_path.display());
|
||||
println!("name: {}", config.node.name);
|
||||
println!("data dir: {}", config.data_dir().display());
|
||||
println!("relays: {:?}", config.node.relays);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::crypto::Keypair;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub node: NodeSection,
|
||||
#[serde(default)]
|
||||
pub query: QuerySection,
|
||||
#[serde(default)]
|
||||
pub index: IndexSection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeSection {
|
||||
pub name: String,
|
||||
pub listen: String,
|
||||
pub relays: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub trusted_keys: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub responder: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuerySection {
|
||||
#[serde(default = "default_max_results")]
|
||||
pub max_results: usize,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for QuerySection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_results: default_max_results(),
|
||||
timeout_ms: default_timeout_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexSection {
|
||||
#[serde(default = "default_data_dir")]
|
||||
pub data_dir: String,
|
||||
}
|
||||
|
||||
impl Default for IndexSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: default_data_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_max_results() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_timeout_ms() -> u64 {
|
||||
2000
|
||||
}
|
||||
|
||||
fn default_data_dir() -> String {
|
||||
"./frx-data".to_string()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(name: &str, listen: &str, relay: &str, data_dir: &str) -> Self {
|
||||
Self {
|
||||
node: NodeSection {
|
||||
name: name.to_string(),
|
||||
listen: listen.to_string(),
|
||||
relays: vec![relay.to_string()],
|
||||
trusted_keys: Vec::new(),
|
||||
responder: true,
|
||||
},
|
||||
query: QuerySection::default(),
|
||||
index: IndexSection {
|
||||
data_dir: data_dir.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.with_context(|| format!("reading config {}", path.display()))?;
|
||||
toml::from_str(&raw).with_context(|| format!("parsing config {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
fs::write(path, toml::to_string_pretty(self)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
PathBuf::from(&self.index.data_dir)
|
||||
}
|
||||
|
||||
pub fn index_dir(&self) -> PathBuf {
|
||||
self.data_dir().join("index")
|
||||
}
|
||||
|
||||
pub fn key_path(&self) -> PathBuf {
|
||||
self.data_dir().join("key.hex")
|
||||
}
|
||||
|
||||
pub fn collections_path(&self) -> PathBuf {
|
||||
self.data_dir().join("collections.toml")
|
||||
}
|
||||
|
||||
pub fn load_key(&self) -> Result<Keypair> {
|
||||
let raw = fs::read_to_string(self.key_path())
|
||||
.with_context(|| format!("reading key {}", self.key_path().display()))?;
|
||||
Keypair::from_hex(&raw)
|
||||
}
|
||||
|
||||
pub fn save_key(&self, key: &Keypair) -> Result<()> {
|
||||
fs::create_dir_all(self.data_dir())?;
|
||||
let path = self.key_path();
|
||||
fs::write(&path, key.to_hex())?;
|
||||
set_private_permissions(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn set_private_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
use crate::PROTOCOL;
|
||||
use crate::message::Envelope;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use rand::RngCore;
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct Keypair {
|
||||
signing: SigningKey,
|
||||
}
|
||||
|
||||
impl Keypair {
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
Self {
|
||||
signing: SigningKey::from_bytes(&bytes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_hex(s: &str) -> Result<Self> {
|
||||
let raw = hex::decode(s.trim()).context("key is not hex")?;
|
||||
let bytes: [u8; 32] = raw
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("key must be 32 bytes"))?;
|
||||
Ok(Self {
|
||||
signing: SigningKey::from_bytes(&bytes),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.signing.to_bytes())
|
||||
}
|
||||
|
||||
pub fn public_hex(&self) -> String {
|
||||
hex::encode(self.signing.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
pub fn verifying_key(&self) -> VerifyingKey {
|
||||
self.signing.verifying_key()
|
||||
}
|
||||
|
||||
pub fn sign(&self, bytes: &[u8]) -> String {
|
||||
hex::encode(self.signing.sign(bytes).to_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn random_nonce() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn random_id() -> String {
|
||||
let mut bytes = [0u8; 8];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn canonical_json(value: &Value) -> String {
|
||||
let mut out = String::new();
|
||||
write_canonical(value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_canonical(value: &Value, out: &mut String) {
|
||||
match value {
|
||||
Value::Null => out.push_str("null"),
|
||||
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
|
||||
Value::Number(n) => out.push_str(&n.to_string()),
|
||||
Value::String(s) => out.push_str(&serde_json::to_string(s).expect("string serializes")),
|
||||
Value::Array(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_canonical(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
out.push('{');
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&serde_json::to_string(key).expect("key serializes"));
|
||||
out.push(':');
|
||||
write_canonical(&map[*key], out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn signing_bytes(envelope: &Envelope) -> Vec<u8> {
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
PROTOCOL,
|
||||
envelope.msg_type,
|
||||
envelope.from,
|
||||
envelope.ts,
|
||||
envelope.nonce,
|
||||
canonical_json(&envelope.body)
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
pub fn verify_envelope(envelope: &Envelope) -> Result<()> {
|
||||
let key_bytes = hex::decode(&envelope.from).context("from is not hex")?;
|
||||
let key_bytes: [u8; 32] = key_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("from must be a 32-byte ed25519 public key"))?;
|
||||
let verifying_key =
|
||||
VerifyingKey::from_bytes(&key_bytes).map_err(|e| anyhow!("bad public key: {e}"))?;
|
||||
let sig_bytes = hex::decode(&envelope.sig).context("sig is not hex")?;
|
||||
let sig_bytes: [u8; 64] = sig_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("sig must be 64 bytes"))?;
|
||||
let signature = Signature::from_bytes(&sig_bytes);
|
||||
verifying_key
|
||||
.verify_strict(&signing_bytes(envelope), &signature)
|
||||
.map_err(|_| anyhow!("signature verification failed"))
|
||||
}
|
||||
|
||||
pub fn now_ts() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn canonical_json_sorts_keys_recursively() {
|
||||
let a = json!({"b": 1, "a": {"d": [3, 2], "c": "x"}});
|
||||
let b = json!({"a": {"c": "x", "d": [3, 2]}, "b": 1});
|
||||
assert_eq!(canonical_json(&a), canonical_json(&b));
|
||||
assert_eq!(canonical_json(&a), r#"{"a":{"c":"x","d":[3,2]},"b":1}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_sign_verify_roundtrip() {
|
||||
let key = Keypair::generate();
|
||||
let env = Envelope::new(&key, crate::message::TYPE_QUERY, json!({"text": "hello"}));
|
||||
verify_envelope(&env).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_body_fails_verification() {
|
||||
let key = Keypair::generate();
|
||||
let mut env = Envelope::new(&key, crate::message::TYPE_QUERY, json!({"text": "hello"}));
|
||||
env.body = json!({"text": "hello", "extra": true});
|
||||
assert!(verify_envelope(&env).is_err());
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
|
||||
pub const MAX_BODY_BYTES: usize = 1_000_000;
|
||||
|
||||
pub struct Extracted {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub published: String,
|
||||
}
|
||||
|
||||
pub fn supported(path: &Path) -> bool {
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(ext) => matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"txt" | "md" | "markdown" | "html" | "htm" | "rst" | "log"
|
||||
),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_file(path: &Path) -> Option<Extracted> {
|
||||
if !supported(path) {
|
||||
return None;
|
||||
}
|
||||
let raw = fs::read_to_string(path).ok()?;
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let body = if ext == "html" || ext == "htm" {
|
||||
strip_html(&raw)
|
||||
} else {
|
||||
collapse_blank_lines(&raw)
|
||||
};
|
||||
let body = truncate_chars(&body, MAX_BODY_BYTES);
|
||||
let title = if ext == "html" || ext == "htm" {
|
||||
html_title(&raw).unwrap_or_else(|| fallback_title(path, &body))
|
||||
} else {
|
||||
markdown_title(&raw).unwrap_or_else(|| fallback_title(path, &body))
|
||||
};
|
||||
let published = fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.map(format_time)
|
||||
.unwrap_or_default();
|
||||
Some(Extracted {
|
||||
title: truncate_chars(title.trim(), 200),
|
||||
body,
|
||||
published,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn summary_of(body: &str) -> String {
|
||||
truncate_chars(body.trim(), 300).replace('\n', " ")
|
||||
}
|
||||
|
||||
fn fallback_title(path: &Path, body: &str) -> String {
|
||||
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
|
||||
if line.chars().count() > 3 {
|
||||
return truncate_chars(line, 120);
|
||||
}
|
||||
}
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("untitled")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn markdown_title(raw: &str) -> Option<String> {
|
||||
raw.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| l.starts_with("# "))
|
||||
.map(|l| l.trim_start_matches('#').trim().to_string())
|
||||
}
|
||||
|
||||
fn html_title(raw: &str) -> Option<String> {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
let start = lower.find("<title")?;
|
||||
let open_end = lower[start..].find('>')? + start + 1;
|
||||
let end = lower[open_end..].find("</title>")? + open_end;
|
||||
let title = decode_entities(raw[open_end..end].trim());
|
||||
if title.is_empty() { None } else { Some(title) }
|
||||
}
|
||||
|
||||
fn strip_html(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len() / 2);
|
||||
let mut chars = raw.chars().peekable();
|
||||
let mut skipping: Option<String> = None;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '<' {
|
||||
let mut tag = String::new();
|
||||
for t in chars.by_ref() {
|
||||
if t == '>' {
|
||||
break;
|
||||
}
|
||||
tag.push(t);
|
||||
}
|
||||
let name = tag
|
||||
.trim_start_matches('/')
|
||||
.trim()
|
||||
.split(|c: char| c.is_whitespace() || c == '/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if skipping.is_none() && (name == "script" || name == "style") {
|
||||
skipping = Some(name.clone());
|
||||
} else if skipping.as_deref() == Some(name.as_str())
|
||||
&& tag.trim_start().starts_with('/')
|
||||
{
|
||||
skipping = None;
|
||||
}
|
||||
out.push(' ');
|
||||
continue;
|
||||
}
|
||||
if skipping.is_none() {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
collapse_whitespace(&decode_entities(&out))
|
||||
}
|
||||
|
||||
fn collapse_whitespace(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut last_space = false;
|
||||
for c in s.chars() {
|
||||
if c.is_whitespace() {
|
||||
if !last_space {
|
||||
out.push(' ');
|
||||
}
|
||||
last_space = true;
|
||||
} else {
|
||||
out.push(c);
|
||||
last_space = false;
|
||||
}
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
fn collapse_blank_lines(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut blank_run = 0;
|
||||
for line in s.lines() {
|
||||
if line.trim().is_empty() {
|
||||
blank_run += 1;
|
||||
if blank_run > 1 {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
blank_run = 0;
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
fn decode_entities(s: &str) -> String {
|
||||
s.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let end = s
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < max)
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
s[..end].to_string()
|
||||
}
|
||||
|
||||
fn format_time(t: SystemTime) -> String {
|
||||
let dt: DateTime<Utc> = t.into();
|
||||
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn html_is_stripped_and_title_extracted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("page.html");
|
||||
let mut f = fs::File::create(&path).unwrap();
|
||||
write!(
|
||||
f,
|
||||
"<html><head><title>Rust & Ownership</title><style>p{{color:red}}</style></head><body><p>Hello <b>world</b></p><script>alert(1)</script></body></html>"
|
||||
)
|
||||
.unwrap();
|
||||
let extracted = extract_file(&path).unwrap();
|
||||
assert_eq!(extracted.title, "Rust & Ownership");
|
||||
assert!(extracted.body.contains("Hello world"));
|
||||
assert!(!extracted.body.contains("alert"));
|
||||
assert!(!extracted.body.contains("color:red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_extensions_are_ignored() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("archive.bin");
|
||||
fs::write(&path, "binary-ish").unwrap();
|
||||
assert!(!supported(&path));
|
||||
assert!(extract_file(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_bodies_are_truncated_on_char_boundaries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("big.txt");
|
||||
let mut body = "é".repeat(MAX_BODY_BYTES / 2 + 50);
|
||||
body.push_str(" tail");
|
||||
fs::write(&path, &body).unwrap();
|
||||
let extracted = extract_file(&path).unwrap();
|
||||
assert!(extracted.body.len() <= MAX_BODY_BYTES);
|
||||
assert!(extracted.body.chars().all(|c| c == 'é'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_heading_becomes_title() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(&path, "# Borrowing\n\nRules of borrowing.\n").unwrap();
|
||||
let extracted = extract_file(&path).unwrap();
|
||||
assert_eq!(extracted.title, "Borrowing");
|
||||
assert!(extracted.body.contains("Rules of borrowing."));
|
||||
}
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tantivy::collector::{Count, TopDocs};
|
||||
use tantivy::directory::MmapDirectory;
|
||||
use tantivy::query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, TermQuery};
|
||||
use tantivy::schema::{Field, IndexRecordOption, STORED, STRING, Schema, TEXT, Value};
|
||||
use tantivy::{Index, IndexReader, IndexWriter, TantivyDocument, Term, doc};
|
||||
|
||||
use crate::extract::{extract_file, summary_of, supported};
|
||||
use crate::message::{EXPOSURE_FULL, ResponseItem};
|
||||
|
||||
const WRITER_BUDGET: usize = 50_000_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub shared: bool,
|
||||
pub exposure: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct CollectionsFile {
|
||||
#[serde(default, rename = "collection")]
|
||||
collections: Vec<Collection>,
|
||||
}
|
||||
|
||||
pub fn load_collections(manifest: &Path) -> Result<Vec<Collection>> {
|
||||
if !manifest.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let raw = fs::read_to_string(manifest).context("reading collections manifest")?;
|
||||
let parsed: CollectionsFile = toml::from_str(&raw).context("parsing collections manifest")?;
|
||||
Ok(parsed.collections)
|
||||
}
|
||||
|
||||
pub fn save_collections(manifest: &Path, collections: &[Collection]) -> Result<()> {
|
||||
if let Some(parent) = manifest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = CollectionsFile {
|
||||
collections: collections.to_vec(),
|
||||
};
|
||||
fs::write(manifest, toml::to_string_pretty(&file)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchHit {
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub published: String,
|
||||
pub exposure: String,
|
||||
pub collection: String,
|
||||
pub shared: bool,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
pub fn response_items(hits: &[SearchHit]) -> Vec<ResponseItem> {
|
||||
hits.iter()
|
||||
.map(|hit| ResponseItem {
|
||||
url: hit.url.clone(),
|
||||
title: hit.title.clone(),
|
||||
summary: hit.summary.clone(),
|
||||
published: hit.published.clone(),
|
||||
exposure: hit.exposure.clone(),
|
||||
content: if hit.exposure == EXPOSURE_FULL {
|
||||
hit.content.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Fields {
|
||||
url: Field,
|
||||
title: Field,
|
||||
body: Field,
|
||||
summary: Field,
|
||||
published: Field,
|
||||
exposure: Field,
|
||||
collection: Field,
|
||||
shared: Field,
|
||||
}
|
||||
|
||||
pub struct LocalIndex {
|
||||
index: Index,
|
||||
reader: IndexReader,
|
||||
writer: Mutex<IndexWriter>,
|
||||
fields: Fields,
|
||||
}
|
||||
|
||||
impl LocalIndex {
|
||||
pub fn open(dir: &Path) -> Result<Self> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let schema = build_schema();
|
||||
let index = Index::open_or_create(MmapDirectory::open(dir)?, schema)
|
||||
.context("opening tantivy index")?;
|
||||
let schema = index.schema();
|
||||
let fields = Fields {
|
||||
url: schema.get_field("url")?,
|
||||
title: schema.get_field("title")?,
|
||||
body: schema.get_field("body")?,
|
||||
summary: schema.get_field("summary")?,
|
||||
published: schema.get_field("published")?,
|
||||
exposure: schema.get_field("exposure")?,
|
||||
collection: schema.get_field("collection")?,
|
||||
shared: schema.get_field("shared")?,
|
||||
};
|
||||
let reader = index.reader()?;
|
||||
let writer = index.writer::<TantivyDocument>(WRITER_BUDGET)?;
|
||||
Ok(Self {
|
||||
index,
|
||||
reader,
|
||||
writer: Mutex::new(writer),
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_collection(&self, collection: &Collection) -> Result<usize> {
|
||||
let root = PathBuf::from(&collection.path);
|
||||
{
|
||||
let writer = self.writer.lock().expect("writer lock");
|
||||
writer.delete_term(Term::from_field_text(
|
||||
self.fields.collection,
|
||||
&collection.name,
|
||||
));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(&root, &mut files);
|
||||
let mut added = 0;
|
||||
for file in files {
|
||||
if self.add_file(collection, &file)? {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
self.commit()?;
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
pub fn add_file(&self, collection: &Collection, path: &Path) -> Result<bool> {
|
||||
let Some(extracted) = extract_file(path) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let url = format!("file://{}", canonical.display());
|
||||
let summary = summary_of(&extracted.body);
|
||||
let shared = if collection.shared { "true" } else { "false" };
|
||||
let writer = self.writer.lock().expect("writer lock");
|
||||
writer.delete_term(Term::from_field_text(self.fields.url, &url));
|
||||
writer.add_document(doc!(
|
||||
self.fields.url => url.as_str(),
|
||||
self.fields.title => extracted.title.as_str(),
|
||||
self.fields.body => extracted.body.as_str(),
|
||||
self.fields.summary => summary.as_str(),
|
||||
self.fields.published => extracted.published.as_str(),
|
||||
self.fields.exposure => collection.exposure.as_str(),
|
||||
self.fields.collection => collection.name.as_str(),
|
||||
self.fields.shared => shared,
|
||||
))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn commit(&self) -> Result<()> {
|
||||
self.writer.lock().expect("writer lock").commit()?;
|
||||
self.reader.reload()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn search(
|
||||
&self,
|
||||
text: &str,
|
||||
limit: usize,
|
||||
only_shared: bool,
|
||||
) -> Result<(Vec<SearchHit>, u64)> {
|
||||
let limit = limit.max(1);
|
||||
self.reader.reload()?;
|
||||
let searcher = self.reader.searcher();
|
||||
let user_query: Box<dyn Query> = if text.trim().is_empty() {
|
||||
Box::new(AllQuery)
|
||||
} else {
|
||||
let parser =
|
||||
QueryParser::for_index(&self.index, vec![self.fields.title, self.fields.body]);
|
||||
let (query, _errors) = parser.parse_query_lenient(text);
|
||||
query
|
||||
};
|
||||
let query: Box<dyn Query> = if only_shared {
|
||||
let shared_term = TermQuery::new(
|
||||
Term::from_field_text(self.fields.shared, "true"),
|
||||
IndexRecordOption::Basic,
|
||||
);
|
||||
Box::new(BooleanQuery::new(vec![
|
||||
(Occur::Must, user_query),
|
||||
(Occur::Must, Box::new(shared_term)),
|
||||
]))
|
||||
} else {
|
||||
user_query
|
||||
};
|
||||
let total = searcher.search(&*query, &Count)? as u64;
|
||||
let top = searcher.search(&*query, &TopDocs::with_limit(limit).order_by_score())?;
|
||||
let mut hits = Vec::with_capacity(top.len());
|
||||
for (_score, address) in top {
|
||||
let document: TantivyDocument = searcher.doc(address)?;
|
||||
hits.push(SearchHit {
|
||||
url: text_value(&document, self.fields.url),
|
||||
title: text_value(&document, self.fields.title),
|
||||
summary: text_value(&document, self.fields.summary),
|
||||
published: text_value(&document, self.fields.published),
|
||||
exposure: text_value(&document, self.fields.exposure),
|
||||
collection: text_value(&document, self.fields.collection),
|
||||
shared: text_value(&document, self.fields.shared) == "true",
|
||||
content: Some(text_value(&document, self.fields.body)),
|
||||
});
|
||||
}
|
||||
Ok((hits, total))
|
||||
}
|
||||
|
||||
pub fn doc_count(&self) -> u64 {
|
||||
self.reader.searcher().num_docs()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_value(document: &TantivyDocument, field: Field) -> String {
|
||||
document
|
||||
.get_first(field)
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_schema() -> Schema {
|
||||
let mut builder = Schema::builder();
|
||||
builder.add_text_field("url", STRING | STORED);
|
||||
builder.add_text_field("title", TEXT | STORED);
|
||||
builder.add_text_field("body", TEXT | STORED);
|
||||
builder.add_text_field("summary", STORED);
|
||||
builder.add_text_field("published", STORED);
|
||||
builder.add_text_field("exposure", STRING | STORED);
|
||||
builder.add_text_field("collection", STRING | STORED);
|
||||
builder.add_text_field("shared", STRING | STORED);
|
||||
builder.build()
|
||||
}
|
||||
|
||||
fn walk(root: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if file_type.is_dir() {
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
walk(&entry.path(), out);
|
||||
} else if file_type.is_file() && supported(&entry.path()) {
|
||||
out.push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::EXPOSURE_METADATA;
|
||||
|
||||
fn collection(name: &str, root: &Path, shared: bool, exposure: &str) -> Collection {
|
||||
Collection {
|
||||
name: name.to_string(),
|
||||
path: root.display().to_string(),
|
||||
shared,
|
||||
exposure: exposure.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_filter_and_exposure_are_enforced() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let shared_dir = temp.path().join("shared");
|
||||
let private_dir = temp.path().join("private");
|
||||
fs::create_dir_all(&shared_dir).unwrap();
|
||||
fs::create_dir_all(&private_dir).unwrap();
|
||||
fs::write(shared_dir.join("a.txt"), "rust ownership rules").unwrap();
|
||||
fs::write(private_dir.join("b.txt"), "rust secret notes").unwrap();
|
||||
|
||||
let index = LocalIndex::open(&temp.path().join("index")).unwrap();
|
||||
index
|
||||
.add_collection(&collection("shared", &shared_dir, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
index
|
||||
.add_collection(&collection(
|
||||
"private",
|
||||
&private_dir,
|
||||
false,
|
||||
EXPOSURE_METADATA,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let (hits, total) = index.search("rust", 10, false).unwrap();
|
||||
assert_eq!(total, 2);
|
||||
assert_eq!(hits.len(), 2);
|
||||
|
||||
let (shared_hits, shared_total) = index.search("rust", 10, true).unwrap();
|
||||
assert_eq!(shared_total, 1);
|
||||
assert_eq!(shared_hits.len(), 1);
|
||||
assert!(shared_hits[0].url.contains("shared"));
|
||||
|
||||
let items = response_items(&shared_hits);
|
||||
assert!(items[0].content.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reindex_removes_deleted_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path().join("corpus");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("a.txt"), "rust one").unwrap();
|
||||
fs::write(dir.join("b.txt"), "rust two").unwrap();
|
||||
let index = LocalIndex::open(&temp.path().join("index")).unwrap();
|
||||
index
|
||||
.add_collection(&collection("test", &dir, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
assert_eq!(index.search("rust", 10, false).unwrap().1, 2);
|
||||
|
||||
fs::remove_file(dir.join("b.txt")).unwrap();
|
||||
index
|
||||
.add_collection(&collection("test", &dir, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
assert_eq!(index.search("rust", 10, false).unwrap().1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_exposure_withholds_content() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path().join("corpus");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("a.txt"), "metadata only").unwrap();
|
||||
let index = LocalIndex::open(&temp.path().join("index")).unwrap();
|
||||
index
|
||||
.add_collection(&collection("test", &dir, true, EXPOSURE_METADATA))
|
||||
.unwrap();
|
||||
let (hits, _) = index.search("metadata", 10, true).unwrap();
|
||||
let items = response_items(&hits);
|
||||
assert!(items[0].content.is_none());
|
||||
assert_eq!(items[0].exposure, EXPOSURE_METADATA);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod extract;
|
||||
pub mod index;
|
||||
pub mod message;
|
||||
pub mod node;
|
||||
pub mod relay;
|
||||
pub mod render;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const PROTOCOL: &str = "FRX/0.3";
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use frxd::config::Config;
|
||||
use frxd::crypto::Keypair;
|
||||
use frxd::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
|
||||
use frxd::{commands, node, relay};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "frxd",
|
||||
version,
|
||||
about = "FRX member node — querier, responder, and local index (Draft 0.3)"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long, global = true, default_value = "frxd.toml")]
|
||||
config: PathBuf,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
Init {
|
||||
#[arg(long, default_value = "member")]
|
||||
name: String,
|
||||
#[arg(long, default_value = "127.0.0.1:7701")]
|
||||
listen: String,
|
||||
#[arg(long, default_value = "http://127.0.0.1:7700")]
|
||||
relay: String,
|
||||
#[arg(long, default_value = "./frx-data")]
|
||||
data_dir: String,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
Add {
|
||||
path: PathBuf,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
shared: bool,
|
||||
#[arg(long, value_enum, default_value_t = Exposure::Full)]
|
||||
exposure: Exposure,
|
||||
},
|
||||
Reindex,
|
||||
Search {
|
||||
text: String,
|
||||
#[arg(long, default_value_t = 10)]
|
||||
limit: usize,
|
||||
},
|
||||
Query {
|
||||
text: String,
|
||||
#[arg(long)]
|
||||
max_results: Option<usize>,
|
||||
#[arg(long)]
|
||||
timeout_ms: Option<u64>,
|
||||
#[arg(long)]
|
||||
local_only: bool,
|
||||
},
|
||||
Serve,
|
||||
Relay {
|
||||
#[arg(long, default_value = "127.0.0.1:7700")]
|
||||
listen: String,
|
||||
#[arg(long, default_value_t = relay::DEFAULT_CAPACITY)]
|
||||
capacity: usize,
|
||||
},
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
enum Exposure {
|
||||
Metadata,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::Init {
|
||||
name,
|
||||
listen,
|
||||
relay,
|
||||
data_dir,
|
||||
force,
|
||||
} => {
|
||||
if cli.config.exists() && !force {
|
||||
bail!(
|
||||
"config {} already exists (use --force to overwrite)",
|
||||
cli.config.display()
|
||||
);
|
||||
}
|
||||
let config = Config::new(&name, &listen, &relay, &data_dir);
|
||||
let key = Keypair::generate();
|
||||
config.save_key(&key)?;
|
||||
config.save(&cli.config)?;
|
||||
println!("wrote config {}", cli.config.display());
|
||||
println!("wrote key {}", config.key_path().display());
|
||||
println!("pubkey {}", key.public_hex());
|
||||
println!("data dir {}", config.data_dir().display());
|
||||
}
|
||||
Command::Add {
|
||||
path,
|
||||
name,
|
||||
shared,
|
||||
exposure,
|
||||
} => {
|
||||
let exposure = match exposure {
|
||||
Exposure::Metadata => EXPOSURE_METADATA,
|
||||
Exposure::Full => EXPOSURE_FULL,
|
||||
};
|
||||
commands::add(&cli.config, &path, name, shared, exposure)?;
|
||||
}
|
||||
Command::Reindex => commands::reindex(&cli.config)?,
|
||||
Command::Search { text, limit } => commands::search(&cli.config, &text, limit).await?,
|
||||
Command::Query {
|
||||
text,
|
||||
max_results,
|
||||
timeout_ms,
|
||||
local_only,
|
||||
} => {
|
||||
commands::query(&cli.config, &text, max_results, timeout_ms, !local_only).await?;
|
||||
}
|
||||
Command::Serve => {
|
||||
let config = Config::load(&cli.config)?;
|
||||
let handle = node::Node::start(config).await?;
|
||||
println!("frxd listening on http://{}", handle.addr);
|
||||
println!("pubkey {}", handle.pubkey);
|
||||
println!("control API POST http://{}/v1/local/query", handle.addr);
|
||||
tokio::signal::ctrl_c().await?;
|
||||
}
|
||||
Command::Relay { listen, capacity } => {
|
||||
let (listener, addr) = relay::bind(&listen).await?;
|
||||
println!("relay listening on http://{addr} (capacity {capacity})");
|
||||
relay::run(listener, capacity).await?;
|
||||
}
|
||||
Command::Status => commands::status(&cli.config).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
use crate::crypto::{Keypair, now_ts, random_nonce, signing_bytes, verify_envelope};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const TYPE_QUERY: &str = "query";
|
||||
pub const TYPE_RESPONSE: &str = "response";
|
||||
pub const TYPE_AGGREGATE: &str = "aggregate";
|
||||
|
||||
pub const EXPOSURE_METADATA: &str = "metadata";
|
||||
pub const EXPOSURE_FULL: &str = "full";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
#[serde(rename = "type")]
|
||||
pub msg_type: String,
|
||||
pub from: String,
|
||||
pub ts: u64,
|
||||
pub nonce: String,
|
||||
pub body: Value,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
pub fn new(key: &Keypair, msg_type: &str, body: Value) -> Self {
|
||||
let mut envelope = Self {
|
||||
msg_type: msg_type.to_string(),
|
||||
from: key.public_hex(),
|
||||
ts: now_ts(),
|
||||
nonce: random_nonce(),
|
||||
body,
|
||||
sig: String::new(),
|
||||
};
|
||||
envelope.sig = key.sign(&signing_bytes(&envelope));
|
||||
envelope
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
verify_envelope(self)
|
||||
}
|
||||
|
||||
pub fn parse_body<T: DeserializeOwned>(&self) -> Result<T> {
|
||||
serde_json::from_value(self.body.clone()).context("malformed body")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Budget {
|
||||
pub max_results: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryBody {
|
||||
pub qid: String,
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub entities: Vec<String>,
|
||||
pub budget: Budget,
|
||||
}
|
||||
|
||||
impl QueryBody {
|
||||
pub fn new(text: &str, max_results: usize) -> Self {
|
||||
Self {
|
||||
qid: crate::crypto::random_id(),
|
||||
text: text.to_string(),
|
||||
entities: Vec::new(),
|
||||
budget: Budget {
|
||||
max_results: max_results.clamp(1, 1000),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResponseItem {
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub published: String,
|
||||
pub exposure: String,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResponseBody {
|
||||
pub qid: String,
|
||||
pub results: Vec<ResponseItem>,
|
||||
pub truncated: bool,
|
||||
pub more_available: u64,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_response(
|
||||
qid: &str,
|
||||
hits: Vec<ResponseItem>,
|
||||
total: u64,
|
||||
max_results: usize,
|
||||
) -> ResponseBody {
|
||||
let max_results = max_results.max(1);
|
||||
let mut results = hits;
|
||||
results.truncate(max_results);
|
||||
let more_available = total.saturating_sub(results.len() as u64);
|
||||
ResponseBody {
|
||||
qid: qid.to_string(),
|
||||
results,
|
||||
truncated: more_available > 0,
|
||||
more_available,
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AggregateBody {
|
||||
pub period: String,
|
||||
pub sent: u64,
|
||||
pub passed: u64,
|
||||
pub cited: u64,
|
||||
}
|
||||
|
||||
pub fn require_type(envelope: &Envelope, expected: &str) -> Result<()> {
|
||||
if envelope.msg_type != expected {
|
||||
return Err(anyhow!(
|
||||
"expected message type {expected}, got {}",
|
||||
envelope.msg_type
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn item(n: usize) -> ResponseItem {
|
||||
ResponseItem {
|
||||
url: format!("file:///doc{n}.txt"),
|
||||
title: format!("doc {n}"),
|
||||
summary: String::new(),
|
||||
published: String::new(),
|
||||
exposure: EXPOSURE_METADATA.to_string(),
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_results_is_respected() {
|
||||
let response = build_response("q1", vec![item(1), item(2), item(3)], 10, 2);
|
||||
assert_eq!(response.results.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_honest() {
|
||||
let response = build_response("q1", vec![item(1), item(2), item(3)], 10, 2);
|
||||
assert!(response.truncated);
|
||||
assert_eq!(response.more_available, 8);
|
||||
|
||||
let response = build_response("q2", vec![item(1), item(2)], 2, 5);
|
||||
assert!(!response.truncated);
|
||||
assert_eq!(response.more_available, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_carries_no_scores() {
|
||||
let response = build_response("q1", vec![item(1)], 1, 5);
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(!json.contains("score"));
|
||||
assert!(!json.contains("relevance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_budget_is_clamped() {
|
||||
let query = QueryBody::new("anything", 0);
|
||||
assert_eq!(query.budget.max_results, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_wrong_type() {
|
||||
let key = Keypair::generate();
|
||||
let envelope = Envelope::new(&key, TYPE_RESPONSE, json!({}));
|
||||
assert!(require_type(&envelope, TYPE_QUERY).is_err());
|
||||
}
|
||||
}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::crypto::Keypair;
|
||||
use crate::index::{LocalIndex, SearchHit, response_items};
|
||||
use crate::message::{
|
||||
Envelope, QueryBody, ResponseBody, ResponseItem, TYPE_AGGREGATE, TYPE_QUERY, TYPE_RESPONSE,
|
||||
build_response,
|
||||
};
|
||||
|
||||
pub struct Node {
|
||||
pub config: Config,
|
||||
pub key: Keypair,
|
||||
index: LocalIndex,
|
||||
seen: Mutex<HashSet<String>>,
|
||||
pending: Mutex<HashMap<String, Vec<(String, ResponseBody)>>>,
|
||||
client: reqwest::Client,
|
||||
sent: AtomicU64,
|
||||
received: AtomicU64,
|
||||
}
|
||||
|
||||
pub struct NodeHandle {
|
||||
pub addr: SocketAddr,
|
||||
pub pubkey: String,
|
||||
pub node: Arc<Node>,
|
||||
tasks: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for NodeHandle {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteResponse {
|
||||
pub member: String,
|
||||
pub results: Vec<ResponseItem>,
|
||||
pub truncated: bool,
|
||||
pub more_available: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MergedItem {
|
||||
pub provenance: String,
|
||||
#[serde(flatten)]
|
||||
pub item: ResponseItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalQueryOutcome {
|
||||
pub qid: String,
|
||||
pub text: String,
|
||||
pub local: LocalPart,
|
||||
pub responses: Vec<RemoteResponse>,
|
||||
pub merged: Vec<MergedItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalPart {
|
||||
pub results: Vec<ResponseItem>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn open(config: Config) -> Result<Arc<Self>> {
|
||||
let key = config.load_key()?;
|
||||
let index = LocalIndex::open(&config.index_dir())?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.context("building http client")?;
|
||||
Ok(Arc::new(Self {
|
||||
config,
|
||||
key,
|
||||
index,
|
||||
seen: Mutex::new(HashSet::new()),
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
client,
|
||||
sent: AtomicU64::new(0),
|
||||
received: AtomicU64::new(0),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn doc_count(&self) -> u64 {
|
||||
self.index.doc_count()
|
||||
}
|
||||
|
||||
pub fn sent(&self) -> u64 {
|
||||
self.sent.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn received(&self) -> u64 {
|
||||
self.received.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn local_search(&self, text: &str, limit: usize) -> Result<(Vec<SearchHit>, u64)> {
|
||||
self.index.search(text, limit, false)
|
||||
}
|
||||
|
||||
pub async fn start(config: Config) -> Result<NodeHandle> {
|
||||
let node = Node::open(config)?;
|
||||
let listener = TcpListener::bind(&node.config.node.listen)
|
||||
.await
|
||||
.with_context(|| format!("binding {}", node.config.node.listen))?;
|
||||
let addr = listener.local_addr()?;
|
||||
let mut tasks = Vec::new();
|
||||
for relay in node.config.node.relays.clone() {
|
||||
tasks.push(tokio::spawn(poll_relay(node.clone(), relay)));
|
||||
}
|
||||
let app = router(node.clone());
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if let Err(error) = axum::serve(listener, app).await {
|
||||
eprintln!("control server stopped: {error}");
|
||||
}
|
||||
}));
|
||||
Ok(NodeHandle {
|
||||
addr,
|
||||
pubkey: node.key.public_hex(),
|
||||
node,
|
||||
tasks,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn local_query(
|
||||
&self,
|
||||
text: &str,
|
||||
max_results: Option<usize>,
|
||||
timeout_ms: Option<u64>,
|
||||
network: bool,
|
||||
) -> Result<LocalQueryOutcome> {
|
||||
let max = max_results.unwrap_or(self.config.query.max_results).max(1);
|
||||
let query = QueryBody::new(text, max);
|
||||
let qid = query.qid.clone();
|
||||
let (local_hits, local_total) = self.index.search(text, max, false)?;
|
||||
let local_items = response_items(&local_hits);
|
||||
let mut responses = Vec::new();
|
||||
if network {
|
||||
self.pending
|
||||
.lock()
|
||||
.expect("pending lock")
|
||||
.insert(qid.clone(), Vec::new());
|
||||
self.sent.fetch_add(1, Ordering::SeqCst);
|
||||
let envelope = Envelope::new(&self.key, TYPE_QUERY, serde_json::to_value(&query)?);
|
||||
self.publish(&envelope).await;
|
||||
let timeout = Duration::from_millis(timeout_ms.unwrap_or(self.config.query.timeout_ms));
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
let collected = self
|
||||
.pending
|
||||
.lock()
|
||||
.expect("pending lock")
|
||||
.remove(&qid)
|
||||
.unwrap_or_default();
|
||||
self.received
|
||||
.fetch_add(collected.len() as u64, Ordering::SeqCst);
|
||||
for (member, body) in collected {
|
||||
responses.push(RemoteResponse {
|
||||
member,
|
||||
results: body.results,
|
||||
truncated: body.truncated,
|
||||
more_available: body.more_available,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut merged = Vec::new();
|
||||
let mut seen_urls = HashSet::new();
|
||||
for item in &local_items {
|
||||
if seen_urls.insert(item.url.clone()) {
|
||||
merged.push(MergedItem {
|
||||
provenance: "local".to_string(),
|
||||
item: item.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for response in &responses {
|
||||
for item in &response.results {
|
||||
if seen_urls.insert(item.url.clone()) {
|
||||
merged.push(MergedItem {
|
||||
provenance: response.member.clone(),
|
||||
item: item.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LocalQueryOutcome {
|
||||
qid,
|
||||
text: text.to_string(),
|
||||
local: LocalPart {
|
||||
results: local_items,
|
||||
total: local_total,
|
||||
},
|
||||
responses,
|
||||
merged,
|
||||
})
|
||||
}
|
||||
|
||||
async fn publish(&self, envelope: &Envelope) -> usize {
|
||||
let mut delivered = 0;
|
||||
for relay in &self.config.node.relays {
|
||||
let url = format!("{}/v1/publish", relay.trim_end_matches('/'));
|
||||
match self.client.post(&url).json(envelope).send().await {
|
||||
Ok(response) if response.status().is_success() => delivered += 1,
|
||||
Ok(response) => eprintln!("relay {relay} rejected query: {}", response.status()),
|
||||
Err(error) => eprintln!("relay {relay} unreachable: {error}"),
|
||||
}
|
||||
}
|
||||
delivered
|
||||
}
|
||||
|
||||
async fn dispatch(self: &Arc<Self>, envelope: Envelope, relay: &str) {
|
||||
if envelope.verify().is_err() {
|
||||
return;
|
||||
}
|
||||
if !self.config.node.trusted_keys.is_empty()
|
||||
&& !self.config.node.trusted_keys.contains(&envelope.from)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match envelope.msg_type.as_str() {
|
||||
TYPE_QUERY => {
|
||||
if envelope.from == self.key.public_hex() || !self.config.node.responder {
|
||||
return;
|
||||
}
|
||||
let Ok(query) = envelope.parse_body::<QueryBody>() else {
|
||||
return;
|
||||
};
|
||||
if query.text.trim().is_empty() || query.qid.is_empty() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut seen = self.seen.lock().expect("seen lock");
|
||||
if !seen.insert(query.qid.clone()) {
|
||||
return;
|
||||
}
|
||||
if seen.len() > 10_000 {
|
||||
seen.clear();
|
||||
}
|
||||
}
|
||||
let node = self.clone();
|
||||
let relay = relay.to_string();
|
||||
let querier = envelope.from.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = node.respond(&query, &querier, &relay).await {
|
||||
eprintln!("responder failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
TYPE_RESPONSE => {
|
||||
let Ok(body) = envelope.parse_body::<ResponseBody>() else {
|
||||
return;
|
||||
};
|
||||
let mut pending = self.pending.lock().expect("pending lock");
|
||||
if let Some(list) = pending.get_mut(&body.qid) {
|
||||
list.push((envelope.from.clone(), body));
|
||||
}
|
||||
}
|
||||
TYPE_AGGREGATE => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond(&self, query: &QueryBody, querier: &str, relay: &str) -> Result<()> {
|
||||
let max = query.budget.max_results.clamp(1, 1000);
|
||||
let (hits, total) = self.index.search(&query.text, max, true)?;
|
||||
if hits.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let body = build_response(&query.qid, response_items(&hits), total, max);
|
||||
let envelope = Envelope::new(&self.key, TYPE_RESPONSE, serde_json::to_value(&body)?);
|
||||
let mut relays = vec![relay.to_string()];
|
||||
for configured in &self.config.node.relays {
|
||||
if !relays.contains(configured) {
|
||||
relays.push(configured.clone());
|
||||
}
|
||||
}
|
||||
let mut last_error: Option<anyhow::Error> = None;
|
||||
for candidate in relays {
|
||||
let url = format!(
|
||||
"{}/v1/unicast?to={}",
|
||||
candidate.trim_end_matches('/'),
|
||||
querier
|
||||
);
|
||||
match self.client.post(&url).json(&envelope).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => {
|
||||
last_error = Some(anyhow!(
|
||||
"relay {candidate} rejected response: {}",
|
||||
response.status()
|
||||
))
|
||||
}
|
||||
Err(error) => last_error = Some(anyhow!("relay {candidate} unreachable: {error}")),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| anyhow!("no relays configured")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_relay(node: Arc<Node>, relay: String) {
|
||||
let base = relay.trim_end_matches('/').to_string();
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/v1/poll?member={}&timeout_ms=20000",
|
||||
base,
|
||||
node.key.public_hex()
|
||||
);
|
||||
match node.client.get(&url).send().await {
|
||||
Ok(response) if response.status() == reqwest::StatusCode::NO_CONTENT => continue,
|
||||
Ok(response) if response.status().is_success() => {
|
||||
match response.json::<Value>().await {
|
||||
Ok(payload) => {
|
||||
if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
|
||||
for message in messages {
|
||||
if let Ok(envelope) =
|
||||
serde_json::from_value::<Envelope>(message.clone())
|
||||
{
|
||||
node.dispatch(envelope, &base).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => tokio::time::sleep(Duration::from_secs(1)).await,
|
||||
}
|
||||
}
|
||||
Ok(_) => tokio::time::sleep(Duration::from_secs(1)).await,
|
||||
Err(_) => tokio::time::sleep(Duration::from_secs(1)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(node: Arc<Node>) -> Router {
|
||||
Router::new()
|
||||
.route("/v1/local/query", post(local_query))
|
||||
.route("/v1/local/status", get(local_status))
|
||||
.with_state(node)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LocalQueryRequest {
|
||||
pub text: String,
|
||||
pub max_results: Option<usize>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(default = "default_network")]
|
||||
pub network: bool,
|
||||
}
|
||||
|
||||
fn default_network() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn local_query(
|
||||
State(node): State<Arc<Node>>,
|
||||
Json(request): Json<LocalQueryRequest>,
|
||||
) -> Response {
|
||||
match node
|
||||
.local_query(
|
||||
&request.text,
|
||||
request.max_results,
|
||||
request.timeout_ms,
|
||||
request.network,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => (StatusCode::OK, Json(outcome)).into_response(),
|
||||
Err(error) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": error.to_string() })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_status(State(node): State<Arc<Node>>) -> Response {
|
||||
Json(json!({
|
||||
"name": node.config.node.name,
|
||||
"pubkey": node.key.public_hex(),
|
||||
"listen": node.config.node.listen,
|
||||
"relays": node.config.node.relays,
|
||||
"responder": node.config.node.responder,
|
||||
"doc_count": node.doc_count(),
|
||||
"sent": node.sent(),
|
||||
"received": node.received(),
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn control_query(
|
||||
base: &str,
|
||||
text: &str,
|
||||
max_results: Option<usize>,
|
||||
timeout_ms: Option<u64>,
|
||||
network: bool,
|
||||
) -> Result<Value> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
let url = format!("{}/v1/local/query", base.trim_end_matches('/'));
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&json!({
|
||||
"text": text,
|
||||
"max_results": max_results,
|
||||
"timeout_ms": timeout_ms,
|
||||
"network": network,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("calling local node at {url} (is `frxd serve` running?)"))?;
|
||||
let status = response.status();
|
||||
let value: Value = response.json().await.context("parsing node response")?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("local node error {status}: {value}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::message::{Envelope, TYPE_AGGREGATE, TYPE_QUERY, TYPE_RESPONSE};
|
||||
|
||||
pub const DEFAULT_CAPACITY: usize = 256;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemberQueue {
|
||||
items: VecDeque<(u64, Envelope)>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
members: HashMap<String, MemberQueue>,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
pub struct Relay {
|
||||
inner: Mutex<Inner>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl Relay {
|
||||
pub fn new(capacity: usize) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: Mutex::new(Inner {
|
||||
members: HashMap::new(),
|
||||
seq: 0,
|
||||
}),
|
||||
capacity,
|
||||
})
|
||||
}
|
||||
|
||||
fn push(&self, targets: Option<&str>, envelope: Envelope) -> Result<usize, StatusCode> {
|
||||
let mut inner = self.inner.lock().expect("relay lock");
|
||||
if inner
|
||||
.members
|
||||
.values()
|
||||
.any(|q| q.items.len() >= self.capacity)
|
||||
{
|
||||
return Err(StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
inner.seq += 1;
|
||||
let seq = inner.seq;
|
||||
match targets {
|
||||
Some(member) => {
|
||||
let Some(queue) = inner.members.get_mut(member) else {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
};
|
||||
queue.items.push_back((seq, envelope));
|
||||
Ok(1)
|
||||
}
|
||||
None => {
|
||||
let from = envelope.from.clone();
|
||||
inner.members.entry(from).or_default();
|
||||
let mut delivered = 0;
|
||||
for queue in inner.members.values_mut() {
|
||||
queue.items.push_back((seq, envelope.clone()));
|
||||
delivered += 1;
|
||||
}
|
||||
Ok(delivered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(relay: Arc<Relay>) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/v1/publish", post(publish))
|
||||
.route("/v1/unicast", post(unicast))
|
||||
.route("/v1/poll", get(poll))
|
||||
.with_state(relay)
|
||||
}
|
||||
|
||||
pub async fn run(listener: TcpListener, capacity: usize) -> Result<()> {
|
||||
let relay = Relay::new(capacity);
|
||||
axum::serve(listener, router(relay)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn bind(addr: &str) -> Result<(TcpListener, SocketAddr)> {
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
let local = listener.local_addr()?;
|
||||
Ok((listener, local))
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn publish(State(relay): State<Arc<Relay>>, Json(envelope): Json<Envelope>) -> Response {
|
||||
if envelope.verify().is_err() {
|
||||
return bad_request("invalid signature");
|
||||
}
|
||||
if envelope.msg_type != TYPE_QUERY {
|
||||
return bad_request("relay carries broadcast queries only");
|
||||
}
|
||||
match relay.push(None, envelope) {
|
||||
Ok(delivered) => (
|
||||
StatusCode::ACCEPTED,
|
||||
Json(json!({ "delivered": delivered })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(status) => backpressure(status),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UnicastParams {
|
||||
to: String,
|
||||
}
|
||||
|
||||
async fn unicast(
|
||||
State(relay): State<Arc<Relay>>,
|
||||
Query(params): Query<UnicastParams>,
|
||||
Json(envelope): Json<Envelope>,
|
||||
) -> Response {
|
||||
if envelope.verify().is_err() {
|
||||
return bad_request("invalid signature");
|
||||
}
|
||||
if envelope.msg_type != TYPE_RESPONSE && envelope.msg_type != TYPE_AGGREGATE {
|
||||
return bad_request("unicast carries responses and aggregates only");
|
||||
}
|
||||
match relay.push(Some(¶ms.to), envelope) {
|
||||
Ok(_) => (StatusCode::OK, Json(json!({ "delivered": true }))).into_response(),
|
||||
Err(StatusCode::NOT_FOUND) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "member not connected" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(status) => backpressure(status),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PollParams {
|
||||
member: String,
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
async fn poll(State(relay): State<Arc<Relay>>, Query(params): Query<PollParams>) -> Response {
|
||||
if params.member.is_empty() || hex::decode(¶ms.member).is_err() {
|
||||
return bad_request("valid member key required");
|
||||
}
|
||||
let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(25_000).min(60_000));
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let batch: Vec<Envelope> = {
|
||||
let mut inner = relay.inner.lock().expect("relay lock");
|
||||
let queue = inner.members.entry(params.member.clone()).or_default();
|
||||
queue
|
||||
.items
|
||||
.drain(..)
|
||||
.map(|(_, envelope)| envelope)
|
||||
.collect()
|
||||
};
|
||||
if !batch.is_empty() {
|
||||
return (StatusCode::OK, Json(json!({ "messages": batch }))).into_response();
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return StatusCode::NO_CONTENT.into_response();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn bad_request(message: &str) -> Response {
|
||||
(StatusCode::BAD_REQUEST, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
fn backpressure(status: StatusCode) -> Response {
|
||||
(
|
||||
status,
|
||||
[(header::RETRY_AFTER, "1")],
|
||||
Json(json!({ "error": "transport backpressure" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::index::SearchHit;
|
||||
|
||||
pub fn local_hits(hits: &[SearchHit], total: u64) {
|
||||
println!("{total} local match(es), showing {}", hits.len());
|
||||
for hit in hits {
|
||||
let scope = if hit.shared { "shared" } else { "private" };
|
||||
println!("- [{} / {}] {}", hit.collection, scope, hit.title);
|
||||
println!(" {}", hit.url);
|
||||
if !hit.summary.is_empty() {
|
||||
println!(" {}", hit.summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_outcome(value: &Value) {
|
||||
if let Some(qid) = value.get("qid").and_then(Value::as_str) {
|
||||
println!("qid {qid}");
|
||||
}
|
||||
let local_total = value
|
||||
.pointer("/local/total")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
println!("local: {local_total} match(es)");
|
||||
if let Some(items) = value.pointer("/local/results").and_then(Value::as_array) {
|
||||
for item in items {
|
||||
print_item("local", item);
|
||||
}
|
||||
}
|
||||
if let Some(responses) = value.get("responses").and_then(Value::as_array) {
|
||||
println!("remote: {} response(s)", responses.len());
|
||||
for response in responses {
|
||||
let member = response
|
||||
.get("member")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let short = member.get(..16).unwrap_or(member);
|
||||
let truncated = response
|
||||
.get("truncated")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let more = response
|
||||
.get("more_available")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
println!(" from member {short}... (truncated={truncated}, more_available={more})");
|
||||
if let Some(results) = response.get("results").and_then(Value::as_array) {
|
||||
for item in results {
|
||||
print_item(short, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_item(provenance: &str, item: &Value) {
|
||||
let title = item.get("title").and_then(Value::as_str).unwrap_or("");
|
||||
let url = item.get("url").and_then(Value::as_str).unwrap_or("");
|
||||
let exposure = item.get("exposure").and_then(Value::as_str).unwrap_or("");
|
||||
println!("- [{provenance}] {title} ({exposure})");
|
||||
println!(" {url}");
|
||||
}
|
||||
Reference in New Issue
Block a user