Receive files offered over IRC

XDCC, which on IRC means a bot hands you a file over a direct
connection. Issue #7, receiving half; offering files comes after.

The tracker entry asks whether hosting should just designate a folder,
and the answer is no - that is mIRC's fserve, which gives the far end a
dir/cd/get shell and is where the historical escapes came from. Real
XDCC bots serve a numbered pack list and a request names a number. That
is the shape the offering half will take, and it is the same principle
already written into moho's media scheme: picking one file out of a
folder is not consent to read the rest of it.

Everything in an offer is chosen by whoever sent it, so three rules
carry this, each a test rather than a habit. No string from the network
reaches the filesystem - the name written is derived here and joined
onto our own directory, so traversal is unrepresentable rather than
blocked. Limits are enforced against the stream, never against the
claim; the advertised size is a claim, and a sender that runs past what
it offered is cut off. And the daemon writes the file, so no window can
aim a network-driven write.

The naming rules are stricter than Linux needs. A name that is harmless
here is a device on Windows, and a trailing dot or space is dropped
there, so evil.exe. and evil.exe are one file on one platform and two on
the other - applied everywhere rather than per-platform, because the
same message producing different files depending on where it was read
is what nobody tests. downloads.ts had the same job and the same gaps,
so it is brought into step.

Nothing listens. Receiving only ever dials out, which is checkable -
the only TcpListener in the daemon is the one in these tests - and it
is also what makes this work on an account routed through Tor: the
transfer takes the route of the connection that carried the offer.
That route is published by the live connection rather than rebuilt from
the account config, because the two can disagree - turning Tor off in
settings does not move a connection that is already up, and rebuilding
would dial a transfer directly while its connection stayed proxied.
Reverse offers, where the sender asks us to listen, are refused: there
is no field in DCC that could carry an onion address, so publishing one
would have to be the real one.

Offers are always asked about, with a setting to stop asking. Turning
that on is worded for what it gives up rather than what it saves.

