Compare commits
No commits in common. "fadd4f37cdf0407e584b56d4657e453efc0c980c" and "829a3916fd8b375aed48f68cefa782edae87ec1c" have entirely different histories.
fadd4f37cd
...
829a3916fd
15 changed files with 72 additions and 235 deletions
|
|
@ -24,17 +24,13 @@ fn brand_mark(brand: &str, accent: &str, logo: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(brand: &str, accent: &str, logo: &str, title: &str, message: &str, code: &str, note: &str) -> String {
|
fn render(brand: &str, accent: &str, logo: &str, title: &str, message: &str, code: &str, note: &str) -> String {
|
||||||
// `message` carries the account name (attacker-influenceable). Substitute every
|
|
||||||
// OTHER slot first and `{{message}}` LAST, so a message that literally contains
|
|
||||||
// `{{code}}`/`{{note}}` (an account named that) is inserted verbatim rather than
|
|
||||||
// splicing in the real code.
|
|
||||||
BASE.replace("{{brand_mark}}", &brand_mark(brand, accent, logo))
|
BASE.replace("{{brand_mark}}", &brand_mark(brand, accent, logo))
|
||||||
.replace("{{brand}}", &escape(brand))
|
.replace("{{brand}}", &escape(brand))
|
||||||
.replace("{{accent}}", &escape(accent))
|
.replace("{{accent}}", &escape(accent))
|
||||||
.replace("{{title}}", &escape(title))
|
.replace("{{title}}", &escape(title))
|
||||||
|
.replace("{{message}}", &escape(message))
|
||||||
.replace("{{code}}", &escape(code))
|
.replace("{{code}}", &escape(code))
|
||||||
.replace("{{note}}", &escape(note))
|
.replace("{{note}}", &escape(note))
|
||||||
.replace("{{message}}", &escape(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str, lang: &str) -> Mail {
|
pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str, lang: &str) -> Mail {
|
||||||
|
|
|
||||||
|
|
@ -497,29 +497,13 @@ pub fn render(lang: &str, msgid: &str, args: &[(&str, String)]) -> String {
|
||||||
.and_then(|m| m.get(msgid))
|
.and_then(|m| m.get(msgid))
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.unwrap_or(msgid);
|
.unwrap_or(msgid);
|
||||||
if args.is_empty() || !template.contains('{') {
|
if args.is_empty() {
|
||||||
return template.to_string();
|
return template.to_string();
|
||||||
}
|
}
|
||||||
// Single left-to-right pass: a substituted value is copied to the output and
|
let mut out = template.to_string();
|
||||||
// never re-scanned, so a value that itself contains `{name}` (e.g. a nick like
|
for (name, val) in args {
|
||||||
// "{game}") can't trigger a second substitution. An unknown `{x}` (no matching
|
out = out.replace(&format!("{{{name}}}"), val);
|
||||||
// arg, e.g. the literal `{ON|OFF}`) is left verbatim, as before.
|
|
||||||
let mut out = String::with_capacity(template.len() + 16);
|
|
||||||
let mut rest = template;
|
|
||||||
while let Some(open) = rest.find('{') {
|
|
||||||
out.push_str(&rest[..open]);
|
|
||||||
let Some(rel_close) = rest[open..].find('}') else {
|
|
||||||
break; // unbalanced '{' — emit the remainder verbatim below
|
|
||||||
};
|
|
||||||
let close = open + rel_close;
|
|
||||||
let name = &rest[open + 1..close];
|
|
||||||
match args.iter().find(|(n, _)| *n == name) {
|
|
||||||
Some((_, val)) => out.push_str(val),
|
|
||||||
None => out.push_str(&rest[open..=close]),
|
|
||||||
}
|
|
||||||
rest = &rest[close + 1..];
|
|
||||||
}
|
}
|
||||||
out.push_str(rest);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
21
lang/de.json
21
lang/de.json
|
|
@ -1334,24 +1334,5 @@
|
||||||
"You didn't identify in time; you've been renamed.": "Du hast dich nicht rechtzeitig identifiziert; du wurdest umbenannt.",
|
"You didn't identify in time; you've been renamed.": "Du hast dich nicht rechtzeitig identifiziert; du wurdest umbenannt.",
|
||||||
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Bitte beachte die Kanalregeln — {reason} Beim nächsten Mal wirst du gekickt.",
|
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Bitte beachte die Kanalregeln — {reason} Beim nächsten Mal wirst du gekickt.",
|
||||||
"Access denied — {what} is for services operators.": "Zugriff verweigert — {what} ist für Services-Operatoren.",
|
"Access denied — {what} is for services operators.": "Zugriff verweigert — {what} ist für Services-Operatoren.",
|
||||||
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 ist kein gültiger Nickname.",
|
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 ist kein gültiger Nickname."
|
||||||
"Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Die Kanäle, die du als \u0002{account}\u0002 registriert hattest, wurden freigegeben: {list}.",
|
|
||||||
"Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Dein Konto \u0002{account}\u0002 {reason}. Du wurdest abgemeldet.",
|
|
||||||
"was dropped": "wurde gelöscht",
|
|
||||||
"collided with another network and no longer belongs to you": "kollidierte mit einem anderen Netzwerk und gehört dir nicht mehr",
|
|
||||||
"was suspended": "wurde gesperrt",
|
|
||||||
"expired after a long period of inactivity": "ist nach langer Inaktivität abgelaufen",
|
|
||||||
"Your password for \u0002{account}\u0002 has been changed.": "Dein Passwort für \u0002{account}\u0002 wurde geändert.",
|
|
||||||
"That's a lot of dice, stopping the repeats here.": "Das sind viele Würfel, ich stoppe die Wiederholungen hier.",
|
|
||||||
"trailing characters in expression": "überzählige Zeichen im Ausdruck",
|
|
||||||
"missing closing parenthesis": "schließende Klammer fehlt",
|
|
||||||
"expected a value": "Wert erwartet",
|
|
||||||
"nothing to roll": "nichts zum Würfeln",
|
|
||||||
"that expression is too long": "dieser Ausdruck ist zu lang",
|
|
||||||
"that doesn't come out to a real number": "das ergibt keine reelle Zahl",
|
|
||||||
"division by zero": "Division durch null",
|
|
||||||
"modulo by zero": "Modulo durch null",
|
|
||||||
"that expression rolls too many dice": "dieser Ausdruck würfelt zu viele Würfel",
|
|
||||||
"hypot() takes two arguments": "hypot() nimmt zwei Argumente",
|
|
||||||
"pow() takes two arguments": "pow() nimmt zwei Argumente"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1334,24 +1334,5 @@
|
||||||
"You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te cambió el nombre.",
|
"You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te cambió el nombre.",
|
||||||
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Prestá atención a las reglas del canal — {reason} La próxima vez te van a expulsar.",
|
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Prestá atención a las reglas del canal — {reason} La próxima vez te van a expulsar.",
|
||||||
"Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de los servicios.",
|
"Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de los servicios.",
|
||||||
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido.",
|
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido."
|
||||||
"Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Los canales que habías registrado como \u0002{account}\u0002 fueron liberados: {list}.",
|
|
||||||
"Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Tu cuenta \u0002{account}\u0002 {reason}. Se cerró tu sesión.",
|
|
||||||
"was dropped": "fue eliminada",
|
|
||||||
"collided with another network and no longer belongs to you": "entró en conflicto con otra red y ya no te pertenece",
|
|
||||||
"was suspended": "fue suspendida",
|
|
||||||
"expired after a long period of inactivity": "caducó tras un largo período de inactividad",
|
|
||||||
"Your password for \u0002{account}\u0002 has been changed.": "Tu contraseña para \u0002{account}\u0002 fue cambiada.",
|
|
||||||
"That's a lot of dice, stopping the repeats here.": "Son un montón de dados, corto las repeticiones acá.",
|
|
||||||
"trailing characters in expression": "caracteres de más en la expresión",
|
|
||||||
"missing closing parenthesis": "falta el paréntesis de cierre",
|
|
||||||
"expected a value": "se esperaba un valor",
|
|
||||||
"nothing to roll": "nada para tirar",
|
|
||||||
"that expression is too long": "esa expresión es demasiado larga",
|
|
||||||
"that doesn't come out to a real number": "eso no da un número real",
|
|
||||||
"division by zero": "división por cero",
|
|
||||||
"modulo by zero": "módulo por cero",
|
|
||||||
"that expression rolls too many dice": "esa expresión tira demasiados dados",
|
|
||||||
"hypot() takes two arguments": "hypot() toma dos argumentos",
|
|
||||||
"pow() takes two arguments": "pow() toma dos argumentos"
|
|
||||||
}
|
}
|
||||||
21
lang/es.json
21
lang/es.json
|
|
@ -1334,24 +1334,5 @@
|
||||||
"You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te ha cambiado el nombre.",
|
"You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te ha cambiado el nombre.",
|
||||||
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Ten en cuenta las reglas del canal — {reason} La próxima vez se te expulsará.",
|
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Ten en cuenta las reglas del canal — {reason} La próxima vez se te expulsará.",
|
||||||
"Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de servicios.",
|
"Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de servicios.",
|
||||||
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido.",
|
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido."
|
||||||
"Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Los canales que habías registrado como \u0002{account}\u0002 han sido liberados: {list}.",
|
|
||||||
"Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Tu cuenta \u0002{account}\u0002 {reason}. Se ha cerrado tu sesión.",
|
|
||||||
"was dropped": "ha sido eliminada",
|
|
||||||
"collided with another network and no longer belongs to you": "entró en conflicto con otra red y ya no te pertenece",
|
|
||||||
"was suspended": "ha sido suspendida",
|
|
||||||
"expired after a long period of inactivity": "ha caducado tras un largo periodo de inactividad",
|
|
||||||
"Your password for \u0002{account}\u0002 has been changed.": "Tu contraseña para \u0002{account}\u0002 ha sido cambiada.",
|
|
||||||
"That's a lot of dice, stopping the repeats here.": "Son muchos dados, detengo las repeticiones aquí.",
|
|
||||||
"trailing characters in expression": "caracteres sobrantes en la expresión",
|
|
||||||
"missing closing parenthesis": "falta el paréntesis de cierre",
|
|
||||||
"expected a value": "se esperaba un valor",
|
|
||||||
"nothing to roll": "nada que lanzar",
|
|
||||||
"that expression is too long": "esa expresión es demasiado larga",
|
|
||||||
"that doesn't come out to a real number": "eso no da un número real",
|
|
||||||
"division by zero": "división por cero",
|
|
||||||
"modulo by zero": "módulo por cero",
|
|
||||||
"that expression rolls too many dice": "esa expresión lanza demasiados dados",
|
|
||||||
"hypot() takes two arguments": "hypot() toma dos argumentos",
|
|
||||||
"pow() takes two arguments": "pow() toma dos argumentos"
|
|
||||||
}
|
}
|
||||||
21
lang/fr.json
21
lang/fr.json
|
|
@ -1334,24 +1334,5 @@
|
||||||
"You didn't identify in time; you've been renamed.": "Vous ne vous êtes pas identifié à temps ; vous avez été renommé.",
|
"You didn't identify in time; you've been renamed.": "Vous ne vous êtes pas identifié à temps ; vous avez été renommé.",
|
||||||
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Veuillez respecter les règles du canal — {reason} La prochaine fois, vous serez expulsé.",
|
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Veuillez respecter les règles du canal — {reason} La prochaine fois, vous serez expulsé.",
|
||||||
"Access denied — {what} is for services operators.": "Accès refusé — {what} est réservé aux opérateurs des services.",
|
"Access denied — {what} is for services operators.": "Accès refusé — {what} est réservé aux opérateurs des services.",
|
||||||
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 n'est pas un pseudo valide.",
|
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 n'est pas un pseudo valide."
|
||||||
"Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Les salons que vous aviez enregistrés en tant que \u0002{account}\u0002 ont été libérés : {list}.",
|
|
||||||
"Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Votre compte \u0002{account}\u0002 {reason}. Vous avez été déconnecté.",
|
|
||||||
"was dropped": "a été supprimé",
|
|
||||||
"collided with another network and no longer belongs to you": "est entré en collision avec un autre réseau et ne vous appartient plus",
|
|
||||||
"was suspended": "a été suspendu",
|
|
||||||
"expired after a long period of inactivity": "a expiré après une longue période d'inactivité",
|
|
||||||
"Your password for \u0002{account}\u0002 has been changed.": "Votre mot de passe pour \u0002{account}\u0002 a été modifié.",
|
|
||||||
"That's a lot of dice, stopping the repeats here.": "Ça fait beaucoup de dés, j'arrête les répétitions ici.",
|
|
||||||
"trailing characters in expression": "caractères en trop dans l'expression",
|
|
||||||
"missing closing parenthesis": "parenthèse fermante manquante",
|
|
||||||
"expected a value": "valeur attendue",
|
|
||||||
"nothing to roll": "rien à lancer",
|
|
||||||
"that expression is too long": "cette expression est trop longue",
|
|
||||||
"that doesn't come out to a real number": "cela ne donne pas un nombre réel",
|
|
||||||
"division by zero": "division par zéro",
|
|
||||||
"modulo by zero": "modulo par zéro",
|
|
||||||
"that expression rolls too many dice": "cette expression lance trop de dés",
|
|
||||||
"hypot() takes two arguments": "hypot() prend deux arguments",
|
|
||||||
"pow() takes two arguments": "pow() prend deux arguments"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1334,24 +1334,5 @@
|
||||||
"You didn't identify in time; you've been renamed.": "Você não se identificou a tempo; seu apelido foi alterado.",
|
"You didn't identify in time; you've been renamed.": "Você não se identificou a tempo; seu apelido foi alterado.",
|
||||||
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez você será expulso.",
|
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez você será expulso.",
|
||||||
"Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços.",
|
"Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços.",
|
||||||
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é um apelido válido.",
|
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é um apelido válido."
|
||||||
"Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Os canais que você registrou como \u0002{account}\u0002 foram liberados: {list}.",
|
|
||||||
"Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Sua conta \u0002{account}\u0002 {reason}. Sua sessão foi encerrada.",
|
|
||||||
"was dropped": "foi removida",
|
|
||||||
"collided with another network and no longer belongs to you": "entrou em conflito com outra rede e não pertence mais a você",
|
|
||||||
"was suspended": "foi suspensa",
|
|
||||||
"expired after a long period of inactivity": "expirou após um longo período de inatividade",
|
|
||||||
"Your password for \u0002{account}\u0002 has been changed.": "Sua senha para \u0002{account}\u0002 foi alterada.",
|
|
||||||
"That's a lot of dice, stopping the repeats here.": "São muitos dados, parando as repetições aqui.",
|
|
||||||
"trailing characters in expression": "caracteres a mais na expressão",
|
|
||||||
"missing closing parenthesis": "falta o parêntese de fechamento",
|
|
||||||
"expected a value": "esperava um valor",
|
|
||||||
"nothing to roll": "nada para rolar",
|
|
||||||
"that expression is too long": "essa expressão é longa demais",
|
|
||||||
"that doesn't come out to a real number": "isso não resulta em um número real",
|
|
||||||
"division by zero": "divisão por zero",
|
|
||||||
"modulo by zero": "módulo por zero",
|
|
||||||
"that expression rolls too many dice": "essa expressão rola dados demais",
|
|
||||||
"hypot() takes two arguments": "hypot() recebe dois argumentos",
|
|
||||||
"pow() takes two arguments": "pow() recebe dois argumentos"
|
|
||||||
}
|
}
|
||||||
21
lang/pt.json
21
lang/pt.json
|
|
@ -1334,24 +1334,5 @@
|
||||||
"You didn't identify in time; you've been renamed.": "Não se identificou a tempo; o seu nome foi alterado.",
|
"You didn't identify in time; you've been renamed.": "Não se identificou a tempo; o seu nome foi alterado.",
|
||||||
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez será expulso.",
|
"Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez será expulso.",
|
||||||
"Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços.",
|
"Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços.",
|
||||||
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é uma alcunha válida.",
|
"\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é uma alcunha válida."
|
||||||
"Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Os canais que tinha registado como \u0002{account}\u0002 foram libertados: {list}.",
|
|
||||||
"Your account \u0002{account}\u0002 {reason}. You have been logged out.": "A sua conta \u0002{account}\u0002 {reason}. A sua sessão foi terminada.",
|
|
||||||
"was dropped": "foi removida",
|
|
||||||
"collided with another network and no longer belongs to you": "entrou em conflito com outra rede e já não lhe pertence",
|
|
||||||
"was suspended": "foi suspensa",
|
|
||||||
"expired after a long period of inactivity": "expirou após um longo período de inatividade",
|
|
||||||
"Your password for \u0002{account}\u0002 has been changed.": "A sua palavra-passe para \u0002{account}\u0002 foi alterada.",
|
|
||||||
"That's a lot of dice, stopping the repeats here.": "São muitos dados, a parar as repetições aqui.",
|
|
||||||
"trailing characters in expression": "caracteres a mais na expressão",
|
|
||||||
"missing closing parenthesis": "falta o parêntese de fecho",
|
|
||||||
"expected a value": "esperava um valor",
|
|
||||||
"nothing to roll": "nada para lançar",
|
|
||||||
"that expression is too long": "essa expressão é demasiado longa",
|
|
||||||
"that doesn't come out to a real number": "isso não dá um número real",
|
|
||||||
"division by zero": "divisão por zero",
|
|
||||||
"modulo by zero": "módulo por zero",
|
|
||||||
"that expression rolls too many dice": "essa expressão lança demasiados dados",
|
|
||||||
"hypot() takes two arguments": "hypot() recebe dois argumentos",
|
|
||||||
"pow() takes two arguments": "pow() recebe dois argumentos"
|
|
||||||
}
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte
|
||||||
let input = rest.join(" ");
|
let input = rest.join(" ");
|
||||||
let input = input.trim();
|
let input = input.trim();
|
||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
ctx.notice(me, from.uid, t!(ctx, "Give me something to roll, e.g. \x022d6+3\x02."));
|
ctx.notice(me, from.uid, "Give me something to roll, e.g. \x022d6+3\x02.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// `N~expr` repeats the roll N times.
|
// `N~expr` repeats the roll N times.
|
||||||
|
|
@ -44,15 +44,12 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte
|
||||||
// `25~99999d1+…` can't multiply the per-roll cap into tens of millions.
|
// `25~99999d1+…` can't multiply the per-roll cap into tens of millions.
|
||||||
if spent >= expr::MAX_TOTAL {
|
if spent >= expr::MAX_TOTAL {
|
||||||
if i + 1 < times {
|
if i + 1 < times {
|
||||||
ctx.notice(me, from.uid, t!(ctx, "That's a lot of dice, stopping the repeats here."));
|
ctx.notice(me, from.uid, "That's a lot of dice — stopping the repeats here.");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(why) => {
|
Err(why) => {
|
||||||
// The parser returns its diagnostic in English; localize the static
|
|
||||||
// fragments through the catalog (interpolated ones fall back verbatim).
|
|
||||||
let why = echo_api::render(ctx.lang(), &why, &[]);
|
|
||||||
ctx.notice(me, from.uid, t!(ctx, "I couldn't roll \x02{body}\x02: {why}.", body = body, why = why));
|
ctx.notice(me, from.uid, t!(ctx, "I couldn't roll \x02{body}\x02: {why}.", body = body, why = why));
|
||||||
return; // the whole batch shares the expression, so it'll fail the same way
|
return; // the whole batch shares the expression, so it'll fail the same way
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -463,11 +463,9 @@ fn push_stats(counters: &[(String, u64)], account: &str, to_nick: &str, me: &str
|
||||||
_ => read_stats(counters, gt, account),
|
_ => read_stats(counters, gt, account),
|
||||||
};
|
};
|
||||||
let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, gt.wire(), s.rank(), s.points(), s.wins, s.losses, s.draws);
|
let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, gt.wire(), s.rank(), s.points(), s.wins, s.losses, s.draws);
|
||||||
// Ranked stats are PUBLIC leaderboard data and are also pushed to a viewer
|
// push_stats is only called for a registered side (a_reg/b_reg), so the
|
||||||
// who queried someone ELSE (STATS <nick>), so `to_nick` is not the subject
|
// account guard in push_tag always applies here.
|
||||||
// account — don't apply the board-state account guard here (that guard is
|
push_tag(me, net, to_nick, account, true, &payload, ctx);
|
||||||
// only for the private mid-game board in push_one).
|
|
||||||
push_tag(me, net, to_nick, account, false, &payload, ctx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -842,9 +842,8 @@ impl Db {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channels entering the warning window, paired with their founder's email
|
/// Channels entering the warning window, paired with their founder's email
|
||||||
/// (skipped when the founder has none) and preferred language. Returns
|
/// (skipped when the founder has none). Returns (channel, email, seconds left).
|
||||||
/// (channel, email, seconds left, founder language).
|
pub fn channels_to_warn(&self, now: u64, ttl: u64, lead: u64) -> Vec<(String, String, u64)> {
|
||||||
pub fn channels_to_warn(&self, now: u64, ttl: u64, lead: u64) -> Vec<(String, String, u64, String)> {
|
|
||||||
let floor = ttl.saturating_sub(lead);
|
let floor = ttl.saturating_sub(lead);
|
||||||
self.channels
|
self.channels
|
||||||
.values()
|
.values()
|
||||||
|
|
@ -854,10 +853,8 @@ impl Db {
|
||||||
if idle <= floor || idle > ttl {
|
if idle <= floor || idle > ttl {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let acc = self.accounts.get(&key(&c.founder))?;
|
let email = self.accounts.get(&key(&c.founder)).and_then(|a| a.email.clone())?;
|
||||||
let email = acc.email.clone()?;
|
Some((c.name.clone(), email, ttl - idle))
|
||||||
let lang = acc.language.clone().unwrap_or_else(|| self.default_language());
|
|
||||||
Some((c.name.clone(), email, ttl - idle, lang))
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -727,8 +727,11 @@ impl Engine {
|
||||||
self.logout_uid(&uid, account, reason);
|
self.logout_uid(&uid, account, reason);
|
||||||
if !orphaned.is_empty() {
|
if !orphaned.is_empty() {
|
||||||
if let Some(ns) = &ns {
|
if let Some(ns) = &ns {
|
||||||
let text = echo_api::render(&self.lang_for_uid(&uid), "Channels you had registered as \x02{account}\x02 were released: {list}.", &[("account", account.to_string()), ("list", orphaned.join(", "))]);
|
self.emit_irc(NetAction::Notice {
|
||||||
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid.clone(), text });
|
from: ns.clone(),
|
||||||
|
to: uid,
|
||||||
|
text: format!("Channels you had registered as \x02{account}\x02 were released: {}.", orphaned.join(", ")),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -770,10 +773,11 @@ impl Engine {
|
||||||
self.network.clear_account(uid);
|
self.network.clear_account(uid);
|
||||||
self.emit_irc(NetAction::Metadata { target: uid.to_string(), key: "accountname".to_string(), value: String::new() });
|
self.emit_irc(NetAction::Metadata { target: uid.to_string(), key: "accountname".to_string(), value: String::new() });
|
||||||
if let Some(ns) = self.nick_service.clone() {
|
if let Some(ns) = self.nick_service.clone() {
|
||||||
let lang = self.lang_for_uid(uid);
|
self.emit_irc(NetAction::Notice {
|
||||||
let reason = echo_api::render(&lang, reason, &[]);
|
from: ns,
|
||||||
let text = echo_api::render(&lang, "Your account \x02{account}\x02 {reason}. You have been logged out.", &[("account", account.to_string()), ("reason", reason)]);
|
to: uid.to_string(),
|
||||||
self.emit_irc(NetAction::Notice { from: ns, to: uid.to_string(), text });
|
text: format!("Your account \x02{account}\x02 {reason}. You have been logged out."),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -855,8 +859,8 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ttl) = self.channel_ttl {
|
if let Some(ttl) = self.channel_ttl {
|
||||||
for (channel, email, left, lang) in self.db.channels_to_warn(now, ttl, lead) {
|
for (channel, email, left) in self.db.channels_to_warn(now, ttl, lead) {
|
||||||
let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left), &lang);
|
let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left), &self.db.default_language());
|
||||||
self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) });
|
self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) });
|
||||||
self.db.mark_channel_warned(&channel);
|
self.db.mark_channel_warned(&channel);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -356,10 +356,9 @@ impl Engine {
|
||||||
|
|
||||||
// Commit a password change the link layer derived off-thread, then notice the user.
|
// Commit a password change the link layer derived off-thread, then notice the user.
|
||||||
pub fn complete_password_change(&mut self, account: &str, creds: Option<db::Credentials>, agent: &str, uid: &str) -> Vec<NetAction> {
|
pub fn complete_password_change(&mut self, account: &str, creds: Option<db::Credentials>, agent: &str, uid: &str) -> Vec<NetAction> {
|
||||||
let lang = self.lang_for_account(account);
|
|
||||||
let text = match creds.and_then(|c| self.db.set_credentials(account, c).ok()) {
|
let text = match creds.and_then(|c| self.db.set_credentials(account, c).ok()) {
|
||||||
Some(()) => echo_api::render(&lang, "Your password for \x02{account}\x02 has been changed.", &[("account", account.to_string())]),
|
Some(()) => format!("Your password for \x02{account}\x02 has been changed."),
|
||||||
None => echo_api::render(&lang, "Sorry, that didn't work. Please try again in a moment.", &[]),
|
None => "Sorry, that didn't work. Please try again in a moment.".to_string(),
|
||||||
};
|
};
|
||||||
vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }]
|
vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,17 +51,12 @@ fn make_nonce() -> String {
|
||||||
STANDARD.encode(b)
|
STANDARD.encode(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proof of holding the shared secret: HMAC-SHA256(secret, direction ‖ nonce). The
|
// Proof of holding the shared secret: HMAC-SHA256(secret, the peer's nonce). The
|
||||||
// secret itself never crosses the wire — the peer verifies this against the nonce
|
// secret itself never crosses the wire — the peer verifies this against the nonce
|
||||||
// it chose. `direction` ("L" for the listener's proof, "D" for the dialer's) is
|
// it chose, so an eavesdropper (or a peer with the wrong secret) can't reproduce
|
||||||
// mixed in so the two sides' proofs are over DIFFERENT inputs: a peer can neither
|
// it and can't replay it on a later connection (fresh nonce each time).
|
||||||
// reflect our own nonce+proof back to us, nor use a second connection as an oracle
|
fn auth_proof(secret: &str, nonce: &str) -> String {
|
||||||
// to have us sign the value it needs — both would need the other direction's
|
|
||||||
// label, which only the other role ever produces.
|
|
||||||
fn auth_proof(secret: &str, direction: &str, nonce: &str) -> String {
|
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
|
||||||
mac.update(direction.as_bytes());
|
|
||||||
mac.update(b"\0");
|
|
||||||
mac.update(nonce.as_bytes());
|
mac.update(nonce.as_bytes());
|
||||||
STANDARD.encode(mac.finalize().into_bytes())
|
STANDARD.encode(mac.finalize().into_bytes())
|
||||||
}
|
}
|
||||||
|
|
@ -146,14 +141,11 @@ async fn listen(bind: String, engine: Shared, secret: String, origin: String, ou
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit; // released when the session ends
|
let _permit = permit; // released when the session ends
|
||||||
let served = match acceptor {
|
let served = match acceptor {
|
||||||
// Bound the TLS handshake too, so a peer that opens the socket but
|
Some(acc) => match acc.accept(stream).await {
|
||||||
// never finishes negotiating can't pin its session permit forever.
|
Ok(tls) => session(tls, engine, secret, origin, outbound).await,
|
||||||
Some(acc) => match tokio::time::timeout(HANDSHAKE_TIMEOUT, acc.accept(stream)).await {
|
Err(e) => return tracing::debug!(%e, %addr, "gossip tls accept failed"),
|
||||||
Ok(Ok(tls)) => session(tls, engine, secret, origin, outbound, true).await,
|
|
||||||
Ok(Err(e)) => return tracing::debug!(%e, %addr, "gossip tls accept failed"),
|
|
||||||
Err(_) => return tracing::debug!(%addr, "gossip tls accept timed out"),
|
|
||||||
},
|
},
|
||||||
None => session(stream, engine, secret, origin, outbound, true).await,
|
None => session(stream, engine, secret, origin, outbound).await,
|
||||||
};
|
};
|
||||||
if let Err(e) = served {
|
if let Err(e) = served {
|
||||||
tracing::debug!(%e, "gossip session ended");
|
tracing::debug!(%e, "gossip session ended");
|
||||||
|
|
@ -171,12 +163,12 @@ async fn dial(peer: Peer, engine: Shared, secret: String, origin: String, outbou
|
||||||
let served = match &connector {
|
let served = match &connector {
|
||||||
Some(conn) => match ServerName::try_from(peer.name.clone()) {
|
Some(conn) => match ServerName::try_from(peer.name.clone()) {
|
||||||
Ok(name) => match conn.connect(name, stream).await {
|
Ok(name) => match conn.connect(name, stream).await {
|
||||||
Ok(tls) => session(tls, engine.clone(), secret.clone(), origin.clone(), outbound.clone(), false).await,
|
Ok(tls) => session(tls, engine.clone(), secret.clone(), origin.clone(), outbound.clone()).await,
|
||||||
Err(e) => Err(anyhow::anyhow!("tls connect: {e}")),
|
Err(e) => Err(anyhow::anyhow!("tls connect: {e}")),
|
||||||
},
|
},
|
||||||
Err(e) => Err(anyhow::anyhow!("bad peer TLS name {:?}: {e}", peer.name)),
|
Err(e) => Err(anyhow::anyhow!("bad peer TLS name {:?}: {e}", peer.name)),
|
||||||
},
|
},
|
||||||
None => session(stream, engine.clone(), secret.clone(), origin.clone(), outbound.clone(), false).await,
|
None => session(stream, engine.clone(), secret.clone(), origin.clone(), outbound.clone()).await,
|
||||||
};
|
};
|
||||||
if let Err(e) = served {
|
if let Err(e) = served {
|
||||||
tracing::debug!(%e, addr = %peer.addr, "gossip session ended");
|
tracing::debug!(%e, addr = %peer.addr, "gossip session ended");
|
||||||
|
|
@ -235,7 +227,7 @@ async fn read_handshake<R: AsyncBufRead + Unpin>(reader: &mut R) -> Option<Msg>
|
||||||
}
|
}
|
||||||
|
|
||||||
// One peer connection: authenticate, then run anti-entropy until it drops.
|
// One peer connection: authenticate, then run anti-entropy until it drops.
|
||||||
async fn session<S>(stream: S, engine: Shared, secret: String, origin: String, outbound: Outbound, listener: bool) -> anyhow::Result<()>
|
async fn session<S>(stream: S, engine: Shared, secret: String, origin: String, outbound: Outbound) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
S: AsyncRead + AsyncWrite + Send + 'static,
|
S: AsyncRead + AsyncWrite + Send + 'static,
|
||||||
{
|
{
|
||||||
|
|
@ -254,12 +246,10 @@ where
|
||||||
|
|
||||||
// Handshake: a mutual challenge-response over the shared secret, so the secret
|
// Handshake: a mutual challenge-response over the shared secret, so the secret
|
||||||
// itself never crosses the wire. Each side sends a random nonce, then proves it
|
// itself never crosses the wire. Each side sends a random nonce, then proves it
|
||||||
// holds the secret by returning HMAC(secret, its-role ‖ the peer's nonce); each
|
// holds the secret by returning HMAC(secret, the peer's nonce); each verifies
|
||||||
// verifies the peer's proof against the nonce it chose and the peer's role. The
|
// the peer's proof against the nonce it chose. A wrong secret (or an
|
||||||
// per-role label (listener "L" vs dialer "D") is what stops a reflection or
|
// eavesdropper) can't produce a valid proof, and a fresh nonce per link stops
|
||||||
// oracle attack — without it, a peer with no secret could bounce our own
|
// replay.
|
||||||
// nonce+proof back and authenticate. A fresh nonce per link stops replay.
|
|
||||||
let (my_dir, peer_dir) = if listener { ("L", "D") } else { ("D", "L") };
|
|
||||||
let my_nonce = make_nonce();
|
let my_nonce = make_nonce();
|
||||||
let _ = send(&tx, &Msg::Hello { origin, nonce: my_nonce.clone() }).await;
|
let _ = send(&tx, &Msg::Hello { origin, nonce: my_nonce.clone() }).await;
|
||||||
let peer_nonce = match read_handshake(&mut reader).await {
|
let peer_nonce = match read_handshake(&mut reader).await {
|
||||||
|
|
@ -269,15 +259,9 @@ where
|
||||||
anyhow::bail!("peer failed to complete the handshake");
|
anyhow::bail!("peer failed to complete the handshake");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// A peer echoing our own nonce back has nothing to prove — refuse it outright
|
let _ = send(&tx, &Msg::Auth { proof: auth_proof(&secret, &peer_nonce) }).await;
|
||||||
// (the direction labels already defeat reflection; this is belt-and-braces).
|
|
||||||
if peer_nonce == my_nonce {
|
|
||||||
writer.abort();
|
|
||||||
anyhow::bail!("gossip peer reused our nonce");
|
|
||||||
}
|
|
||||||
let _ = send(&tx, &Msg::Auth { proof: auth_proof(&secret, my_dir, &peer_nonce) }).await;
|
|
||||||
match read_handshake(&mut reader).await {
|
match read_handshake(&mut reader).await {
|
||||||
Some(Msg::Auth { proof }) if ct_str_eq(&proof, &auth_proof(&secret, peer_dir, &my_nonce)) => {}
|
Some(Msg::Auth { proof }) if ct_str_eq(&proof, &auth_proof(&secret, &my_nonce)) => {}
|
||||||
_ => {
|
_ => {
|
||||||
writer.abort();
|
writer.abort();
|
||||||
anyhow::bail!("bad gossip handshake");
|
anyhow::bail!("bad gossip handshake");
|
||||||
|
|
@ -378,15 +362,6 @@ mod tests {
|
||||||
use crate::engine::db::Db;
|
use crate::engine::db::Db;
|
||||||
use echo_nickserv::NickServ;
|
use echo_nickserv::NickServ;
|
||||||
|
|
||||||
// The listener's and the dialer's proof over the same nonce MUST differ, so a
|
|
||||||
// secretless peer can't reflect one side's proof (or oracle it on a second
|
|
||||||
// connection) as the other side's expected value.
|
|
||||||
#[test]
|
|
||||||
fn handshake_proof_is_direction_separated() {
|
|
||||||
assert_ne!(auth_proof("s3cret", "L", "abc"), auth_proof("s3cret", "D", "abc"));
|
|
||||||
assert_eq!(auth_proof("s3cret", "L", "abc"), auth_proof("s3cret", "L", "abc"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn engine(origin: &str, tag: &str) -> (Shared, Outbound) {
|
fn engine(origin: &str, tag: &str) -> (Shared, Outbound) {
|
||||||
let path = std::env::temp_dir().join(format!("echo-gossip-{tag}.jsonl"));
|
let path = std::env::temp_dir().join(format!("echo-gossip-{tag}.jsonl"));
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
@ -407,8 +382,8 @@ mod tests {
|
||||||
a.lock().await.test_register("alice");
|
a.lock().await.test_register("alice");
|
||||||
|
|
||||||
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
||||||
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true));
|
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx));
|
||||||
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false));
|
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx));
|
||||||
|
|
||||||
let mut converged = false;
|
let mut converged = false;
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
|
|
@ -431,8 +406,8 @@ mod tests {
|
||||||
let (b, btx) = engine("B", "push-b");
|
let (b, btx) = engine("B", "push-b");
|
||||||
|
|
||||||
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
||||||
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true));
|
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx));
|
||||||
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false));
|
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx));
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await; // let both subscribe
|
tokio::time::sleep(Duration::from_millis(200)).await; // let both subscribe
|
||||||
|
|
||||||
a.lock().await.test_register("late");
|
a.lock().await.test_register("late");
|
||||||
|
|
@ -460,8 +435,8 @@ mod tests {
|
||||||
b.lock().await.test_register_pw("alice", "from-b");
|
b.lock().await.test_register_pw("alice", "from-b");
|
||||||
|
|
||||||
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
||||||
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true));
|
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx));
|
||||||
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false));
|
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx));
|
||||||
|
|
||||||
let mut converged = false;
|
let mut converged = false;
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
|
|
@ -487,8 +462,8 @@ mod tests {
|
||||||
a.lock().await.test_register_channel("#secret", "alice");
|
a.lock().await.test_register_channel("#secret", "alice");
|
||||||
|
|
||||||
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
||||||
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true));
|
let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx));
|
||||||
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false));
|
let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx));
|
||||||
|
|
||||||
// Wait for the account to converge (proves the link works), then assert
|
// Wait for the account to converge (proves the link works), then assert
|
||||||
// the channel never crossed it.
|
// the channel never crossed it.
|
||||||
|
|
@ -517,8 +492,8 @@ mod tests {
|
||||||
a.lock().await.test_register("alice");
|
a.lock().await.test_register("alice");
|
||||||
|
|
||||||
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
let (ca, cb) = tokio::io::duplex(64 * 1024);
|
||||||
let sa = tokio::spawn(session(ca, a.clone(), "right".into(), "A".into(), atx, true));
|
let sa = tokio::spawn(session(ca, a.clone(), "right".into(), "A".into(), atx));
|
||||||
let sb = tokio::spawn(session(cb, b.clone(), "wrong".into(), "B".into(), btx, false));
|
let sb = tokio::spawn(session(cb, b.clone(), "wrong".into(), "B".into(), btx));
|
||||||
|
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(1), async {
|
let _ = tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
let _ = sa.await;
|
let _ = sa.await;
|
||||||
|
|
@ -563,8 +538,8 @@ mod tests {
|
||||||
let (sside, cside) = tokio::io::duplex(64 * 1024);
|
let (sside, cside) = tokio::io::duplex(64 * 1024);
|
||||||
let name = ServerName::try_from("echo").unwrap();
|
let name = ServerName::try_from("echo").unwrap();
|
||||||
let (server, client) = tokio::join!(acceptor.accept(sside), connector.connect(name, cside));
|
let (server, client) = tokio::join!(acceptor.accept(sside), connector.connect(name, cside));
|
||||||
let sa = tokio::spawn(session(server.expect("tls accept"), a.clone(), "s3cret".into(), "A".into(), atx, true));
|
let sa = tokio::spawn(session(server.expect("tls accept"), a.clone(), "s3cret".into(), "A".into(), atx));
|
||||||
let sb = tokio::spawn(session(client.expect("tls connect"), b.clone(), "s3cret".into(), "B".into(), btx, false));
|
let sb = tokio::spawn(session(client.expect("tls connect"), b.clone(), "s3cret".into(), "B".into(), btx));
|
||||||
|
|
||||||
let mut converged = false;
|
let mut converged = false;
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
|
|
|
||||||
17
src/link.rs
17
src/link.rs
|
|
@ -55,19 +55,20 @@ fn redact(line: &str) -> Cow<'_, str> {
|
||||||
let after = p + kw.len();
|
let after = p + kw.len();
|
||||||
if let Some(c) = line[after..].find(" :") {
|
if let Some(c) = line[after..].find(" :") {
|
||||||
let tstart = after + c + 2;
|
let tstart = after + c + 2;
|
||||||
// Tokenise the trailer exactly like the dispatcher (`split_whitespace`),
|
let mut words = line[tstart..].split(' ');
|
||||||
// so a TAB or a leading/collapsed space can't sneak a credential past
|
let first = words.next().unwrap_or("");
|
||||||
// redaction while still reaching the command handler.
|
let mut cmd = first.to_ascii_lowercase();
|
||||||
let mut words = line[tstart..].split_whitespace();
|
let mut argstart = tstart + first.len();
|
||||||
let mut cmd = words.next().unwrap_or("").to_ascii_lowercase();
|
|
||||||
// "NS <cmd> ..." / "NICKSERV <cmd> ..." — unwrap one level.
|
// "NS <cmd> ..." / "NICKSERV <cmd> ..." — unwrap one level.
|
||||||
if matches!(cmd.as_str(), "ns" | "nickserv") {
|
if matches!(cmd.as_str(), "ns" | "nickserv") {
|
||||||
cmd = words.next().unwrap_or("").to_ascii_lowercase();
|
if let Some(w2) = words.next() {
|
||||||
|
cmd = w2.to_ascii_lowercase();
|
||||||
|
argstart += 1 + w2.len();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let is_set_password = cmd == "set"
|
let is_set_password = cmd == "set"
|
||||||
&& words.next().map(|w| w.eq_ignore_ascii_case("password")).unwrap_or(false);
|
&& words.next().map(|w| w.eq_ignore_ascii_case("password")).unwrap_or(false);
|
||||||
// Over-redact the whole trailer when the command is credential-bearing.
|
if (SECRET_CMDS.contains(&cmd.as_str()) || is_set_password) && argstart < line.len() {
|
||||||
if SECRET_CMDS.contains(&cmd.as_str()) || is_set_password {
|
|
||||||
return format!("{} :[REDACTED]", line[..tstart].trim_end_matches(" :")).into();
|
return format!("{} :[REDACTED]", line[..tstart].trim_end_matches(" :")).into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue