258fa072aa
The discovery module defines the seam: DiscoveryProvider (subscribe/ announce over 32-byte TopicKeys) and a signed Announcement carrying root hash, ed25519 author, metadata and provider addresses. Signatures cover a deterministic postcard encoding including the topic (no cross- channel replay) and the provider identities (addresses stay refreshable hints). LanDiscovery conforms to the trait — mdns sightings become locally-authored announcements, one per pinned root — and the daemon's auto-sync now runs entirely through it: verify, index unconditionally, fetch only for trusted authors from allowlisted providers on already- pinned incomplete roots. The milestone-4 real-mdns sync test passes unchanged through the new path. Verification unit tests cover round trip, tampered root, wrong topic, forged author, mismatched signing key, and serde survival. docs/redoal-integration.md sketches the gesture-topic gossip provider against this contract. Also: fix a flaky hang in the socket-activation test (dup2(3,3) leaves CLOEXEC set when the listener already sits on fd 3; parent's listener copy masked daemon death), and give the test client a read-timeout hang guard. Add a top-level README. Dependencies: ed25519-dalek (Signature type; same implementation iroh keys use), postcard (deterministic signed encoding, iroh's canonical compact codec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
231 lines
8.0 KiB
Rust
231 lines
8.0 KiB
Rust
//! Milestone 5: systemd socket activation, readiness notification, and
|
|
//! metered behavior.
|
|
|
|
mod support;
|
|
|
|
use std::os::fd::AsRawFd;
|
|
use std::os::unix::process::CommandExt;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use varde_proto::{PinPolicy, Request, ResponseData};
|
|
|
|
extern "C" {
|
|
fn dup2(oldfd: i32, newfd: i32) -> i32;
|
|
fn fcntl(fd: i32, cmd: i32, arg: i32) -> i32;
|
|
}
|
|
|
|
const F_SETFD: i32 = 2;
|
|
|
|
/// Spawn the daemon with an activation socket on fd 3, the LISTEN_FDS
|
|
/// protocol systemd uses.
|
|
#[test]
|
|
fn systemd_socket_activation() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let socket_path = dir.path().join("activated.sock");
|
|
let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap();
|
|
let fd = listener.as_raw_fd();
|
|
|
|
std::fs::write(dir.path().join("no-config.toml"), "").unwrap();
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_varde-daemon"));
|
|
command
|
|
.arg("--user")
|
|
.arg("--store")
|
|
.arg(dir.path().join("store"))
|
|
// Deliberately point --socket elsewhere: the activation fd must
|
|
// win over self-binding.
|
|
.arg("--socket")
|
|
.arg(dir.path().join("ignored.sock"))
|
|
.env("VARDE_CONFIG", dir.path().join("no-config.toml"))
|
|
.env("VARDE_DISCOVERY", "false")
|
|
.env("LISTEN_FDS", "1")
|
|
.env_remove("LISTEN_PID");
|
|
// SAFETY: dup2/fcntl are async-signal-safe; we place the listener on
|
|
// fd 3 in the child. dup2 clears CLOEXEC on the duplicate, but when
|
|
// the listener already *is* fd 3, dup2(3,3) is a no-op that leaves
|
|
// CLOEXEC set and exec would close the socket — clear it explicitly.
|
|
unsafe {
|
|
command.pre_exec(move || {
|
|
let rc = if fd == 3 {
|
|
fcntl(3, F_SETFD, 0)
|
|
} else {
|
|
dup2(fd, 3)
|
|
};
|
|
if rc < 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
let mut child = command.spawn().expect("spawning daemon");
|
|
// Drop our copy of the listener: if the daemon dies, connects must
|
|
// fail (and this test fail cleanly) rather than queue in the backlog
|
|
// of a socket nobody accepts on.
|
|
drop(listener);
|
|
|
|
// The daemon must answer on the activation socket.
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let mut answered = false;
|
|
while Instant::now() < deadline {
|
|
if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() {
|
|
let mut client = support::Client::connect(&socket_path);
|
|
client.set_read_timeout(Duration::from_secs(5));
|
|
let reply = client.request(&Request::Status { hash: None });
|
|
if reply.ok {
|
|
answered = true;
|
|
break;
|
|
}
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
assert!(answered, "daemon did not serve on the activation socket");
|
|
assert!(
|
|
!dir.path().join("ignored.sock").exists(),
|
|
"daemon bound its own socket despite activation"
|
|
);
|
|
}
|
|
|
|
/// With NOTIFY_SOCKET set, the daemon reports READY=1 once serving.
|
|
#[test]
|
|
fn sd_notify_ready_is_sent() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let notify_path = dir.path().join("notify.sock");
|
|
let notify = std::os::unix::net::UnixDatagram::bind(¬ify_path).unwrap();
|
|
notify
|
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
|
|
std::fs::write(dir.path().join("no-config.toml"), "").unwrap();
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_varde-daemon"))
|
|
.arg("--user")
|
|
.arg("--store")
|
|
.arg(dir.path().join("store"))
|
|
.arg("--socket")
|
|
.arg(dir.path().join("varde.sock"))
|
|
.env("VARDE_CONFIG", dir.path().join("no-config.toml"))
|
|
.env("VARDE_DISCOVERY", "false")
|
|
.env("NOTIFY_SOCKET", ¬ify_path)
|
|
.spawn()
|
|
.expect("spawning daemon");
|
|
|
|
let mut buf = [0u8; 64];
|
|
let result = notify.recv(&mut buf);
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
let n = result.expect("no readiness notification received");
|
|
let message = std::str::from_utf8(&buf[..n]).unwrap();
|
|
assert!(
|
|
message.contains("READY=1"),
|
|
"unexpected notify payload: {message}"
|
|
);
|
|
}
|
|
|
|
fn wait_complete(socket: &Path, hash: &str, timeout: Duration) -> bool {
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
let mut client = support::Client::connect(socket);
|
|
let reply = client.request(&Request::Status {
|
|
hash: Some(hash.to_string()),
|
|
});
|
|
if let Some(ResponseData::HashStatus(s)) = reply.data {
|
|
if s.complete {
|
|
return true;
|
|
}
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
false
|
|
}
|
|
|
|
/// A metered daemon serves nothing, even openly exported content.
|
|
#[test]
|
|
fn metered_daemon_stops_serving() {
|
|
let provider_dir = tempfile::tempdir().unwrap();
|
|
let provider =
|
|
support::spawn_daemon_with_env(provider_dir.path(), &[("VARDE_FORCE_METERED", "true")]);
|
|
let fetcher = support::spawn_daemon();
|
|
let mut provider_client = support::Client::connect(&provider.socket);
|
|
let mut fetcher_client = support::Client::connect(&fetcher.socket);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let src = dir.path().join("f.bin");
|
|
std::fs::write(&src, vec![4u8; 200_000]).unwrap();
|
|
let reply = provider_client.request(&Request::Add {
|
|
path: src.display().to_string(),
|
|
recursive: false,
|
|
});
|
|
let hash = match reply.data {
|
|
Some(ResponseData::Added { hash, .. }) => hash,
|
|
other => panic!("unexpected add reply: {other:?}"),
|
|
};
|
|
let reply = provider_client.request(&Request::Pin {
|
|
hash: hash.clone(),
|
|
policy: PinPolicy::default(),
|
|
});
|
|
assert!(reply.ok);
|
|
let reply = provider_client.request(&Request::TicketExport { hash: hash.clone() });
|
|
let ticket = match reply.data {
|
|
Some(ResponseData::Ticket { ticket }) => ticket,
|
|
other => panic!("unexpected ticket reply: {other:?}"),
|
|
};
|
|
|
|
let reply = fetcher_client.request(&Request::TicketImport {
|
|
ticket,
|
|
pin_policy: PinPolicy::default(),
|
|
});
|
|
assert!(reply.ok, "import should be accepted (pin recorded)");
|
|
assert!(
|
|
!wait_complete(&fetcher.socket, &hash, Duration::from_secs(8)),
|
|
"metered provider must not serve"
|
|
);
|
|
}
|
|
|
|
/// A metered daemon defers its own fetches too.
|
|
#[test]
|
|
fn metered_daemon_defers_fetching() {
|
|
let provider = support::spawn_daemon();
|
|
let fetcher_dir = tempfile::tempdir().unwrap();
|
|
let fetcher =
|
|
support::spawn_daemon_with_env(fetcher_dir.path(), &[("VARDE_FORCE_METERED", "true")]);
|
|
let mut provider_client = support::Client::connect(&provider.socket);
|
|
let mut fetcher_client = support::Client::connect(&fetcher.socket);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let src = dir.path().join("f.bin");
|
|
std::fs::write(&src, vec![6u8; 200_000]).unwrap();
|
|
let reply = provider_client.request(&Request::Add {
|
|
path: src.display().to_string(),
|
|
recursive: false,
|
|
});
|
|
let hash = match reply.data {
|
|
Some(ResponseData::Added { hash, .. }) => hash,
|
|
other => panic!("unexpected add reply: {other:?}"),
|
|
};
|
|
assert!(
|
|
provider_client
|
|
.request(&Request::Pin {
|
|
hash: hash.clone(),
|
|
policy: PinPolicy::default(),
|
|
})
|
|
.ok
|
|
);
|
|
let reply = provider_client.request(&Request::TicketExport { hash: hash.clone() });
|
|
let ticket = match reply.data {
|
|
Some(ResponseData::Ticket { ticket }) => ticket,
|
|
other => panic!("unexpected ticket reply: {other:?}"),
|
|
};
|
|
|
|
let reply = fetcher_client.request(&Request::TicketImport {
|
|
ticket,
|
|
pin_policy: PinPolicy::default(),
|
|
});
|
|
assert!(reply.ok, "import records the pin even while metered");
|
|
assert!(
|
|
!wait_complete(&fetcher.socket, &hash, Duration::from_secs(8)),
|
|
"metered fetcher must not download"
|
|
);
|
|
}
|