Two things fell out of writing the tests. Acknowledging a chunk to a
sender that has closed its end provokes a reset, and a reset discards
what had arrived but not yet been read - a lost file, caused by being
polite about a byte count nobody was listening for; acknowledgements
now stop after the first refusal. And a transfer now finishes when what
was offered has arrived, rather than waiting for the sender to close,
which both ends promptly and cannot deadlock against a sender waiting
for us.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-30 22:03:55 -04:00
co-authored by Claude Opus 5
parent 9f17ff229e
commit 1fdc8a5424
10 changed files with 1380 additions and 0 deletions
+21
View File
@@ -223,6 +223,13 @@ async fn run(state: &AppState, config: &IrcAccountConfig) -> Result<()> {
// capability negotiation to work anywhere.
let ison = tokio::spawn(poll_query_presence(state.clone(), account_id.clone(), sender.clone()));
// Published for as long as this connection is up, so a file offered over
// it is fetched back the same way. Registered rather than rebuilt when a
// transfer starts: turning Tor off in settings does not move a connection
// that is already established, and a transfer must follow the connection
// rather than the setting.
state.runtime.set_irc_transport(&account_id, Some(super::irc_dcc::transport_for(config)));
let result = async {
while let Some(msg) = stream.next().await.transpose()? {
handle_message(state, &account_id, &config.nick, &sender, msg, &nickserv_wait, &mut channels).await;
@@ -231,6 +238,13 @@ async fn run(state: &AppState, config: &IrcAccountConfig) -> Result<()> {
}
.await;
ison.abort();
// The route goes with the connection, and so does anything running over
// it: a transfer that outlived its connection would be a socket nobody
// is watching, and an unanswered offer would be a prompt for a file that
// can no longer arrive. `result?` below returns on error, so this has to
// come first.
state.runtime.set_irc_transport(&account_id, None);
state.runtime.cancel_dcc_for_account(&account_id);
result?;
Ok(())
@@ -662,6 +676,13 @@ async fn handle_message(
} else {
(from.clone(), "dm")
};
// Before it is treated as something somebody said. A file offer
// is CTCP, and recording it as a message put a line of control
// characters in the log where the offer should have been.
if let Some(dcc) = super::irc_dcc::parse_dcc(&body) {
super::irc_dcc::incoming(state, account_id, &from, &buffer_name, kind, dcc).await;
return;
}
if let Some(action_body) = strip_action(&body) {
state.runtime.record_message_at(state, account_id, &buffer_name, kind, &from, action_body, true, "chat", None, None, false, None, Vec::new(), Vec::new(), None, sent_at, None);
} else {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -214,6 +214,7 @@ mod tests {
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
};
let config = crate::accounts::MatrixAccountConfig {
homeserver_url: homeserver.to_string(),
+5
View File
@@ -1764,6 +1764,7 @@ mod tests {
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
};
let config = MatrixAccountConfig {
@@ -1878,6 +1879,7 @@ mod tests {
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
};
let config = MatrixAccountConfig {
@@ -1986,6 +1988,7 @@ mod tests {
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
};
let config_a = MatrixAccountConfig {
@@ -2139,6 +2142,7 @@ mod tests {
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
};
let config = MatrixAccountConfig {
@@ -2291,6 +2295,7 @@ mod tests {
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
};
let config = MatrixAccountConfig {
+1
View File
@@ -370,6 +370,7 @@ mod tests {
shutdown: Arc::new(tokio::sync::Notify::new()),
voice: Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
};
let config = MatrixAccountConfig {
homeserver_url: homeserver.to_string(),
+1
View File
@@ -2,5 +2,6 @@ pub mod audio;
pub mod discord;
pub mod discord_voice;
pub mod irc;
pub mod irc_dcc;
pub mod matrix;
pub mod sockchat;
+1
View File
@@ -173,6 +173,7 @@ async fn main() -> Result<()> {
shutdown: Arc::new(tokio::sync::Notify::new()),
voice: Arc::new(backend::discord_voice::VoiceState::new()),
voice_prefs: Arc::new(backend::audio::VoicePrefsStore::open(opts.data_dir.join("voice.toml"))),
dcc_prefs: Arc::new(backend::irc_dcc::DccPrefsStore::open(opts.data_dir.join("dcc.toml"))),
};
// Reconnect every saved account, same as
+61
View File
@@ -849,6 +849,67 @@ pub async fn dispatch(
// a second window has to agree with the first.
"getVoicePrefs" => (Some(serde_json::to_value(state.voice_prefs.get()).unwrap()), None),
// Files offered over IRC. Offers and transfers are one list: what a
// person is watching is a file arriving, and being offered is its
// first state rather than a different kind of thing.
"listTransfers" => {
let rows: Vec<Value> =
state.runtime.dcc_transfers().iter().map(backend::irc_dcc::transfer_json).collect();
(Some(serde_json::json!(rows)), None)
}
"acceptTransfer" => {
let Some(id) = p_str_opt(params, "id") else {
return (None, Some("acceptTransfer requires \"id\"".to_string()));
};
backend::irc_dcc::accept(state, id);
(Some(Value::Bool(true)), None)
}
// Turning down an offer and stopping one already running are the same
// request from where the person is sitting - they want it to stop.
"cancelTransfer" => {
let Some(id) = p_str_opt(params, "id") else {
return (None, Some("cancelTransfer requires \"id\"".to_string()));
};
backend::irc_dcc::cancel(state, id, "declined");
(Some(Value::Bool(true)), None)
}
// The resolved directory goes out alongside the setting, so a window
// can show where files will actually land rather than an empty box
// that means "wherever the platform puts them".
"getDccPrefs" => {
let prefs = state.dcc_prefs.get();
let mut out = serde_json::to_value(&prefs).unwrap_or_else(|_| serde_json::json!({}));
if let Some(obj) = out.as_object_mut() {
obj.insert("resolvedDirectory".into(), Value::String(prefs.download_dir().display().to_string()));
}
(Some(out), None)
}
"setDccPrefs" => {
let prefs = state.dcc_prefs.update(|p| {
if let Some(dir) = p_str_opt(params, "directory") {
p.directory = (!dir.is_empty()).then(|| dir.to_string());
}
if let Some(n) = params.get("maxBytes").and_then(|v| v.as_u64()) {
p.max_bytes = n;
}
if let Some(n) = params.get("maxTransfers").and_then(|v| v.as_u64()) {
// One is the smallest useful answer; zero would mean
// nothing could ever be accepted, which the auto-accept
// toggle already expresses better.
p.max_transfers = (n as usize).max(1);
}
if let Some(b) = params.get("autoAccept").and_then(|v| v.as_bool()) {
p.auto_accept = b;
}
});
state.events.emit("dccPrefsChanged", serde_json::to_value(&prefs).unwrap_or_default());
(Some(serde_json::to_value(&prefs).unwrap_or_default()), None)
}
// Choosing a device. An input choice takes effect on a call already in
// progress: the stream is moved rather than reopened, which is what
// the desktop's own mixer does.
+153
View File
@@ -38,6 +38,73 @@ impl VoiceFlags {
}
}
/// A file somebody has offered, from the moment it is offered to the moment
/// it is on disk or has failed.
#[derive(Clone, Debug)]
pub struct DccTransfer {
pub id: String,
pub account_id: String,
/// Who offered it.
pub from: String,
/// What we would call it on disk - already made safe.
pub file_name: String,
/// What they called it. Shown alongside where the two differ, so a name
/// that had to be changed is visible rather than silently substituted.
pub raw_name: String,
pub size: u64,
pub received: u64,
pub state: DccState,
/// Set once it is on disk.
pub path: Option<String>,
/// Why it failed, or why it was refused.
pub error: Option<String>,
/// Raised to stop a transfer that is already running.
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// The offer itself, kept so accepting does not have to read anything
/// off the network a second time. Dropped once it is settled.
pub offer: Option<crate::backend::irc_dcc::DccSend>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DccState {
/// Waiting on an answer.
Offered,
Receiving,
Done,
Declined,
Failed,
}
impl DccState {
pub fn as_str(self) -> &'static str {
match self {
DccState::Offered => "offered",
DccState::Receiving => "receiving",
DccState::Done => "done",
DccState::Declined => "declined",
DccState::Failed => "failed",
}
}
/// Still going, so it counts against the concurrency limit.
pub fn active(self) -> bool {
matches!(self, DccState::Offered | DccState::Receiving)
}
}
/// How many transfers are remembered at once.
///
/// The finished ones are kept only so the list has some history in it; a
/// person who wants the file wants the file, not a ledger.
const DCC_KEEP: usize = 100;
/// How many unanswered offers one person may have outstanding.
///
/// Without this, anybody who can message you can put an unbounded queue of
/// prompts on your screen, which is a nuisance at best and a way to hide a
/// real prompt among fifty fake ones at worst.
const DCC_PENDING_PER_NICK: usize = 3;
/// Somebody in a voice channel, as a call view needs them.
///
/// A struct rather than the tuple this used to be: it grew a picture, and a
@@ -286,6 +353,19 @@ pub struct Runtime {
discord_voice_avatars: Mutex<HashMap<(String, String), String>>,
/// account -> the voice channel we are in, if any.
discord_voice_self: Mutex<HashMap<String, String>>,
/// Offered files waiting on an answer, and transfers already running,
/// newest first. One list rather than two: a person watching this is
/// watching one thing happen - a file arriving - and an offer is its
/// first state, not a different kind of object.
dcc_transfers: Mutex<Vec<DccTransfer>>,
/// account -> the route its live IRC connection is using.
///
/// Kept rather than rebuilt from the account config when a transfer is
/// accepted, because the two can disagree: turning Tor off in settings
/// does not move a connection that is already up, and rebuilding would
/// then dial a transfer directly while its connection was still proxied.
/// No entry means no live connection, and so nothing to transfer over.
irc_transports: Mutex<HashMap<String, crate::net::tor::Transport>>,
/// Discord-specific: account id -> its live gateway writer, so a status
/// change can push a presence update on the existing connection instead of
/// waiting for a reconnect.
@@ -416,6 +496,8 @@ impl Runtime {
discord_voice_names: Mutex::new(HashMap::new()),
discord_voice_avatars: Mutex::new(HashMap::new()),
discord_voice_self: Mutex::new(HashMap::new()),
dcc_transfers: Mutex::new(Vec::new()),
irc_transports: Mutex::new(HashMap::new()),
discord_gateway_senders: Mutex::new(HashMap::new()),
matrix_machines: Mutex::new(HashMap::new()),
matrix_encrypted_rooms: Mutex::new(HashSet::new()),
@@ -1028,6 +1110,77 @@ impl Runtime {
}
/// The same, with what each of them is doing.
/// Records a new offer, unless this person already has too many waiting.
///
/// Returns None when the offer was dropped, so the caller can say so in
/// the log rather than the offer vanishing without explanation.
pub fn add_dcc_offer(&self, transfer: DccTransfer) -> Option<DccTransfer> {
let mut list = self.dcc_transfers.lock().unwrap();
let waiting = list
.iter()
.filter(|t| t.account_id == transfer.account_id && t.from == transfer.from && t.state == DccState::Offered)
.count();
if waiting >= DCC_PENDING_PER_NICK {
return None;
}
list.insert(0, transfer.clone());
list.truncate(DCC_KEEP);
Some(transfer)
}
pub fn set_irc_transport(&self, account_id: &str, transport: Option<crate::net::tor::Transport>) {
let mut map = self.irc_transports.lock().unwrap();
match transport {
Some(t) => {
map.insert(account_id.to_string(), t);
}
None => {
map.remove(account_id);
}
}
}
pub fn irc_transport(&self, account_id: &str) -> Option<crate::net::tor::Transport> {
self.irc_transports.lock().unwrap().get(account_id).cloned()
}
pub fn dcc_transfers(&self) -> Vec<DccTransfer> {
self.dcc_transfers.lock().unwrap().clone()
}
pub fn dcc_transfer(&self, id: &str) -> Option<DccTransfer> {
self.dcc_transfers.lock().unwrap().iter().find(|t| t.id == id).cloned()
}
/// How many are offered or running, against the concurrency limit.
pub fn dcc_active_count(&self) -> usize {
self.dcc_transfers.lock().unwrap().iter().filter(|t| t.state.active()).count()
}
/// Applies `edit` to one transfer and hands back what it became.
pub fn update_dcc(&self, id: &str, edit: impl FnOnce(&mut DccTransfer)) -> Option<DccTransfer> {
let mut list = self.dcc_transfers.lock().unwrap();
let t = list.iter_mut().find(|t| t.id == id)?;
edit(t);
Some(t.clone())
}
/// Stops everything belonging to an account that is going away.
///
/// A transfer outliving the connection that carried its offer would be a
/// socket nobody is watching any more.
pub fn cancel_dcc_for_account(&self, account_id: &str) {
for t in self.dcc_transfers.lock().unwrap().iter_mut() {
if t.account_id == account_id && t.state.active() {
t.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
if t.state == DccState::Offered {
t.state = DccState::Declined;
t.error = Some("disconnected before it was answered".to_string());
}
}
}
}
pub fn discord_voice_roster(&self, account_id: &str, channel_id: &str) -> Vec<VoiceRosterEntry> {
let names = self.discord_voice_names.lock().unwrap();
let avatars = self.discord_voice_avatars.lock().unwrap();
+1
View File
@@ -30,4 +30,5 @@ pub struct AppState {
/// Which sound devices voice uses, and whether it is silenced. Persisted,
/// so a deliberate mute is still in force after a restart.
pub voice_prefs: Arc<crate::backend::audio::VoicePrefsStore>,
pub dcc_prefs: Arc<crate::backend::irc_dcc::DccPrefsStore>,
}