Merge remote-tracking branch 'origin/main' into feat/spell-check

# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
bflatastic
2026-10-11 10:22:24 +07:00
99 changed files with 7284 additions and 1106 deletions
+36
View File
@@ -158,6 +158,7 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy)
};
table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0)) * 1000.0);
measure_boolean_drag(table, col);
let mut s = Session::with_document(doc);
let doc = s.doc().map_err(failed)?.clone();
@@ -240,6 +241,41 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
Ok(())
}
/// Live boolean editing: one drag frame of an operand of a boolean group of 24 overlapping
/// circles, i.e. the group's result recomputed with the operand somewhere new (each frame a new
/// position, as in a real drag). The top operand recombines one step; the bottom one, all 23.
fn measure_boolean_drag(table: &mut Table, col: usize) {
let mut d = Document::new("booleans");
let Some(page) = d.first_page() else { return };
let Ok(b) = d.create(page, NodeKind::BooleanOp { op: figcraft_doc::BoolOp::Union }, "Union", 0.0, 0.0, 0.0, 0.0) else { return };
let kids: Vec<NodeId> = (0..24)
.filter_map(|i| {
let a = f64::from(i) * std::f64::consts::TAU / 24.0;
d.create(
b,
NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 },
"Circle",
200.0 + 120.0 * a.cos(),
200.0 + 120.0 * a.sin(),
90.0,
90.0,
)
.ok()
})
.collect();
let _ = d.fit_to_children(b);
let frame = |which: usize, k: usize| {
let mut moved = d.clone();
if let Some(n) = kids.get(which).and_then(|id| moved.node_mut(*id)) {
// Distinct offsets per run and column, so no frame is served from the cache.
n.transform = figcraft_doc::geom::Affine::translate((0.37 * (k + 1) as f64 + 1000.0 * col as f64, 0.11 * k as f64)) * n.transform;
}
moved.node(b).and_then(|n| figcraft_render::node_outline(&moved, n)).is_some()
};
table.put(col, "boolean of 24: drag frame, top operand", mean(20, |k| _ = frame(23, k)));
table.put(col, "boolean of 24: drag frame, bottom operand", mean(5, |k| _ = frame(0, 100 + k)));
}
/// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design
/// system, for each instance count in `instances`; print the tables.
pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult<ExitCode> {
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "figcraft-server"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
description = "FigCraft's standalone collaboration server: keeps many files with share links (edit or view), persists them as snapshots plus a log, and serves live sessions (headless, native only)"
[lib]
path = "src/lib.rs"
[[bin]]
name = "figcraft-server"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
figcraft-collab = { workspace = true }
figcraft-collab-net = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
crc32fast = { workspace = true }
toml = "1"
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
[dev-dependencies]
figcraft-engine = { workspace = true }
figcraft-doc = { workspace = true }
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
+294
View File
@@ -0,0 +1,294 @@
//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data
//! directory. When a server is running on the directory they ask it (so revoking a link
//! disconnects whoever joined with it at once); otherwise they change the directory themselves.
use std::fmt::Write as _;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
use figcraft_collab_net::hub::Backend;
use figcraft_collab_net::{Endpoint, Invite, manage};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::disk;
use crate::store::{self, KEY, Store};
/// Where a running server says it listens.
pub const SERVER_JSON: &str = "server.json";
pub const USAGE: &str = "\
figcraft-server: FigCraft's collaboration server (files, share links, live sessions)
Usage:
figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
figcraft-server key [--rotate] the address to publish to, with the server key
figcraft-server files [list] the files kept
figcraft-server files remove FILE remove a file and its links for good
figcraft-server links list FILE a file's share links
figcraft-server links create FILE --access edit|view [--label TEXT]
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
Options (also keys of the --config TOML file):
--listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb
-h, --help this text
-V, --version the version
Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
";
/// What to do.
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Serve,
Key { rotate: bool },
Files,
RemoveFile { file: String },
Links { file: String },
CreateLink { file: String, access: Access, label: String },
Revoke { file: String, link: String },
Help,
Version,
}
/// A parsed command line.
#[derive(Clone, Debug, PartialEq)]
pub struct Cli {
pub config: Config,
pub command: Command,
}
/// Read the arguments (after the program name).
pub fn parse(args: &[String]) -> Result<Cli, String> {
// The config file first: flags override it.
let mut config = match args.iter().position(|a| a == "--config") {
Some(i) => Config::load(Path::new(args.get(i + 1).ok_or("--config needs a file")?))?,
None => Config::default(),
};
let mut words = Vec::new();
let mut access = None;
let mut label = String::new();
let mut rotate = false;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
match a.as_str() {
"-h" | "--help" => return Ok(Cli { config, command: Command::Help }),
"-V" | "--version" => return Ok(Cli { config, command: Command::Version }),
"--config" => {
value("--config")?;
}
"--listen" => config.listen = value("--listen")?,
"--data" => config.data = PathBuf::from(value("--data")?),
"--public-url" => config.public_url = Some(value("--public-url")?),
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
"--label" => label = value("--label")?,
"--rotate" => rotate = true,
s if s.starts_with('-') => return Err(format!("unknown option {s}")),
s => words.push(s.to_string()),
}
}
let w: Vec<&str> = words.iter().map(String::as_str).collect();
let command = match w.as_slice() {
[] | ["serve"] => Command::Serve,
["key"] => Command::Key { rotate },
["files"] | ["files", "list"] => Command::Files,
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
["links", "list", file] => Command::Links { file: file.to_string() },
["links", "create", file] => {
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
}
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
_ => return Err(format!("unknown command `{}`", words.join(" "))),
};
config.check()?;
Ok(Cli { config, command })
}
/// What a running server writes into its data directory.
#[derive(Debug, Serialize, Deserialize)]
pub struct Running {
pub listen: SocketAddr,
pub pid: u32,
}
/// Note where the server listens (for the command line).
pub fn write_running(data: &Path, listen: SocketAddr) -> Result<(), String> {
let text = serde_json::to_vec_pretty(&Running { listen, pid: std::process::id() }).map_err(|e| e.to_string())?;
disk::write_atomic(&data.join(SERVER_JSON), &text).map_err(|e| format!("can't write {SERVER_JSON}: {e}"))
}
/// `addr` as something this machine can connect to (the loopback address for "every interface").
fn reachable(addr: SocketAddr) -> SocketAddr {
match addr.ip() {
IpAddr::V4(ip) if ip.is_unspecified() => SocketAddr::from(([127, 0, 0, 1], addr.port())),
IpAddr::V6(ip) if ip.is_unspecified() => SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, addr.port())),
_ => addr,
}
}
/// How share links and the publish address name the server: `public_url`, else the listen
/// address (this machine's network address for "every interface").
pub fn public_endpoint(cfg: &Config, listen: SocketAddr) -> Result<Endpoint, String> {
if let Some(e) = cfg.public()? {
return Ok(e);
}
let ip = if listen.ip().is_unspecified() { figcraft_collab_net::lan_ip().unwrap_or(reachable(listen).ip()) } else { listen.ip() };
Ok(Endpoint::plain(SocketAddr::new(ip, listen.port()).to_string()))
}
/// Run a command other than serving; what to print.
pub fn run(cli: &Cli) -> Result<String, String> {
let cfg = &cli.config;
match &cli.command {
Command::Help => Ok(USAGE.to_string()),
Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
Command::Key { rotate } => key(cfg, *rotate),
Command::Files => match admin(cfg, Admin::Files)? {
AdminReply::Files(files) => Ok(files_table(&files)),
other => unexpected(other),
},
Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
AdminReply::Done => Ok(format!("Removed file {file} and its links.\n")),
other => unexpected(other),
},
Command::Links { file } => match admin(cfg, Admin::Links { file: file.clone() })? {
AdminReply::Links(links) => Ok(links_table(&links)),
other => unexpected(other),
},
Command::CreateLink { file, access, label } => {
match admin(cfg, Admin::CreateLink { file: file.clone(), access: *access, label: label.clone() })? {
AdminReply::Link(l) => Ok(new_link(cfg, &l)?),
other => unexpected(other),
}
}
Command::Revoke { file, link } => match admin(cfg, Admin::Revoke { file: file.clone(), link: link.clone() })? {
AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")),
other => unexpected(other),
},
}
}
fn unexpected(reply: AdminReply) -> Result<String, String> {
match reply {
AdminReply::Failed { reason } => Err(reason),
other => Err(format!("the server answered {other:?}")),
}
}
/// The running server's address, when one holds the data directory.
fn running(data: &Path) -> Result<SocketAddr, String> {
let bytes = disk::read_capped(&data.join(SERVER_JSON), 1 << 16).map_err(|e| format!("a server is using {} but {e}", data.display()))?;
let r: Running = serde_json::from_slice(&bytes).map_err(|e| format!("{SERVER_JSON} is damaged ({e})"))?;
Ok(reachable(r.listen))
}
/// Do one request: through the running server, or on the data directory.
fn admin(cfg: &Config, req: Admin) -> Result<AdminReply, String> {
match Store::try_open(&cfg.data, cfg.store())? {
Some(mut store) => Ok(store.admin(req, &|_| None).reply),
None => {
let addr = running(&cfg.data)?;
let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
let replies = manage::request(&Endpoint::plain(addr.to_string()), key.trim(), vec![req], &cfg.net(), Duration::from_secs(5))?;
replies.into_iter().next().ok_or_else(|| "the server didn't answer".to_string())
}
}
}
/// The address to publish to, with the key.
fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
let mut store = Store::try_open(&cfg.data, cfg.store())?;
let listen = match &store {
Some(_) => cfg.addr()?,
None => running(&cfg.data)?,
};
let key = match (store.as_mut(), rotate) {
(Some(s), true) => s.rotate_key()?,
(Some(s), false) => s.key().to_string(),
(None, true) => return Err("stop the server to replace its key".into()),
(None, false) => std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?.trim().to_string(),
};
let public = public_endpoint(cfg, listen)?;
let mut out = String::new();
if rotate {
out.push_str("A new server key: the old one no longer works.\n");
}
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
let _ = writeln!(out, " {}?key={key}", public.url());
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
Ok(out)
}
fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
let listen = match running(&cfg.data) {
Ok(a) if store::lock(&cfg.data)?.is_none() => a,
_ => cfg.addr()?,
};
let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() };
let mut out = String::new();
let what = if l.link.access == Access::View { "view" } else { "edit" };
let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file);
let _ = writeln!(out, " {}", invite.link());
let _ = writeln!(out, " {}", invite.url());
let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token).");
Ok(out)
}
fn files_table(files: &[FileInfo]) -> String {
if files.is_empty() {
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
}
let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
for f in files {
let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
}
out
}
fn links_table(links: &[LinkInfo]) -> String {
if links.is_empty() {
return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
}
let mut out = String::from("LINK ACCESS CREATED LABEL\n");
for l in links {
let _ = writeln!(out, "{:<16} {:<6} {:<20} {}", l.id, l.access.id(), date(l.created), l.label);
}
out
}
fn elide(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::<String>()) }
}
fn size(bytes: u64) -> String {
match bytes {
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / f64::from(1 << 20)),
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
pub fn date(secs: u64) -> String {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
+107
View File
@@ -0,0 +1,107 @@
//! The command line: parsing (flags over the config file), and managing a data directory both
//! on its own and through the server running on it.
use figcraft_collab::Replica;
use figcraft_collab::message::Access;
use figcraft_doc::Document;
use crate::cli::{self, Cli, Command, date, parse};
use crate::config::Config;
use crate::store::Store;
use crate::test_util::{TempDir, config};
fn args(s: &[&str]) -> Vec<String> {
s.iter().map(|a| a.to_string()).collect()
}
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
cli::run(&Cli { config: config(dir), command })
}
/// A data directory with one published file: its id.
fn with_file(dir: &std::path::Path) -> String {
let mut store = Store::open(dir, config(dir).store()).unwrap();
let snapshot = Replica::new(Document::new("Board")).snapshot().unwrap();
store.publish("Board", &snapshot).unwrap().0.id
}
#[test]
fn parsing() {
let c = parse(&[]).unwrap();
assert_eq!(c.command, Command::Serve);
assert_eq!(c.config, Config::default());
let c = parse(&args(&["serve", "--listen", "0.0.0.0:47900", "--data", "d", "--public-url", "wss://f.example.com/live/"])).unwrap();
assert_eq!((c.config.listen.as_str(), c.config.data.to_str()), ("0.0.0.0:47900", Some("d")));
assert_eq!(
parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command,
Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() }
);
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files);
assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true });
assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help);
for bad in
[&["--nope"][..], &["links", "create", "f1"], &["--listen", "nowhere"], &["--listen"], &["frobnicate"], &["--public-url", "ws://h/?key=k"]]
{
assert!(parse(&args(bad)).is_err(), "{bad:?}");
}
// A config file, with flags over it; unknown keys are errors.
let dir = TempDir::new("cli-config");
let file = dir.path().join("server.toml");
std::fs::write(&file, "listen = \"0.0.0.0:47901\"\nmax_files = 7\ncompact_after = 50\n").unwrap();
let c = parse(&args(&["--config", file.to_str().unwrap(), "--listen", "127.0.0.1:1"])).unwrap();
assert_eq!((c.config.listen.as_str(), c.config.max_files, c.config.compact_after), ("127.0.0.1:1", 7, 50));
std::fs::write(&file, "max_files = 0\n").unwrap();
assert!(parse(&args(&["--config", file.to_str().unwrap()])).unwrap_err().contains("max_files"));
std::fs::write(&file, "colour = \"blue\"\n").unwrap();
assert!(parse(&args(&["--config", file.to_str().unwrap()])).is_err());
}
#[test]
fn managing_a_directory_on_its_own() {
let dir = TempDir::new("cli-offline");
let file = with_file(dir.path());
let list = run(dir.path(), Command::Files).unwrap();
assert!(list.contains(&file) && list.contains("Board"), "{list}");
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap();
assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}");
let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap();
assert_eq!(links.lines().count(), 4, "a header and three links: {links}");
let id = links.lines().find(|l| l.contains("Client")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
run(dir.path(), Command::Revoke { file: file.clone(), link: id.clone() }).unwrap();
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap_err().contains("no such link"));
// The key: printed as the address to publish to; replaced on request.
let key = run(dir.path(), Command::Key { rotate: false }).unwrap();
assert!(key.contains("ws://127.0.0.1:") && key.contains("?key="), "{key}");
let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap();
assert_ne!(key.lines().nth(1), rotated.lines().nth(2));
run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap();
assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet"));
assert!(run(dir.path(), Command::Links { file }).is_err());
}
#[test]
fn managing_through_the_running_server() {
let dir = TempDir::new("cli-online");
let file = with_file(dir.path());
let mut server = crate::start(&config(dir.path())).unwrap();
// The directory is the server's now: commands ask it.
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::Edit, label: "Team".into() }).unwrap();
assert!(made.contains(&format!("host={}", server.addr())), "links name the running server: {made}");
let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap();
assert!(links.contains("Team"), "{links}");
let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked"));
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server"));
assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr())));
server.stop();
// Stopped: the directory is free again.
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
}
#[test]
fn dates() {
assert_eq!(date(0), "1970-01-01 00:00 UTC");
assert_eq!(date(951_782_400), "2000-02-29 00:00 UTC");
assert_eq!(date(1_791_986_580), "2026-10-14 14:03 UTC");
}
+121
View File
@@ -0,0 +1,121 @@
//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use figcraft_collab_net::{Endpoint, NetLimits};
use serde::Deserialize;
use crate::store::StoreConfig;
/// The port the server listens on unless told otherwise (the app's sessions try it first too).
pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
/// Everything the server can be told (the TOML file's keys are these names).
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// Where to listen: `127.0.0.1:47900` (this machine only) unless told otherwise.
pub listen: String,
/// The data directory (files, links, the server key).
pub data: PathBuf,
/// How share links name this server when it isn't reached at `listen` (a reverse proxy:
/// `wss://figcraft.example.com/`).
pub public_url: Option<String>,
/// Most files kept.
pub max_files: usize,
/// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
/// most 64 MiB).
pub max_file_mb: u64,
/// Most share links per file.
pub max_links: usize,
/// Most open connections.
pub max_connections: usize,
/// Most people in one file at once.
pub max_people: usize,
/// Compact a file's log into a new snapshot after this many entries…
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
}
impl Default for Config {
fn default() -> Self {
Self {
listen: format!("127.0.0.1:{DEFAULT_PORT}"),
data: PathBuf::from("figcraft-data"),
public_url: None,
max_files: 1000,
max_file_mb: 64,
max_links: 100,
max_connections: 512,
max_people: 256,
compact_after: 1000,
compact_mb: 16,
}
}
}
impl Config {
/// Read a TOML file's settings (unknown keys are errors).
pub fn load(path: &Path) -> Result<Config, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("can't read {}: {e}", path.display()))?;
toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
}
/// The listen address.
pub fn addr(&self) -> Result<SocketAddr, String> {
self.listen.trim().parse().map_err(|_| format!("`listen` must be an address with a port, like 0.0.0.0:{DEFAULT_PORT}"))
}
/// The public URL, when set: a `ws://` or `wss://` address without a query.
pub fn public(&self) -> Result<Option<Endpoint>, String> {
let Some(url) = self.public_url.as_deref().map(str::trim).filter(|u| !u.is_empty()) else { return Ok(None) };
let (e, query) = Endpoint::parse(url).map_err(|e| format!("`public_url`: {e}"))?;
if !query.is_empty() {
return Err("`public_url` can't have a query (no keys or tokens in it)".into());
}
Ok(Some(e))
}
/// Check everything (before starting).
pub fn check(&self) -> Result<(), String> {
self.addr()?;
self.public()?;
for (name, v) in [
("max_files", self.max_files as u64),
("max_file_mb", self.max_file_mb),
("max_links", self.max_links as u64),
("max_connections", self.max_connections as u64),
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
] {
if v == 0 {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
Ok(())
}
/// The store's part.
pub fn store(&self) -> StoreConfig {
let mib = |n: u64| n.saturating_mul(1 << 20);
StoreConfig {
max_files: self.max_files,
max_file_bytes: mib(self.max_file_mb),
max_links: self.max_links,
compact_after: self.compact_after,
compact_bytes: mib(self.compact_mb),
core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() },
}
}
/// The transport's part.
pub fn net(&self) -> NetLimits {
NetLimits { max_connections: self.max_connections, ..NetLimits::default() }
}
}
+200
View File
@@ -0,0 +1,200 @@
//! The server's files on disk and reading them back without trusting them: atomic writes (a
//! temporary file beside the target, flushed, then renamed over it), the snapshot format and the
//! append-only log format.
//!
//! - `snapshot.bin`: `FCSN`, a format byte, the generation (u64 LE), a CRC-32 of the rest (u32 LE),
//! then the session state ([`SessionState`] in the wire encoding).
//! - `log.bin`: `FCLG`, a format byte, the generation of the snapshot it continues, then records:
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
//!
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use figcraft_collab::message;
use figcraft_collab::{Journal, SessionState};
pub const SNAPSHOT: &str = "snapshot.bin";
pub const LOG: &str = "log.bin";
pub const META: &str = "meta.json";
pub const LINKS: &str = "links.json";
const SNAP_MAGIC: &[u8; 4] = b"FCSN";
const LOG_MAGIC: &[u8; 4] = b"FCLG";
const FORMAT: u8 = 1;
const SNAP_HEADER: usize = 4 + 1 + 8 + 4;
pub const LOG_HEADER: usize = 4 + 1 + 8;
const RECORD_HEADER: usize = 8;
/// `path` with `.tmp` added: where its next version is written first.
pub fn temp_of(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let tmp = temp_of(path);
{
let mut f = File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
sync_dir(path.parent());
Ok(())
}
/// Make a rename in `dir` durable (directories can't be opened for that on Windows).
pub fn sync_dir(dir: Option<&Path>) {
#[cfg(unix)]
if let Some(d) = dir
&& let Ok(f) = File::open(d)
{
let _ = f.sync_all();
}
#[cfg(not(unix))]
let _ = dir;
}
/// Read a file of at most `max` bytes (a longer one is refused without being read).
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?;
let len = f.metadata().map_err(|e| e.to_string())?.len();
if len > max {
return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len));
}
let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
f.take(max).read_to_end(&mut bytes).map_err(|e| format!("can't read {}: {e}", name(path)))?;
Ok(bytes)
}
fn name(path: &Path) -> String {
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}
fn crc(bytes: &[u8]) -> u32 {
crc32fast::hash(bytes)
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes)
}
fn u64_at(b: &[u8], at: usize) -> Option<u64> {
b.get(at..at + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes)
}
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
let body = message::encode(state).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
Ok(out)
}
/// A snapshot's generation and body, when its header and checksum are right.
fn snapshot_body(bytes: &[u8]) -> Result<(u64, &[u8]), String> {
if bytes.get(..4) != Some(SNAP_MAGIC.as_slice()) {
return Err("the snapshot isn't one (wrong header)".into());
}
if bytes.get(4) != Some(&FORMAT) {
return Err("the snapshot is in a format this version doesn't read".into());
}
let (Some(generation), Some(sum), Some(body)) = (u64_at(bytes, 5), u32_at(bytes, 13), bytes.get(SNAP_HEADER..)) else {
return Err("the snapshot is cut short".into());
};
if crc(body) != sum {
return Err("the snapshot is damaged (checksum mismatch)".into());
}
Ok((generation, body))
}
/// A snapshot's generation, after checking its header and checksum (no decoding).
pub fn check_snapshot(bytes: &[u8]) -> Result<u64, String> {
snapshot_body(bytes).map(|(g, _)| g)
}
/// A snapshot's generation and session state.
pub fn decode_snapshot(bytes: &[u8]) -> Result<(u64, SessionState), String> {
let (generation, body) = snapshot_body(bytes)?;
let state = message::decode::<SessionState>(body, body.len()).map_err(|e| format!("the snapshot can't be read: {e}"))?;
Ok((generation, state))
}
/// An empty log continuing snapshot `generation`.
pub fn log_header(generation: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(LOG_HEADER);
out.extend_from_slice(LOG_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out
}
/// One log record.
pub fn record(entry: &Journal) -> Result<Vec<u8>, String> {
let body = message::encode(entry).map_err(|e| e.to_string())?;
let len = u32::try_from(body.len()).map_err(|_| "a journal entry over 4 GiB".to_string())?;
let mut out = Vec::with_capacity(RECORD_HEADER + body.len());
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
Ok(out)
}
/// What a log holds.
#[derive(Debug)]
pub struct LogRead {
pub generation: u64,
pub entries: Vec<Journal>,
/// Why it ends before the end of the file (a damaged or cut-short record), if it does.
pub damaged: Option<String>,
}
/// Read a log: its generation and the entries up to the first damaged record. Only a header that
/// isn't a log's is an error.
pub fn read_log(bytes: &[u8]) -> Result<LogRead, String> {
if bytes.get(..4) != Some(LOG_MAGIC.as_slice()) {
return Err("the log isn't one (wrong header)".into());
}
if bytes.get(4) != Some(&FORMAT) {
return Err("the log is in a format this version doesn't read".into());
}
let generation = u64_at(bytes, 5).ok_or("the log is cut short")?;
let mut at = LOG_HEADER;
let mut entries = Vec::new();
let mut damaged = None;
while at < bytes.len() {
let (Some(len), Some(sum)) = (u32_at(bytes, at), u32_at(bytes, at + 4)) else {
damaged = Some(format!("a record cut short at byte {at}"));
break;
};
let start = at + RECORD_HEADER;
let Some(body) = usize::try_from(len).ok().and_then(|n| bytes.get(start..start.checked_add(n)?)) else {
damaged = Some(format!("a record cut short at byte {at}"));
break;
};
if crc(body) != sum {
damaged = Some(format!("a damaged record at byte {at}"));
break;
}
match message::decode::<Journal>(body, body.len()) {
Ok(e) => entries.push(e),
Err(e) => {
damaged = Some(format!("an unreadable record at byte {at} ({e})"));
break;
}
}
at = start + body.len();
}
Ok(LogRead { generation, entries, damaged })
}
+80
View File
@@ -0,0 +1,80 @@
//! `figcraft-server`: FigCraft's standalone collaboration server (milestone M9, step 5 of
//! `docs/collab-protocol.md`; running it: `docs/server.md`).
//!
//! It keeps many files in a data directory ([`store`]), each with share links that let people
//! edit or only view, and serves their live sessions through the same hub the desktop app hosts
//! with ([`figcraft_collab_net::hub`]). Files persist as a snapshot plus an append-only log of what
//! changed ([`disk`]), compacted into a new snapshot from time to time, so a restart loses nothing
//! that was confirmed and clients resume where they were. There are no user accounts yet: share
//! links are the access, and the server key manages files.
pub mod cli;
pub mod config;
pub mod disk;
pub mod store;
use std::net::{SocketAddr, TcpListener};
use figcraft_collab_net::hub::Hosting;
use crate::config::Config;
use crate::store::Store;
/// A running server. Dropping it stops it: open files are saved, everyone is told the server is
/// restarting (their apps reconnect once it is back).
pub struct Running {
hosting: Hosting,
}
impl Running {
/// Where it listens.
pub fn addr(&self) -> SocketAddr {
self.hosting.addr()
}
/// Open connections.
pub fn connections(&self) -> usize {
self.hosting.connections()
}
/// Stop (see the type's docs).
pub fn stop(&mut self) {
self.hosting.stop();
}
}
/// Start serving `cfg.data` on `cfg.listen`. Files that can't be read are skipped and logged.
pub fn start(cfg: &Config) -> Result<Running, String> {
cfg.check()?;
let store = Store::open(&cfg.data, cfg.store())?;
for p in store.problems() {
log::warn!("{p}");
}
let files = store.len();
let addr = cfg.addr()?;
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
let addr = listener.local_addr().map_err(|e| e.to_string())?;
cli::write_running(&cfg.data, addr)?;
let public = cli::public_endpoint(cfg, addr)?;
let hosting = Hosting::serve(listener, store, cfg.net()).map_err(|e| format!("can't start serving: {e}"))?;
log::info!(
"figcraft-server {}: {files} file{} in {}; listening on {addr}; links name {}",
env!("CARGO_PKG_VERSION"),
if files == 1 { "" } else { "s" },
cfg.data.display(),
public.url()
);
if addr.ip().is_loopback() {
log::info!("only this machine can connect: listen on 0.0.0.0:{} (or behind a reverse proxy) to share", addr.port());
}
Ok(Running { hosting })
}
#[cfg(test)]
mod cli_tests;
#[cfg(test)]
mod server_tests;
#[cfg(test)]
mod store_tests;
#[cfg(test)]
mod test_util;
+40
View File
@@ -0,0 +1,40 @@
//! `figcraft-server`: see `docs/server.md`, or run it with `--help`.
use std::process::ExitCode;
use figcraft_server::cli::{self, Command};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let cli = match cli::parse(&args) {
Ok(c) => c,
Err(e) => {
eprintln!("figcraft-server: {e}\n(figcraft-server --help lists what it takes)");
return ExitCode::from(2);
}
};
if cli.command != Command::Serve {
return match cli::run(&cli) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("figcraft-server: {e}");
ExitCode::FAILURE
}
};
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).target(env_logger::Target::Stdout).init();
match figcraft_server::start(&cli.config) {
// Serves until the process is stopped: what was confirmed is on disk already, and clients
// reconnect once it runs again.
Ok(_running) => loop {
std::thread::park();
},
Err(e) => {
log::error!("{e}");
ExitCode::FAILURE
}
}
}
+280
View File
@@ -0,0 +1,280 @@
//! The server end to end over loopback, in-process with a temporary data directory: an app
//! publishes its file and syncs through the server, others join with the edit link and the view
//! link, edits converge and the viewer's are refused (by its app, and by the server for a client
//! that skips the check), the server restarts mid-session and everyone resumes without losing a
//! confirmed edit, revoked links stop working at once, and fuzzed frames don't hurt it.
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::time::Duration;
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, ServerMsg};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Client, VIEW_ONLY};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::{Published, Status};
use serde_json::{Value, json};
use tungstenite::{Bytes, Message, WebSocket};
use crate::test_util::{TempDir, config, pump, same, session};
fn key(dir: &Path) -> String {
std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string()
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn live(s: &Session) -> bool {
s.live.status == Status::Live && s.live.syncing()
}
/// `a` publishes its open file to the server at `addr`; the links.
fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
a.live.published.clone().unwrap()
}
fn join(link: &str, name: &str) -> Session {
let mut s = session(name);
s.execute("collab.join", &json!({"link": link})).unwrap();
s
}
/// A bare client: hello, then what it's told.
struct Raw(WebSocket<TcpStream>);
impl Raw {
fn connect(addr: SocketAddr) -> Raw {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0)
}
fn send(&mut self, m: &ClientMsg) {
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
}
fn next(&mut self) -> ServerMsg {
loop {
if let Message::Binary(b) = self.0.read().unwrap() {
return message::decode(&b, usize::MAX).unwrap();
}
}
}
}
#[test]
fn publish_then_edit_and_view_together() {
let dir = TempDir::new("share");
let mut server = crate::start(&config(dir.path())).unwrap();
let mut a = session("Ada");
a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 80})).unwrap();
let addr = server.addr();
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir.path()))})).unwrap();
assert_eq!(r["status"], "connecting");
// Nothing changes it until it is on the server.
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
assert!(e.contains("being published"), "{e}");
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
assert_eq!(a.live_summary()["readOnly"], false);
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
assert_ne!(p.edit, p.view);
// Bo joins with the edit link, Cy with the view link.
let mut b = join(&p.edit, "Bo");
let mut c = join(&p.view, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(c.live_summary()["access"], "view");
assert_eq!(c.live_summary()["readOnly"], true);
assert!(same(a.doc().unwrap(), b.doc().unwrap()) && same(a.doc().unwrap(), c.doc().unwrap()));
// Ada and Bo edit; everyone converges, Cy included.
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap());
pump(&mut [&mut a, &mut b, &mut c], "Ada's rectangle at Bo", |s| s[1].doc().unwrap().contains(rect));
b.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Card"}})).unwrap();
pump(&mut [&mut a, &mut b, &mut c], "everyone agreeing", |s| {
let (ad, bd, cd) = (s[0].doc().unwrap(), s[1].doc().unwrap(), s[2].doc().unwrap());
same(ad, bd) && same(ad, cd) && cd.node(rect).is_some_and(|n| n.name == "Card")
});
// Cy can't change anything: commands and gestures are refused before anything is sent.
let before = c.doc().unwrap().clone();
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
assert!(e.contains("View only"), "{e}");
assert!(c.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Mine"}})).is_err());
assert!(c.begin_interaction("Move").is_err());
assert!(same(&before, c.doc().unwrap()));
// Cy is still seen: presence goes both ways.
let cy = c.live.info.as_ref().unwrap().me;
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
// A client that skips the app's check is refused by the server.
let view = Invite::parse(&p.view).unwrap();
let mut raw = Raw::connect(addr);
raw.send(&Client::hello(&view.file, &view.token, "Raw", ""));
let welcome = loop {
if let w @ ServerMsg::Welcome { .. } = raw.next() {
break w;
}
};
let mut client = Client::join(welcome).unwrap();
assert_eq!(client.access(), Access::View);
let mut d = client.document().clone();
d.node_mut(rect).unwrap().name = "Sneaky".into();
raw.send(&client.edit(&d).unwrap().unwrap());
loop {
if let ServerMsg::Reject { reason, .. } = raw.next() {
assert_eq!(reason, VIEW_ONLY);
break;
}
}
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
// Leaving keeps a local copy.
c.execute("collab.leave", &json!({})).unwrap();
assert_eq!(c.live.status, Status::Off);
assert!(c.doc().unwrap().contains(rect));
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
server.stop();
}
#[test]
fn a_restart_mid_session_loses_no_confirmed_edit() {
let dir = TempDir::new("restart");
let mut cfg = config(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
// The server stops: both reconnect, and what Ada makes meanwhile waits.
server.stop();
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
cfg.listen = addr.to_string();
let mut server = start_again(&cfg);
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
});
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
// Someone new gets it all from the restarted server.
let mut c = join(&p.view, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]));
assert!(same(a.doc().unwrap(), c.doc().unwrap()));
server.stop();
}
/// Start a server on `cfg.listen` again (the port may take a moment to come free).
fn start_again(cfg: &crate::config::Config) -> crate::Running {
let start = std::time::Instant::now();
loop {
match crate::start(cfg) {
Ok(s) => return s,
Err(e) if start.elapsed() < Duration::from_secs(10) && e.contains("can't listen") => std::thread::sleep(Duration::from_millis(50)),
Err(e) => panic!("{e}"),
}
}
}
#[test]
fn revoked_links_stop_working_at_once() {
let dir = TempDir::new("revoke");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let file = Invite::parse(&p.edit).unwrap().file;
let endpoint = Endpoint::plain(addr.to_string());
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
let AdminReply::Link(review) = ask(Admin::CreateLink { file: file.clone(), access: Access::View, label: "Review".into() }) else { panic!() };
let link = Invite { server: endpoint.clone(), file: file.clone(), token: review.token.clone() }.link();
let mut b = join(&p.edit, "Bo");
let mut c = join(&link, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)));
let AdminReply::Files(files) = ask(Admin::Files) else { panic!() };
assert_eq!((files.len(), files[0].people), (1, 3));
// Revoked: Cy is out at once, the others stay.
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
});
assert!(live(&b));
// The link no longer joins.
c.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
// Managing needs the key.
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("wrong server key"), "{e}");
// Removing the file ends it for everyone in it.
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b], "everyone out", |s| {
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
});
server.stop();
}
#[test]
fn fuzzed_frames_dont_hurt_the_server() {
let dir = TempDir::new("fuzz");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let edit = Invite::parse(&p.edit).unwrap();
let valid: Vec<Vec<u8>> = [
Client::hello(&edit.file, &edit.token, "Fuzz", ""),
ClientMsg::Manage(message::Manage { protocol: message::PROTOCOL, key: key(dir.path()), client_version: String::new() }),
ClientMsg::Admin(Admin::Links { file: "0000000000000000".into() }),
ClientMsg::Ping,
]
.iter()
.map(|m| message::encode(m).unwrap())
.collect();
let mut rng = Rng::new(11);
for round in 0..80 {
let Ok(s) = TcpStream::connect(addr) else { continue };
let _ = s.set_read_timeout(Some(Duration::from_millis(200)));
let Ok((mut ws, _)) = tungstenite::client::client(format!("ws://{addr}/"), s) else { continue };
for _ in 0..1 + rng.below(3) {
let mut bytes = rng.pick(&valid).cloned().unwrap_or_default();
if round % 5 == 0 {
bytes = vec![0x5A; 100_000];
}
for _ in 0..1 + rng.below(4) {
if bytes.is_empty() {
break;
}
let i = rng.below(bytes.len());
match rng.below(3) {
0 => bytes[i] = rng.next_u64() as u8,
1 => bytes.insert(i, rng.next_u64() as u8),
_ => bytes.truncate(i.max(1)),
}
}
let frame =
if round % 7 == 0 { Message::Text(String::from_utf8_lossy(&bytes).into_owned().into()) } else { Message::Binary(Bytes::from(bytes)) };
if ws.send(frame).is_err() {
break;
}
}
let _ = ws.read();
}
// Still serving, the file intact.
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in after the noise", |s| live(s[1]) && live(s[0]));
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
server.stop();
}
+615
View File
@@ -0,0 +1,615 @@
//! The data directory: the files a server keeps, their share links and the server key, and the
//! [`Backend`] the hub serves them through.
//!
//! ```text
//! <data>/LOCK held while a server (or a command changing the directory) uses it
//! <data>/server.json where the running server listens (for the command line)
//! <data>/server.key the server key: managing files (publish, links, removal)
//! <data>/files/<id>/meta.json name, created, updated, sequence number
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
//! <data>/files/<id>/log.bin what changed since (see `disk`)
//! ```
//!
//! An open file appends its server's journal to its log; the hub has the store make the appends
//! durable before anyone hears of them. After [`StoreConfig::compact_after`] entries or
//! [`StoreConfig::compact_bytes`] of log, when everyone has left, and when the server stops, the
//! state is written as the next snapshot and the log starts over. Link tokens are stored only as
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
//! never deleted.
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
use figcraft_collab::{Change, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile};
use figcraft_collab_net::token;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::disk::{self, LINKS, LOG, LOG_HEADER, META, SNAPSHOT};
pub const LOCK: &str = "LOCK";
pub const KEY: &str = "server.key";
pub const FILES: &str = "files";
/// What the store enforces.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StoreConfig {
pub max_files: usize,
pub max_file_bytes: u64,
pub max_links: usize,
pub compact_after: usize,
pub compact_bytes: u64,
pub core: figcraft_collab::Limits,
}
/// A file's description (`meta.json`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Meta {
name: String,
created: u64,
updated: u64,
#[serde(default)]
seq: u64,
}
/// A share link (`links.json`): the token itself is never stored.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Link {
id: String,
access: Access,
label: String,
created: u64,
/// SHA-256 of the token, hex.
hash: String,
}
impl Link {
fn info(&self, file: &str) -> LinkInfo {
LinkInfo { id: self.id.clone(), file: file.to_string(), access: self.access, label: self.label.clone(), created: self.created }
}
}
/// An open file's log.
struct Log {
generation: u64,
file: File,
/// Records not written yet (written and synced at the next flush).
pending: Vec<u8>,
/// Entries and bytes since the last compaction.
entries: usize,
bytes: u64,
/// Changed since the last compaction.
dirty: bool,
}
struct Entry {
meta: Meta,
links: Vec<Link>,
/// Open: its log.
log: Option<Log>,
}
/// The data directory in use (it holds the directory's lock until dropped).
pub struct Store {
root: PathBuf,
cfg: StoreConfig,
key: String,
files: BTreeMap<String, Entry>,
/// What was skipped at startup or found damaged since, for the operator.
problems: Vec<String>,
_lock: File,
}
/// Seconds since 1970.
pub fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// SHA-256 of a token, hex.
pub fn hash(token: &str) -> String {
Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
/// A file or link id as this store makes them (and nothing a path could misuse).
fn valid_id(s: &str) -> bool {
(1..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// A name others may see: trimmed, no control characters, at most 256 characters.
fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
}
/// Take the data directory's lock: `None` when someone else holds it.
pub fn lock(root: &Path) -> Result<Option<File>, String> {
fs::create_dir_all(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
let f = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(root.join(LOCK))
.map_err(|e| format!("can't open {}: {e}", root.join(LOCK).display()))?;
match f.try_lock() {
Ok(()) => Ok(Some(f)),
Err(TryLockError::WouldBlock) => Ok(None),
Err(TryLockError::Error(e)) => Err(format!("can't lock the data directory: {e}")),
}
}
/// Read the server key, or make one.
fn load_key(root: &Path) -> Result<String, String> {
let path = root.join(KEY);
match fs::read_to_string(&path) {
Ok(k) if valid_id(k.trim()) && k.trim().len() >= 16 => Ok(k.trim().to_string()),
Ok(_) => Err(format!("{} isn't a server key (delete it to make a new one)", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => write_key(root),
Err(e) => Err(format!("can't read {}: {e}", path.display())),
}
}
/// Make a new server key (only the operator's account can read it on Unix).
fn write_key(root: &Path) -> Result<String, String> {
let key = token::secret()?;
let path = root.join(KEY);
disk::write_atomic(&path, format!("{key}\n").as_bytes()).map_err(|e| format!("can't write {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(key)
}
impl Store {
/// Use the data directory `root` (made if missing). Fails when another server or command holds
/// it. Files that can't be read are skipped and listed in [`problems`](Self::problems).
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))
}
/// [`open`](Self::open), or `None` when someone else holds the directory.
pub fn try_open(root: &Path, cfg: StoreConfig) -> Result<Option<Store>, String> {
let Some(lock) = lock(root)? else { return Ok(None) };
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), problems: Vec::new(), _lock: lock };
store.scan(&dir);
Ok(Some(store))
}
fn scan(&mut self, dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
};
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
ids.sort();
for (id, path) in ids {
if !path.is_dir() {
continue;
}
if !valid_id(&id) {
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
continue;
}
if self.files.len() >= self.cfg.max_files {
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
continue;
}
match self.check(&path) {
Ok(entry) => {
self.files.insert(id, entry);
}
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
}
}
}
/// A file folder's description and links, after checking its snapshot and log. Leftover
/// temporary files (writes cut short) are removed.
fn check(&self, path: &Path) -> Result<Entry, String> {
for name in [SNAPSHOT, LOG, META, LINKS] {
let tmp = disk::temp_of(&path.join(name));
if tmp.exists() {
let _ = fs::remove_file(&tmp);
}
}
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec<Link> =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
// A damaged log only loses its end; it is reported when the file opens.
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
&& let Ok(log) = disk::read_log(&bytes)
&& log.generation != generation
{
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
}
Ok(Entry { meta, links, log: None })
}
/// The server key (for managing files).
pub fn key(&self) -> &str {
&self.key
}
/// Replace the server key; the new one.
pub fn rotate_key(&mut self) -> Result<String, String> {
self.key = write_key(&self.root)?;
Ok(self.key.clone())
}
/// What was skipped or found damaged.
pub fn problems(&self) -> &[String] {
&self.problems
}
/// How many files are kept.
pub fn len(&self) -> usize {
self.files.len()
}
/// No files are kept.
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
fn dir(&self, id: &str) -> PathBuf {
self.root.join(FILES).join(id)
}
fn entry(&mut self, id: &str) -> Result<&mut Entry, String> {
self.files.get_mut(id).ok_or_else(|| "this file isn't on the server (it may have been removed)".to_string())
}
fn info(&self, id: &str, e: &Entry, open: Option<OpenFile>) -> FileInfo {
let dir = self.dir(id);
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(dir.join(n)).ok()).map(|m| m.len()).sum();
FileInfo {
id: id.to_string(),
name: e.meta.name.clone(),
created: e.meta.created,
updated: e.meta.updated,
seq: open.map_or(e.meta.seq, |o| o.seq),
bytes,
people: open.map_or(0, |o| o.people),
}
}
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
if self.files.len() >= self.cfg.max_files {
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
}
let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
if replica.document().node_count() > self.cfg.core.max_nodes {
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
}
let mut doc = replica.document().clone();
let name = match clean_name(name) {
n if n.is_empty() => doc.name.clone(),
n => n,
};
doc.name = name.clone();
let server = Server::new(doc, "", self.cfg.core);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(1, &state)?;
if bytes.len() as u64 > self.cfg.max_file_bytes {
return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
}
let id = loop {
let id = token::file_id()?;
if !self.files.contains_key(&id) && !self.dir(&id).exists() {
break id;
}
};
let t = now();
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
let meta = Meta { name, created: t, updated: t, seq: 0 };
let dir = self.dir(&id);
let written = fs::create_dir_all(&dir)
.map_err(|e| e.to_string())
.and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()))
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
// Last: a folder without its description is an upload cut short.
.and_then(|()| write_json(&dir.join(META), &meta));
if let Err(e) = written {
let _ = fs::remove_dir_all(&dir);
return Err(format!("couldn't keep the file: {e}"));
}
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None };
let info = self.info(&id, &entry, None);
self.files.insert(id.clone(), entry);
log::info!("published file {id} ({} bytes)", bytes.len());
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }))
}
/// The files kept.
pub fn list(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileInfo> {
self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
}
/// A file's links.
pub fn links(&mut self, file: &str) -> Result<Vec<LinkInfo>, String> {
Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
}
/// A new link to a file.
pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result<NewLink, String> {
let max = self.cfg.max_links;
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
if entry.links.len() >= max {
return Err(format!("a file has at most {max} links: revoke one first"));
}
let (link, token) = new_link(access, &clean_name(label), now())?;
entry.links.push(link.clone());
if let Err(e) = write_json(&path, &entry.links) {
entry.links.pop();
return Err(format!("couldn't keep the link: {e}"));
}
Ok(NewLink { link: link.info(file), token })
}
/// Revoke a link.
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
let gone = entry.links.remove(at);
if let Err(e) = write_json(&path, &entry.links) {
entry.links.insert(at, gone);
return Err(format!("couldn't revoke the link: {e}"));
}
log::info!("revoked link {link} of file {file}");
Ok(())
}
/// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
/// longer served either way).
pub fn remove(&mut self, file: &str) -> Result<(), String> {
// Closed first: an open log would keep the folder from being deleted on Windows.
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
drop(entry);
let dir = self.dir(file);
fs::remove_dir_all(&dir)
.map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
log::info!("removed file {file}");
Ok(())
}
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
fn load(&mut self, file: &str) -> Result<Server, String> {
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
let mut issues = Vec::new();
let mut fresh_log = true;
let mut kept = 0;
match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) {
Ok(log) if log.generation == generation => {
fresh_log = false;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
fresh_log = true;
break;
}
kept += 1;
}
if let Some(why) = log.damaged {
issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
fresh_log = true;
}
}
// From before a compaction that was cut short: the snapshot has it all.
Ok(_) => {}
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
}
for i in issues {
log::warn!("{i}");
self.problems.push(i);
}
server.journaling(true);
let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
let log = match (fresh_log, file_handle) {
(false, Ok(f)) => {
let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
// Replayed entries make it worth compacting once everyone has left.
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
_ => self.rewrite(file, generation + 1, &server)?,
};
self.entry(file)?.log = Some(log);
Ok(server)
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
let dir = self.dir(file);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(generation, &state)?;
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
if let Ok(e) = self.entry(file) {
e.meta.seq = server.seq();
let meta = e.meta.clone();
if let Err(err) = write_json(&dir.join(META), &meta) {
log::warn!("file {file}: couldn't update {META}: {err}");
}
}
Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
}
/// Compact an open file: its state as the next snapshot, an empty log.
fn compact(&mut self, file: &str, server: &Server) {
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
match self.rewrite(file, generation + 1, server) {
Ok(log) => {
if let Ok(e) = self.entry(file) {
e.log = Some(log);
}
log::debug!("compacted file {file}");
}
// The old snapshot and log still hold everything: carry on appending.
Err(e) => log::error!("file {file}: compaction failed: {e}"),
}
}
/// Write and sync what's pending in every open log.
fn flush_all(&mut self) {
for (id, e) in &mut self.files {
if let Some(log) = e.log.as_mut() {
flush_log(id, log);
}
}
}
}
/// Write and sync a log's pending records (kept for the next try when that fails).
fn flush_log(id: &str, log: &mut Log) {
if log.pending.is_empty() {
return;
}
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
Ok(()) => {
log.bytes += log.pending.len() as u64;
log.pending.clear();
}
Err(e) => log::error!("file {id}: couldn't write its log: {e}"),
}
}
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
disk::write_atomic(path, &text).map_err(|e| e.to_string())
}
/// A new link and its token.
fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> {
let token = token::secret()?;
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token))
}
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
let entry = self.entry(file)?;
let presented = hash(token);
// Every link is compared (in constant time), so timing doesn't tell which one matched.
let mut found = None;
for l in &entry.links {
if same_secret(&presented, &l.hash) {
found = Some(Admission { access: l.access, link: Some(l.id.clone()) });
}
}
found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
}
fn open(&mut self, file: &str) -> Result<Server, String> {
self.load(file).map_err(|e| {
let why = format!("file {file} can't be opened: {e}");
log::error!("{why}");
self.problems.push(why);
"this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
})
}
fn changed(&mut self, file: &str, server: &mut Server) {
let journal = server.take_journal();
if journal.is_empty() {
return;
}
let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
let Some(e) = self.files.get_mut(file) else { return };
let Some(log) = e.log.as_mut() else { return };
for j in &journal {
match disk::record(j) {
Ok(r) => log.pending.extend(r),
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
}
}
log.entries += journal.len();
log.dirty = true;
e.meta.updated = now();
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
// The snapshot holds what's pending too.
self.compact(file, server);
}
}
fn flush(&mut self) {
self.flush_all();
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
flush_log(file, log);
log.dirty
});
if dirty {
self.compact(file, server);
}
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
true
}
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
self.flush_all();
for (file, server) in open {
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
self.compact(file, server);
}
}
}
fn manage(&mut self, key: &str) -> Result<(), String> {
if same_secret(key, &self.key) { Ok(()) } else { Err("wrong server key".into()) }
}
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
match req {
Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
Err(e) => failed(e),
},
Admin::Files => AdminReply::Files(self.list(open)).into(),
Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
Admin::Revoke { file, link } => match self.revoke(&file, &link) {
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None },
Err(e) => failed(e),
},
Admin::Remove { file } => {
if !self.files.contains_key(&file) {
return failed("this file isn't on the server (it may have been removed)".into());
}
let reply = match self.remove(&file) {
Ok(()) => AdminReply::Done,
Err(reason) => AdminReply::Failed { reason },
};
Handled { reply, revoked: None, removed: Some(file) }
}
}
}
fn closing(&self) -> Closing {
Closing::Restarting
}
}
+271
View File
@@ -0,0 +1,271 @@
//! The data directory on its own: publishing, links and their hashes, persistence across a crash
//! (snapshot plus log) and compaction, damaged files skipped and reported, caps, the lock, and
//! temporary directories cleaned up.
use std::fs;
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Client, Outgoing, Replica, Server};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::{Document, NodeKind};
use crate::disk::{self, LINKS, LOG, META, SNAPSHOT};
use crate::store::{FILES, Store, StoreConfig, hash};
use crate::test_util::{TempDir, config};
fn cfg(dir: &std::path::Path) -> StoreConfig {
config(dir).store()
}
fn doc(name: &str) -> Document {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
d
}
fn snapshot(d: Document) -> Vec<figcraft_collab::Change> {
Replica::new(d).snapshot().unwrap()
}
/// Publish `name`; its id and the edit and view tokens.
fn publish(store: &mut Store, name: &str) -> (String, String, String) {
let (info, edit, view) = store.publish(name, &snapshot(doc(name))).unwrap();
assert_eq!(edit.link.access, Access::Edit);
assert_eq!(view.link.access, Access::View);
(info.id, edit.token, view.token)
}
/// A client in `server` (as the hub would let it in) and what it was told.
fn join(server: &mut Server, access: Access) -> Client {
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
let (_, out) = server.join_as(h, access).unwrap();
let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() };
Client::join(w).unwrap()
}
/// Rename the first page through `c`; the server takes the batch.
fn rename(server: &mut Server, c: &mut Client, name: &str) {
let mut d = c.document().clone();
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().name = name.into();
let m = c.edit(&d).unwrap().unwrap();
for o in server.receive(c.id(), m) {
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
c.receive(m, &figcraft_collab::NoRecompute).unwrap();
}
}
}
#[test]
fn files_links_and_access() {
let dir = TempDir::new("links");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert!(store.is_empty());
let (id, edit, view) = publish(&mut store, "Café ☕");
let files = store.list(&|_| None);
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "Café ☕");
assert!(files[0].bytes > 0);
// Each link lets in with its access; anything else doesn't.
assert_eq!(store.admit(&id, &edit).unwrap().access, Access::Edit);
assert_eq!(store.admit(&id, &view).unwrap().access, Access::View);
assert!(store.admit(&id, "WrongWrongWrongWrong00").unwrap_err().contains("isn't valid"));
assert!(store.admit("0000000000000000", &edit).unwrap_err().contains("isn't on the server"));
// Tokens are kept only as hashes.
let on_disk = fs::read_to_string(dir.path().join(FILES).join(&id).join(LINKS)).unwrap();
assert!(!on_disk.contains(&edit) && !on_disk.contains(&view));
assert!(on_disk.contains(&hash(&edit)) && on_disk.contains(&hash(&view)));
// More links; revoking one stops it, the others keep working.
let review = store.create_link(&id, Access::View, " Review\n").unwrap();
assert_eq!(review.link.label, "Review");
assert_eq!(store.links(&id).unwrap().len(), 3);
assert_eq!(store.admit(&id, &review.token).unwrap().link.as_deref(), Some(review.link.id.as_str()));
store.revoke(&id, &review.link.id).unwrap();
assert!(store.admit(&id, &review.token).is_err());
assert!(store.admit(&id, &view).is_ok());
assert!(store.revoke(&id, &review.link.id).is_err(), "already gone");
// Removed for good.
store.remove(&id).unwrap();
assert!(store.is_empty());
assert!(!dir.path().join(FILES).join(&id).exists());
assert!(store.admit(&id, &edit).is_err());
}
#[test]
fn a_file_survives_a_crash_and_compacts() {
let dir = TempDir::new("crash");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, before, seq) = {
let mut store = Store::open(dir.path(), c).unwrap();
let (id, _, _) = publish(&mut store, "Crash");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
for i in 0..5 {
rename(&mut server, &mut a, &format!("Page {i}"));
store.changed(&id, &mut server);
}
store.flush();
// A crash: no compaction, nothing else written.
(id, server.replica().clone(), server.seq())
};
let mut store = Store::open(dir.path(), c).unwrap();
assert!(store.problems().is_empty(), "{:?}", store.problems());
let mut server = store.open(&id).unwrap();
assert_eq!(server.replica(), &before, "the log brought it back");
assert_eq!(server.seq(), seq);
// Compaction: everyone left; the snapshot has it all and the log starts over.
assert!(store.idle(&id, &mut server));
let snap = disk::decode_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(snap.1.seq, seq);
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
assert_eq!(log.generation, snap.0);
assert!(log.entries.is_empty());
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
let generation = || disk::check_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(generation(), 1);
for i in 0..3 {
rename(&mut server, &mut a, &format!("P{i}"));
store.changed(&id, &mut server);
store.flush();
}
assert_eq!(generation(), 2, "compacted after three entries");
rename(&mut server, &mut a, "After");
store.changed(&id, &mut server);
store.flush();
drop(store);
let mut store = Store::open(dir.path(), c).unwrap();
let restored = store.open(&id).unwrap();
assert_eq!(restored.replica(), server.replica());
}
#[test]
fn damaged_files_are_skipped_and_reported() {
let dir = TempDir::new("damaged");
let (bad_snapshot, bad_meta, cut_log, good) = {
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let ids: Vec<String> = ["A", "B", "C", "D"].iter().map(|n| publish(&mut store, n).0).collect();
// C gets a log with entries.
let mut server = store.open(&ids[2]).unwrap();
let mut c = join(&mut server, Access::Edit);
store.changed(&ids[2], &mut server);
rename(&mut server, &mut c, "Kept");
store.changed(&ids[2], &mut server);
rename(&mut server, &mut c, "Lost");
store.changed(&ids[2], &mut server);
store.flush();
(ids[0].clone(), ids[1].clone(), ids[2].clone(), ids[3].clone())
};
let folder = |id: &str| dir.path().join(FILES).join(id);
// A: a flipped byte in its snapshot; B: garbage description; C: its log cut mid-record;
// D: a write that was cut short left a temporary file.
let mut snap = fs::read(folder(&bad_snapshot).join(SNAPSHOT)).unwrap();
let last = snap.len() - 1;
snap[last] ^= 0xFF;
fs::write(folder(&bad_snapshot).join(SNAPSHOT), snap).unwrap();
fs::write(folder(&bad_meta).join(META), b"{not json").unwrap();
let log = fs::read(folder(&cut_log).join(LOG)).unwrap();
fs::write(folder(&cut_log).join(LOG), &log[..log.len() - 3]).unwrap();
fs::write(disk::temp_of(&folder(&good).join(SNAPSHOT)), b"half").unwrap();
fs::create_dir_all(dir.path().join(FILES).join("not a file id")).unwrap();
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let problems = store.problems().join("\n");
assert!(problems.contains(&bad_snapshot) && problems.contains("checksum"), "{problems}");
assert!(problems.contains(&bad_meta) && problems.contains(META), "{problems}");
assert!(problems.contains("not a file id"), "{problems}");
assert_eq!(store.len(), 2, "C and D are served");
// Nothing is deleted.
assert!(folder(&bad_snapshot).exists() && folder(&bad_meta).exists());
assert!(!disk::temp_of(&folder(&good).join(SNAPSHOT)).exists(), "the leftover is gone");
// C opens without the cut-short record, and says so.
let server = store.open(&cut_log).unwrap();
let page = server.document().first_page().unwrap();
assert_eq!(server.document().node(page).unwrap().name, "Kept");
assert!(store.problems().iter().any(|p| p.contains(&cut_log) && p.contains("cut short")));
// Damage after startup: the file can't be opened, with a reason for the person joining.
fs::write(folder(&good).join(SNAPSHOT), b"FCSN garbage").unwrap();
assert!(store.open(&good).unwrap_err().contains("can't be opened"));
assert!(store.problems().iter().any(|p| p.contains(&good)));
}
#[test]
fn caps_hold() {
let dir = TempDir::new("caps");
let c = StoreConfig { max_files: 1, max_links: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c).unwrap();
let (id, _, _) = publish(&mut store, "One");
assert!(store.publish("Two", &snapshot(doc("Two"))).unwrap_err().contains("at most 1"));
store.create_link(&id, Access::View, "").unwrap();
assert!(store.create_link(&id, Access::View, "").unwrap_err().contains("at most 3"));
let dir2 = TempDir::new("caps-size");
let mut tiny = Store::open(dir2.path(), StoreConfig { max_file_bytes: 64, ..cfg(dir2.path()) }).unwrap();
assert!(tiny.publish("Big", &snapshot(doc("Big"))).unwrap_err().contains("MiB"));
assert!(tiny.publish("Junk", &[figcraft_collab::Change::Delete { id: figcraft_doc::NodeId(1) }]).is_err());
}
#[test]
fn one_server_per_data_directory() {
let dir = TempDir::new("lock");
let first = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert!(Store::open(dir.path(), cfg(dir.path())).err().unwrap().contains("is using"));
assert!(Store::try_open(dir.path(), cfg(dir.path())).unwrap().is_none());
let key = first.key().to_string();
drop(first);
let again = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert_eq!(again.key(), key, "the key is kept");
}
#[test]
fn temporary_directories_are_removed() {
let path = {
let dir = TempDir::new("cleanup");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
publish(&mut store, "Gone");
drop(store);
dir.path().to_path_buf()
};
assert!(!path.exists());
}
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
let state = Server::new(doc("Fuzz"), "", figcraft_collab::Limits::default()).state().unwrap();
let snap = disk::encode_snapshot(3, &state).unwrap();
let mut log = disk::log_header(3);
log.extend(disk::record(&figcraft_collab::Journal::Issued { client: 9 }).unwrap());
assert_eq!(disk::decode_snapshot(&snap).unwrap().1, state);
assert_eq!(disk::read_log(&log).unwrap().entries.len(), 1);
for round in 0..5_000 {
let base = if round % 2 == 0 { &snap } else { &log };
let mut bytes = base.clone();
for _ in 0..1 + rng.below(4) {
if bytes.is_empty() {
break;
}
let i = rng.below(bytes.len());
match rng.below(3) {
0 => bytes[i] = rng.next_u64() as u8,
1 => bytes.insert(i, rng.next_u64() as u8),
_ => bytes.truncate(i),
}
}
let _ = disk::decode_snapshot(&bytes);
let _ = disk::check_snapshot(&bytes);
let _ = disk::read_log(&bytes);
}
}
+83
View File
@@ -0,0 +1,83 @@
//! What the tests share: temporary data directories (removed when dropped), settings, and engine
//! sessions linked over loopback.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
use figcraft_collab_net::NetLink;
use figcraft_collab_net::guest::GuestConfig;
use figcraft_engine::Session;
use serde_json::json;
use crate::config::Config;
/// A directory of its own under the system's temporary directory, removed with everything in it
/// when dropped.
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
static NEXT: AtomicU32 = AtomicU32::new(0);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join("figcraft-server-tests").join(format!("{tag}-{}-{nanos}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Settings for a server on `dir`, listening on a free loopback port.
pub fn config(dir: &Path) -> Config {
Config { listen: "127.0.0.1:0".into(), data: dir.to_path_buf(), ..Config::default() }
}
/// Joins quickly, and keeps trying long enough to outlast a server restart.
pub fn quick(mut c: GuestConfig) -> GuestConfig {
c.backoff = Duration::from_millis(20);
c.max_backoff = Duration::from_millis(200);
c.attempts = 100;
c.limits.hello_timeout = Duration::from_secs(5);
c
}
/// An engine session with the network link, named `name`.
pub fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump every session (as their windows would each frame) until `f` holds.
pub fn pump(sessions: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
let start = Instant::now();
loop {
for s in sessions.iter_mut() {
s.live_poll();
s.live_sync();
}
if f(sessions) {
return;
}
let states: Vec<String> = sessions.iter().map(|s| s.live_summary().to_string()).collect();
assert!(start.elapsed() < Duration::from_secs(20), "timed out waiting for {what}: {states:#?}");
std::thread::sleep(Duration::from_millis(5));
}
}
/// The synced properties of two documents agree.
pub fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
}