C12.4: mail credentials and OAuth sign-in

Passwords and refresh tokens go only to the system keychain; a refused
keychain keeps them in memory until exit and says why. Gmail and
Microsoft sign in by authorization code with S256 PKCE on a loopback
redirect; refresh tokens are kept, and revoked grants ask to sign in
again. Client IDs come from the build (env or oauth.env). ureq trace
records never reach the log.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude committed 2026-10-19 18:59:56 -04:00
1 parent 7c5bf48589
commit bd68382921
19 files changed
+1065 -6

No files matched your search

Generated
+3
View File
@@ -2135,9 +2135,11 @@ version = "0.1.8"
dependencies = [
"base64 0.23.1",
"ely-platform",
"form_urlencoded",
"getrandom 0.4.3",
"html5ever",
"imap-proto",
"keyring",
"log",
"mail-builder",
"mail-parser",
@@ -2154,6 +2156,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-rustls",
"ureq",
]
[[package]]
+1 -1
View File
@@ -320,4 +320,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 `<?>`);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。
- 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。
- 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。
+10
View File
@@ -8,6 +8,7 @@ publish.workspace = true
[dependencies]
base64.workspace = true
form_urlencoded = "1"
getrandom.workspace = true
html5ever.workspace = true
imap-proto.workspace = true
@@ -25,6 +26,15 @@ sha2.workspace = true
smtp-proto = "0.2"
tokio.workspace = true
tokio-rustls.workspace = true
ureq.workspace = true
[dependencies.keyring]
version = "3"
features = ["apple-native", "windows-native"]
[target.'cfg(target_os = "linux")'.dependencies.keyring]
version = "3"
features = ["linux-native-sync-persistent"]
[dev-dependencies]
ely-platform.workspace = true
+39
View File
@@ -0,0 +1,39 @@
use std::{env, fs, io::ErrorKind, path::Path};
const KEYS: [&str; 3] = [
"ELY_GOOGLE_CLIENT_ID",
"ELY_GOOGLE_CLIENT_SECRET",
"ELY_MICROSOFT_CLIENT_ID",
];
/// Client IDs: CI secrets, else the local oauth.env.
fn main() {
let file = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../oauth.env");
println!("cargo:rerun-if-changed={}", file.display());
for key in KEYS {
println!("cargo:rerun-if-env-changed={key}");
}
let text = match fs::read_to_string(&file) {
Ok(text) => text,
Err(err) if err.kind() == ErrorKind::NotFound => return,
Err(err) => panic!("oauth.env unreadable: {err}"),
};
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
panic!("oauth.env line {}: expected KEY=value", number + 1);
};
let key = key.trim();
assert!(
KEYS.contains(&key),
"oauth.env line {}: unknown key {key}",
number + 1
);
if env::var_os(key).is_none() {
println!("cargo:rustc-env={key}={}", value.trim());
}
}
}
+12
View File
@@ -25,6 +25,14 @@ pub enum Error {
Store(String),
/// The store refuses writes; the reason says why.
ReadOnly(String),
/// The system credential store failed.
Vault(String),
/// This build has no client ID for the provider.
Unavailable(&'static str),
/// Sign-in did not finish; the reason says why.
SignIn(String),
/// The provider revoked the refresh token.
SignInAgain(String),
}
impl fmt::Display for Error {
@@ -40,6 +48,10 @@ impl fmt::Display for Error {
Self::Mime(text) => write!(f, "unreadable message: {text}"),
Self::Store(text) => write!(f, "local store failed: {text}"),
Self::ReadOnly(text) => write!(f, "local store is read-only: {text}"),
Self::Vault(text) => write!(f, "credential store failed: {text}"),
Self::Unavailable(name) => write!(f, "{name} sign-in is not in this build"),
Self::SignIn(text) => write!(f, "sign-in failed: {text}"),
Self::SignInAgain(text) => write!(f, "sign in again: {text}"),
}
}
}
+3
View File
@@ -4,9 +4,11 @@ mod error;
mod imap;
mod mime;
mod net;
pub mod oauth;
mod pop;
mod smtp;
mod store;
mod vault;
mod wire;
pub use error::{Error, Result};
@@ -17,3 +19,4 @@ pub use pop::Pop;
pub use smtp::Smtp;
pub use store::{AccountId, Fetched, Key, MailboxId, MessageId, Sizes, Store, Stored};
pub use tokio_rustls::TlsConnector;
pub use vault::{Backend, Kept, Keyring, Vault};
+277
View File
@@ -0,0 +1,277 @@
use std::time::SystemTime;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
use tokio::net::TcpListener;
use crate::{Error, Result};
mod loopback;
mod token;
/// One OAuth authorization server and this app's client.
pub struct Provider {
pub name: &'static str,
pub authorize: String,
pub token: String,
pub client_id: String,
/// Google's desktop clients carry one; Microsoft's do not.
pub client_secret: Option<String>,
pub scope: String,
/// Authorize parameters this server adds.
pub extra: &'static [(&'static str, &'static str)],
/// The registered loopback host.
pub redirect_host: &'static str,
}
/// Tokens from one exchange or refresh.
pub struct Tokens {
pub access: String,
pub expires_at: SystemTime,
/// A new refresh token; store it, drop the old one.
pub refresh: Option<String>,
/// The signed-in address, from the ID token.
pub email: Option<String>,
}
/// Gmail: IMAP, POP3 and SMTP under one scope.
pub fn google() -> Result<Provider> {
Ok(gmail(
built("google", option_env!("ELY_GOOGLE_CLIENT_ID"))?,
built("google", option_env!("ELY_GOOGLE_CLIENT_SECRET"))?,
))
}
fn gmail(client_id: String, secret: String) -> Provider {
Provider {
name: "google",
authorize: "https://accounts.google.com/o/oauth2/v2/auth".into(),
token: "https://oauth2.googleapis.com/token".into(),
client_id,
client_secret: Some(secret),
scope: "https://mail.google.com/ openid email".into(),
// Refresh token on every consent.
extra: &[("access_type", "offline"), ("prompt", "consent")],
redirect_host: "127.0.0.1",
}
}
/// Outlook.com and Microsoft 365.
pub fn microsoft() -> Result<Provider> {
Ok(outlook(built(
"microsoft",
option_env!("ELY_MICROSOFT_CLIENT_ID"),
)?))
}
fn outlook(client_id: String) -> Provider {
Provider {
name: "microsoft",
authorize: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into(),
token: "https://login.microsoftonline.com/common/oauth2/v2.0/token".into(),
client_id,
client_secret: None,
scope: [
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/POP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access openid email profile",
]
.join(" "),
extra: &[],
// Port ignored only for localhost.
redirect_host: "localhost",
}
}
fn built(name: &'static str, value: Option<&str>) -> Result<String> {
match value {
Some(value) if !value.is_empty() => Ok(value.to_owned()),
_ => Err(Error::Unavailable(name)),
}
}
/// A sign-in waiting for the browser to come back.
pub struct SignIn {
provider: Provider,
listener: TcpListener,
url: String,
redirect: String,
state: String,
verifier: String,
}
impl SignIn {
/// Opens the loopback port and builds the browser URL.
pub async fn start(provider: Provider, hint: &str) -> Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?;
let port = listener
.local_addr()
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?
.port();
let redirect = format!("http://{}:{port}", provider.redirect_host);
let state = random();
let verifier = random();
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let mut query = vec![
("client_id", provider.client_id.as_str()),
("response_type", "code"),
("redirect_uri", redirect.as_str()),
("scope", provider.scope.as_str()),
("state", state.as_str()),
("code_challenge", challenge.as_str()),
("code_challenge_method", "S256"),
];
query.extend_from_slice(provider.extra);
if !hint.is_empty() {
query.push(("login_hint", hint));
}
let url = format!("{}?{}", provider.authorize, encode(&query));
log::info!("oauth: {} sign-in waits on 127.0.0.1:{port}", provider.name);
Ok(Self {
provider,
listener,
url,
redirect,
state,
verifier,
})
}
/// The page to open in the system browser.
pub fn url(&self) -> &str {
&self.url
}
/// Waits for the redirect, then trades the code for tokens.
pub async fn finish(self) -> Result<Tokens> {
let code = loopback::code(&self.listener, &self.state).await?;
log::info!("oauth: {} returned a code", self.provider.name);
drop(self.listener);
let mut form = vec![
("grant_type", "authorization_code".to_owned()),
("client_id", self.provider.client_id.clone()),
("code", code),
("code_verifier", self.verifier),
("redirect_uri", self.redirect),
];
if let Some(secret) = &self.provider.client_secret {
form.push(("client_secret", secret.clone()));
}
let tokens = token::post(&self.provider, form, false).await?;
if tokens.refresh.is_none() {
return Err(Error::SignIn("no refresh token granted".into()));
}
Ok(tokens)
}
}
/// A fresh access token; a refused refresh needs sign-in again.
pub async fn refresh(provider: &Provider, refresh: &str) -> Result<Tokens> {
let mut form = vec![
("grant_type", "refresh_token".to_owned()),
("client_id", provider.client_id.clone()),
("refresh_token", refresh.to_owned()),
];
if let Some(secret) = &provider.client_secret {
form.push(("client_secret", secret.clone()));
}
token::post(provider, form, true).await
}
/// 32 random bytes, base64url: a PKCE verifier or a state.
fn random() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("system randomness");
URL_SAFE_NO_PAD.encode(bytes)
}
fn encode(pairs: &[(&str, &str)]) -> String {
pairs
.iter()
.map(|(key, value)| format!("{}={}", escape(key), escape(value)))
.collect::<Vec<_>>()
.join("&")
}
fn escape(text: &str) -> String {
let mut out = String::new();
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_client_id_makes_the_provider_unavailable() {
assert!(matches!(
built("google", None),
Err(Error::Unavailable("google"))
));
assert!(matches!(
built("google", Some("")),
Err(Error::Unavailable("google"))
));
assert_eq!(built("google", Some("id-1")).unwrap(), "id-1");
}
async fn asked(provider: Provider) -> Vec<(String, String)> {
let sign_in = SignIn::start(provider, "").await.unwrap();
let (_, query) = sign_in.url().split_once('?').unwrap();
form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect()
}
fn value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
#[tokio::test]
async fn gmail_asks_for_a_refresh_token_every_time() {
let pairs = asked(gmail("id".into(), "s".into())).await;
assert_eq!(value(&pairs, "access_type"), Some("offline"));
assert_eq!(value(&pairs, "prompt"), Some("consent"));
assert!(
value(&pairs, "scope")
.unwrap()
.contains("https://mail.google.com/")
);
}
#[tokio::test]
async fn outlook_asks_for_offline_access_and_the_address_claims() {
let pairs = asked(outlook("id".into())).await;
let scope: Vec<&str> = value(&pairs, "scope").unwrap().split(' ').collect();
for needed in ["offline_access", "openid", "email", "profile"] {
assert!(scope.contains(&needed), "{needed}");
}
assert_eq!(value(&pairs, "access_type"), None);
assert!(
value(&pairs, "redirect_uri")
.unwrap()
.starts_with("http://localhost:")
);
}
#[test]
fn query_values_are_escaped() {
assert_eq!(
encode(&[("scope", "a b/c"), ("x", "é~")]),
"scope=a%20b%2Fc&x=%C3%A9~"
);
}
}
@@ -0,0 +1,85 @@
use std::time::Duration;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
use crate::{Error, Result};
const LIMIT: usize = 8 * 1024;
const READ: Duration = Duration::from_secs(10);
const PAGE: &str = "ElyMail: sign-in finished. You can close this tab.";
/// Serves redirects until one carries a code or error.
pub(super) async fn code(listener: &TcpListener, state: &str) -> Result<String> {
loop {
let (mut stream, _) = listener
.accept()
.await
.map_err(|err| Error::SignIn(format!("loopback accept failed: {err}")))?;
let Some(target) = request(&mut stream).await else {
continue;
};
let query = target.split_once('?').map_or("", |(_, query)| query);
let pairs: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let field = |name: &str| {
pairs
.iter()
.find(|(key, _)| key == name)
.map(|(_, v)| v.clone())
};
if field("code").is_none() && field("error").is_none() {
reply(&mut stream, "404 Not Found", "").await;
continue;
}
reply(&mut stream, "200 OK", PAGE).await;
if field("state").as_deref() != Some(state) {
return Err(Error::SignIn("redirect state does not match".into()));
}
if let Some(error) = field("error") {
return Err(Error::SignIn(match field("error_description") {
Some(text) => format!("{error}: {text}"),
None => error,
}));
}
return field("code").ok_or_else(|| Error::SignIn("redirect without a code".into()));
}
}
/// The request target of one GET, None for anything else.
async fn request(stream: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut chunk = [0u8; 1024];
while !head.windows(4).any(|window| window == b"\r\n\r\n") {
let read = tokio::time::timeout(READ, stream.read(&mut chunk)).await;
match read {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
Ok(Ok(count)) => head.extend_from_slice(&chunk[..count]),
}
if head.len() > LIMIT {
log::warn!("oauth: loopback request over {LIMIT} bytes dropped");
return None;
}
}
let line = head.split(|byte| *byte == b'\n').next()?;
let line = std::str::from_utf8(line).ok()?.trim_end();
let mut parts = line.split(' ');
match (parts.next(), parts.next()) {
(Some("GET"), Some(target)) => Some(target.to_owned()),
_ => None,
}
}
async fn reply(stream: &mut TcpStream, status: &str, body: &str) {
let text = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
if let Err(err) = stream.write_all(text.as_bytes()).await {
log::warn!("oauth: loopback reply failed: {err}");
}
}
+110
View File
@@ -0,0 +1,110 @@
use std::time::{Duration, SystemTime};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::Deserialize;
use ureq::Agent;
use super::{Provider, Tokens};
use crate::{Error, Result};
const TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Deserialize)]
struct Granted {
access_token: String,
expires_in: u64,
refresh_token: Option<String>,
id_token: Option<String>,
}
#[derive(Deserialize)]
struct Refused {
error: String,
error_description: Option<String>,
}
#[derive(Deserialize)]
struct Claims {
email: Option<String>,
preferred_username: Option<String>,
}
/// Posts to the token endpoint off the async thread.
pub(super) async fn post(
provider: &Provider,
form: Vec<(&'static str, String)>,
refreshing: bool,
) -> Result<Tokens> {
let url = provider.token.clone();
let name = provider.name;
let started = SystemTime::now();
let (status, body) = tokio::task::spawn_blocking(move || send(&url, &form))
.await
.map_err(|err| Error::SignIn(format!("token request stopped: {err}")))??;
if status == 200 {
let tokens = granted(&body, started)?;
log::info!(
"oauth: {name} token granted, refresh {}",
tokens.refresh.is_some()
);
return Ok(tokens);
}
let refused: Refused = serde_json::from_str(&body)
.map_err(|_| Error::SignIn(format!("token endpoint answered {status}")))?;
let reason = match refused.error_description {
Some(text) => format!("{}: {text}", refused.error),
None => refused.error.clone(),
};
log::warn!("oauth: {name} token refused ({status}, {})", refused.error);
if refreshing
&& matches!(
refused.error.as_str(),
"invalid_grant" | "interaction_required"
)
{
return Err(Error::SignInAgain(reason));
}
Err(Error::SignIn(reason))
}
fn send(url: &str, form: &[(&'static str, String)]) -> Result<(u16, String)> {
let agent: Agent = Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
.build()
.into();
let mut response = agent
.post(url)
.send_form(form.iter().map(|(key, value)| (*key, value.as_str())))
.map_err(|err| Error::SignIn(format!("token endpoint unreachable: {err}")))?;
let status = response.status().as_u16();
let body = response
.body_mut()
.read_to_string()
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
Ok((status, body))
}
fn granted(body: &str, started: SystemTime) -> Result<Tokens> {
let granted: Granted = serde_json::from_str(body)
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
let email = match &granted.id_token {
Some(token) => email(token)?,
None => None,
};
Ok(Tokens {
access: granted.access_token,
expires_at: started + Duration::from_secs(granted.expires_in),
refresh: granted.refresh_token,
email,
})
}
/// The address claim; the token came straight from the endpoint.
fn email(token: &str) -> Result<Option<String>> {
let unreadable = || Error::SignIn("ID token unreadable".into());
let payload = token.split('.').nth(1).ok_or_else(unreadable)?;
let bytes = URL_SAFE_NO_PAD.decode(payload).map_err(|_| unreadable())?;
let claims: Claims = serde_json::from_slice(&bytes).map_err(|_| unreadable())?;
Ok(claims.email.or(claims.preferred_username))
}
+106
View File
@@ -0,0 +1,106 @@
use std::{collections::HashMap, sync::Mutex};
use crate::{Error, Result};
/// A secret store backend, or a test double.
pub trait Backend: Send + Sync {
fn get(&self, name: &str) -> std::result::Result<Option<String>, String>;
fn set(&self, name: &str, secret: &str) -> std::result::Result<(), String>;
fn delete(&self, name: &str) -> std::result::Result<(), String>;
}
/// The system credential store (§18.2).
pub struct Keyring;
const SERVICE: &str = "ElyMail";
impl Backend for Keyring {
fn get(&self, name: &str) -> std::result::Result<Option<String>, String> {
match entry(name)?.get_password() {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(err.to_string()),
}
}
fn set(&self, name: &str, secret: &str) -> std::result::Result<(), String> {
entry(name)?
.set_password(secret)
.map_err(|err| err.to_string())
}
fn delete(&self, name: &str) -> std::result::Result<(), String> {
match entry(name)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(err.to_string()),
}
}
}
fn entry(name: &str) -> std::result::Result<keyring::Entry, String> {
keyring::Entry::new(SERVICE, name).map_err(|err| err.to_string())
}
/// Where a kept secret lives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Kept {
System,
/// The system store refused; gone when the app quits.
Session(String),
}
/// Passwords and refresh tokens: system store, else memory.
pub struct Vault {
backend: Box<dyn Backend>,
session: Mutex<HashMap<String, String>>,
}
impl Vault {
pub fn system() -> Self {
Self::new(Box::new(Keyring))
}
pub fn new(backend: Box<dyn Backend>) -> Self {
Self {
backend,
session: Mutex::new(HashMap::new()),
}
}
/// Stores a secret; never on disk outside the system store.
pub fn put(&self, name: &str, secret: &str) -> Kept {
self.session().remove(name);
match self.backend.set(name, secret) {
Ok(()) => {
log::info!("vault: {name} kept in the system store");
Kept::System
}
Err(reason) => {
log::warn!("vault: system store refused {name}, this session only: {reason}");
self.session().insert(name.to_owned(), secret.to_owned());
Kept::Session(reason)
}
}
}
/// The secret, None when never stored.
pub fn get(&self, name: &str) -> Result<Option<String>> {
if let Some(secret) = self.session().get(name) {
return Ok(Some(secret.clone()));
}
self.backend
.get(name)
.map_err(|reason| Error::Vault(format!("reading {name}: {reason}")))
}
pub fn remove(&self, name: &str) -> Result<()> {
self.session().remove(name);
self.backend
.delete(name)
.map_err(|reason| Error::Vault(format!("deleting {name}: {reason}")))
}
fn session(&self) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
self.session.lock().expect("vault lock")
}
}
+143
View File
@@ -0,0 +1,143 @@
mod oauth_fake;
use std::{
thread,
time::{Duration, SystemTime},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use ely_mail_engine::{
Error,
oauth::{SignIn, refresh},
};
use oauth_fake::{browse, endpoint, id_token, provider, query, signed_in};
use sha2::{Digest, Sha256};
#[tokio::test]
async fn sign_in_exchanges_the_code_with_pkce() {
let body = format!(
r#"{{"access_token":"at-1","expires_in":3600,"refresh_token":"rt-1","id_token":"{}"}}"#,
id_token(r#"{"email":"ana@example.com"}"#)
);
let (token, forms) = endpoint(200, body);
let sign_in = SignIn::start(provider(token, Some("shh")), "ana@example.com")
.await
.unwrap();
let asked = query(sign_in.url());
assert!(sign_in.url().starts_with("https://auth.example/authorize?"));
assert_eq!(asked["client_id"], "client-1");
assert_eq!(asked["response_type"], "code");
assert_eq!(asked["code_challenge_method"], "S256");
assert_eq!(asked["login_hint"], "ana@example.com");
assert_eq!(asked["scope"], "mail openid email");
assert_eq!(asked["prompt"], "consent");
let redirect = asked["redirect_uri"].clone();
assert!(redirect.starts_with("http://127.0.0.1:"));
let state = asked["state"].clone();
let browser = thread::spawn(move || {
let ignored = browse(&redirect, "/favicon.ico");
let done = browse(&redirect, &format!("/?code=c%2B1&state={state}"));
(ignored, done, redirect)
});
let before = SystemTime::now();
let tokens = sign_in.finish().await.unwrap();
let (ignored, done, redirect) = browser.join().unwrap();
assert_eq!(ignored, "HTTP/1.1 404 Not Found");
assert_eq!(done, "HTTP/1.1 200 OK");
assert_eq!(tokens.access, "at-1");
assert_eq!(tokens.refresh.as_deref(), Some("rt-1"));
assert_eq!(tokens.email.as_deref(), Some("ana@example.com"));
let left = tokens.expires_at.duration_since(before).unwrap();
assert!(left >= Duration::from_secs(3600) && left < Duration::from_secs(3610));
let form = forms.recv().unwrap();
assert_eq!(form["grant_type"], "authorization_code");
assert_eq!(form["code"], "c+1");
assert_eq!(form["client_secret"], "shh");
assert_eq!(form["redirect_uri"], redirect);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(form["code_verifier"].as_bytes()));
assert_eq!(challenge, asked["code_challenge"]);
}
#[tokio::test]
async fn a_wrong_state_is_refused() {
let sign_in = SignIn::start(provider("http://127.0.0.1:9/token".into(), None), "")
.await
.unwrap();
let asked = query(sign_in.url());
assert!(!asked.contains_key("login_hint"));
let redirect = asked["redirect_uri"].clone();
let browser = thread::spawn(move || browse(&redirect, "/?code=x&state=forged"));
let err = sign_in.finish().await.err().unwrap();
browser.join().unwrap();
assert!(matches!(err, Error::SignIn(text) if text.contains("state")));
}
#[tokio::test]
async fn a_declined_consent_reports_the_reason() {
let sign_in = SignIn::start(provider("http://127.0.0.1:9/token".into(), None), "")
.await
.unwrap();
let asked = query(sign_in.url());
let (redirect, state) = (asked["redirect_uri"].clone(), asked["state"].clone());
let path = format!("/?error=access_denied&error_description=user+said+no&state={state}");
let browser = thread::spawn(move || browse(&redirect, &path));
let err = sign_in.finish().await.err().unwrap();
browser.join().unwrap();
assert!(matches!(err, Error::SignIn(text) if text == "access_denied: user said no"));
}
#[tokio::test]
async fn a_sign_in_without_a_refresh_token_fails() {
let body = r#"{"access_token":"at-1","expires_in":3600}"#.to_owned();
let err = signed_in(body).await.err().unwrap();
assert!(matches!(err, Error::SignIn(text) if text.contains("refresh token")));
}
#[tokio::test]
async fn the_username_stands_in_for_a_missing_email() {
let body = format!(
r#"{{"access_token":"at-1","expires_in":3600,"refresh_token":"rt-1","id_token":"{}"}}"#,
id_token(r#"{"preferred_username":"bo@outlook.com"}"#)
);
let tokens = signed_in(body).await.unwrap();
assert_eq!(tokens.email.as_deref(), Some("bo@outlook.com"));
}
#[tokio::test]
async fn refresh_rotates_the_token() {
let body = r#"{"access_token":"at-2","expires_in":60,"refresh_token":"rt-2"}"#.to_owned();
let (token, forms) = endpoint(200, body);
let tokens = refresh(&provider(token, None), "rt-1").await.unwrap();
assert_eq!(
(tokens.access.as_str(), tokens.refresh.as_deref()),
("at-2", Some("rt-2"))
);
assert_eq!(tokens.email, None);
let form = forms.recv().unwrap();
assert_eq!(form["grant_type"], "refresh_token");
assert_eq!(form["refresh_token"], "rt-1");
assert!(!form.contains_key("client_secret"));
}
#[tokio::test]
async fn a_revoked_refresh_token_needs_sign_in_again() {
for error in ["invalid_grant", "interaction_required"] {
let body = format!(r#"{{"error":"{error}","error_description":"revoked"}}"#);
let (token, _) = endpoint(400, body);
let err = refresh(&provider(token, None), "rt-1").await.err().unwrap();
assert!(
matches!(&err, Error::SignInAgain(text) if text == &format!("{error}: revoked")),
"{err}"
);
}
}
#[tokio::test]
async fn other_refusals_stay_sign_in_errors() {
let (token, _) = endpoint(401, r#"{"error":"invalid_client"}"#.to_owned());
let err = refresh(&provider(token, None), "rt-1").await.err().unwrap();
assert!(matches!(err, Error::SignIn(text) if text == "invalid_client"));
let (token, _) = endpoint(502, "<html>bad gateway</html>".to_owned());
let err = refresh(&provider(token, None), "rt-1").await.err().unwrap();
assert!(matches!(err, Error::SignIn(text) if text.contains("502")));
}
@@ -0,0 +1,99 @@
//! A fake token endpoint and browser for OAuth tests.
#![allow(dead_code)]
use std::{
collections::HashMap,
io::{Read, Write},
net::TcpListener,
sync::mpsc,
thread,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use ely_mail_engine::oauth::{Provider, SignIn, Tokens};
/// A token endpoint that answers once and reports the form.
pub fn endpoint(status: u16, body: String) -> (String, mpsc::Receiver<HashMap<String, String>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/token", listener.local_addr().unwrap());
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut data = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let count = stream.read(&mut chunk).unwrap();
data.extend_from_slice(&chunk[..count]);
let text = String::from_utf8_lossy(&data).to_string();
if let Some((head, rest)) = text.split_once("\r\n\r\n") {
let length: usize = head
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse().unwrap())
})
.unwrap();
if rest.len() >= length {
let form = form_urlencoded::parse(rest.as_bytes())
.into_owned()
.collect();
let _ = sender.send(form);
break;
}
}
}
let reply = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(reply.as_bytes()).unwrap();
});
(url, receiver)
}
pub fn provider(token: String, secret: Option<&str>) -> Provider {
Provider {
name: "test",
authorize: "https://auth.example/authorize".into(),
token,
client_id: "client-1".into(),
client_secret: secret.map(str::to_owned),
scope: "mail openid email".into(),
extra: &[("prompt", "consent")],
redirect_host: "127.0.0.1",
}
}
pub fn id_token(claims: &str) -> String {
format!("e30.{}.sig", URL_SAFE_NO_PAD.encode(claims))
}
pub fn query(url: &str) -> HashMap<String, String> {
let (_, query) = url.split_once('?').unwrap();
form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect()
}
/// The browser following the redirect; returns the status line.
pub fn browse(redirect: &str, path: &str) -> String {
let address = redirect.trim_start_matches("http://");
let mut stream = std::net::TcpStream::connect(address).unwrap();
write!(stream, "GET {path} HTTP/1.1\r\nHost: {address}\r\n\r\n").unwrap();
let mut reply = String::new();
stream.read_to_string(&mut reply).unwrap();
reply.lines().next().unwrap().to_owned()
}
/// Completes a sign-in whose endpoint answers with body.
pub async fn signed_in(body: String) -> ely_mail_engine::Result<Tokens> {
let (token, _) = endpoint(200, body);
let sign_in = SignIn::start(provider(token, None), "").await.unwrap();
let asked = query(sign_in.url());
let (redirect, state) = (asked["redirect_uri"].clone(), asked["state"].clone());
let browser = thread::spawn(move || browse(&redirect, &format!("/?code=c&state={state}")));
let result = sign_in.finish().await;
browser.join().unwrap();
result
}
@@ -0,0 +1,45 @@
//! The real logger at trace, around token requests.
mod oauth_fake;
use ely_mail_engine::oauth::refresh;
use ely_platform::AppId;
use oauth_fake::{endpoint, provider, signed_in};
const SECRET: &str = "PRIVATE_SECRET";
const CODE_TOKEN: &str = "PRIVATE_FIRST";
const ROTATED: &str = "PRIVATE_ROTATED";
#[tokio::test]
async fn trace_logs_keep_no_token_or_client_secret() {
let dir = tempfile::tempdir().unwrap();
// The only test here; no thread reads env.
unsafe { std::env::set_var("ELY_LOG", "trace") };
ely_platform::logging::init_in(AppId::Mail, dir.path()).unwrap();
let body = format!(
r#"{{"access_token":"{CODE_TOKEN}","expires_in":60,"refresh_token":"{CODE_TOKEN}"}}"#
);
signed_in(body).await.unwrap();
let body =
format!(r#"{{"access_token":"{ROTATED}","expires_in":60,"refresh_token":"{ROTATED}"}}"#);
let (token, _) = endpoint(200, body);
refresh(&provider(token, Some(SECRET)), CODE_TOKEN)
.await
.unwrap();
log::logger().flush();
let logged = std::fs::read_to_string(dir.path().join("ely.log")).unwrap();
assert!(logged.contains("oauth: test token granted"), "{logged}");
for secret in [SECRET, CODE_TOKEN, ROTATED, "PRIVATE"] {
assert!(
!logged.contains(secret),
"{secret} reached the log:
{logged}"
);
}
assert!(
!logged.contains(" ureq"),
"HTTP library logged:
{logged}"
);
}
+86
View File
@@ -0,0 +1,86 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use ely_mail_engine::{Backend, Error, Kept, Vault};
#[derive(Clone, Default)]
struct Fake {
saved: Arc<Mutex<HashMap<String, String>>>,
refuse: Option<&'static str>,
}
impl Backend for Fake {
fn get(&self, name: &str) -> Result<Option<String>, String> {
match self.refuse {
Some(reason) => Err(reason.into()),
None => Ok(self.saved.lock().unwrap().get(name).cloned()),
}
}
fn set(&self, name: &str, secret: &str) -> Result<(), String> {
match self.refuse {
Some(reason) => Err(reason.into()),
None => {
self.saved
.lock()
.unwrap()
.insert(name.into(), secret.into());
Ok(())
}
}
}
fn delete(&self, name: &str) -> Result<(), String> {
match self.refuse {
Some(reason) => Err(reason.into()),
None => {
self.saved.lock().unwrap().remove(name);
Ok(())
}
}
}
}
#[test]
fn secrets_go_to_the_system_store() {
let fake = Fake::default();
let vault = Vault::new(Box::new(fake.clone()));
assert_eq!(vault.get("a").unwrap(), None);
assert_eq!(vault.put("a", "rt-1"), Kept::System);
assert_eq!(fake.saved.lock().unwrap()["a"], "rt-1");
assert_eq!(vault.get("a").unwrap().as_deref(), Some("rt-1"));
vault.remove("a").unwrap();
assert!(fake.saved.lock().unwrap().is_empty());
}
#[test]
fn a_refused_store_keeps_the_secret_for_this_session() {
let vault = Vault::new(Box::new(Fake {
refuse: Some("locked"),
..Fake::default()
}));
assert_eq!(vault.put("a", "pw"), Kept::Session("locked".into()));
assert_eq!(vault.get("a").unwrap().as_deref(), Some("pw"));
let err = vault.get("b").err().unwrap();
assert!(matches!(err, Error::Vault(text) if text.contains("locked")));
let err = vault.remove("a").err().unwrap();
assert!(matches!(err, Error::Vault(_)));
assert!(
vault.get("a").is_err(),
"the session copy is gone after remove"
);
}
#[test]
fn a_new_secret_replaces_the_session_copy() {
let fake = Fake {
refuse: Some("locked"),
..Fake::default()
};
let vault = Vault::new(Box::new(fake));
vault.put("a", "old");
assert_eq!(vault.put("a", "new"), Kept::Session("locked".into()));
assert_eq!(vault.get("a").unwrap().as_deref(), Some("new"));
}
+32 -4
View File
@@ -7,7 +7,8 @@ use std::{
};
use anyhow::{Context, Result};
use env_logger::{Builder, Env, Target};
use env_logger::{Builder, Env, Logger, Target};
use log::{Level, Log, Metadata, Record};
use crate::{AppId, data_dir};
@@ -28,6 +29,31 @@ impl Write for Tee {
}
}
/// The HTTP library logs raw bytes, tokens too.
struct Private(Logger);
fn wire(meta: &Metadata) -> bool {
let target = meta.target();
let http = target == "ureq" || target.starts_with("ureq::") || target.starts_with("ureq_proto");
http && meta.level() > Level::Info
}
impl Log for Private {
fn enabled(&self, meta: &Metadata) -> bool {
!wire(meta) && self.0.enabled(meta)
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
self.0.log(record);
}
}
fn flush(&self) {
self.0.flush();
}
}
/// The file logging writes to; none until `init` succeeds.
pub fn path() -> Option<&'static Path> {
LOG_PATH.get().map(PathBuf::as_path)
@@ -66,10 +92,12 @@ pub fn init_in(app: AppId, dir: &Path) -> Result<()> {
.append(true)
.open(&path)
.with_context(|| format!("cannot open {}", path.display()))?;
Builder::from_env(Env::new().filter_or("ELY_LOG", "info"))
let logger = Builder::from_env(Env::new().filter_or("ELY_LOG", "info"))
.target(Target::Pipe(Box::new(Tee(file))))
.try_init()
.context("a logger is already set")?;
.build();
let level = logger.filter();
log::set_boxed_logger(Box::new(Private(logger))).context("a logger is already set")?;
log::set_max_level(level);
LOG_PATH.set(path).expect("the logger was set once");
let previous = panic::take_hook();
+1 -1
View File
@@ -137,7 +137,7 @@
- [x] C12.1 `crates/ely-mail-engine` 与连通(MAIL-01 前半):IMAP、POP3、SMTP 经 TLS(rustls,系统根证书)或 STARTTLS 连接,密码与 XOAUTH2 认证,失败带服务器原话;替身服务器测三协议的登录、拒绝、列邮箱、取信、发信。已做:`Imap`、`Pop`、`Smtp` 三个会话,`Server`(主机、端口、`Security::Tls` 或 `StartTls`,无明文)、`Login`(密码或 OAuth 令牌);系统信任库经 rustls-platform-verifier,测试换入只信测试证书的连接器;每步 60 秒超时;`Error` 分连接、TLS、凭据被拒(服务器原话)、命令被拒、读不懂、超时、发不出(信封地址或登录带换行等)。IMAP 由引擎自写,以 imap-proto 解析(`wire.rs` 收一个个完整回复,含字面量),换下 async-imap(它的 LIST、FETCH 不看结尾的 NO 与 BAD,拒绝成了空结果与“无此 UID”;登录与命令中途的 `* BYE` 被丢掉,只剩断线;trace 日志写出密码与邮件):每条命令看结尾的状态,NO 与 BAD 带原话报出(登录为凭据被拒,其余为命令被拒);问候或任何时候收到 `* BYE`,此后断线即带其原话;非 ASCII 或带换行的参数以字面量发出,等服务器的 `+`;STARTTLS 之后若已有明文字节即拒绝;LIST 里带引号的邮箱名去掉转义(imap-proto 照原样留着),字面量照原样,列出的名字可直接打开;日志只记标签与命令名。POP3 由引擎自写(RFC 1939:问候、STLS、USER/PASS、AUTH XOAUTH2、UIDL、RETR、QUIT),换下 async-pop(它留着点填充、丢末尾 CRLF、吞掉登录后 CAPA 的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码与邮件);STLS 之后若已有明文字节即拒绝;用户名与密码带换行即拒绝。SMTP 的 XOAUTH2 与事务由引擎自走(mail-send 遇错误挑战会重发令牌;它的 DATA 不给首行的点加点,且自加末尾 CRLF):信封地址照 RFC 5321 的 Mailbox 核对(点串或带引号的本地部分,域名或地址字面量;地址或信头有 UTF-8 时,服务器报 SMTPUTF8 才发出并带此参数,否则拒绝(RFC 6531)),不合即拒绝、不发任何命令;DATA 每行开头的点(含首行)加一个点,裸 CR 或 LF 即拒绝。服务器拒绝事务中的一步后发 RSET,会话可再发信。三协议的 XOAUTH2 被拒都以空行答错误挑战、留下服务器最后的原话。34 个测试(`cargo test -p ely-mail-engine`,含以真日志器在 trace 下跑三协议、日志里无密码、令牌与正文),真服务器未连。审查三轮:第 3 轮的两处(带引号的邮箱名、信头的 UTF-8)已修并以测试核对,未再审查(每条至多三轮)
- [x] C12.2 MIME(MAIL-04、05 的基础):mail-parser 读出头、纯文本、HTML、cid 内嵌图片、附件与原始头;mail-builder 写出多部分邮件、附件、Message-ID、In-Reply-To 与 References;样本 EML 往返测试。已做:`read` 得 `Mail`(主题、发件人、收件人、抄送(分组展开)、日期、Message-ID、In-Reply-To、References、纯文本、HTML、`inline`、`attachments`、原始头块);按 MIME 树与所在容器读(不用 mail-parser 摊平的列表):正文只取发件人写的部分(mail-parser 会把 HTML 转成文字或反之顶替缺的一种,不取);alternative 里纯文本与 HTML 各取最后一个(RFC 2046 §5.1.4),其余版本不并入,没有正文的版本(如日历)为附件;related 的根照 `start`,无 `start` 取首个(RFC 2387),其余带 Content-ID 的即 cid 目标,不论处置;mixed 等的部分依次并成正文;处置为 attachment 的实体整个是附件,多部分实体连同其头(RFC 2183 §2.9),子孙不进正文;附件字节只解传输编码,文字附件不按字符集转换;不在 related 里的部分,带 Content-ID 且不是 attachment 的为 cid 目标;无 Content-Type 照 RFC 2045 为 text/plain,digest 的子部分为 message/rfc822;解码有问题的部分与无地址的收件人记警告。`build` 由 `Draft` 写出(密送不进信头,留在 SMTP 信封):Message-ID 为 16 字节随机数加发件人的域(mail-builder 默认写本机主机名,不用),交回供发件记录;地址照 SMTP 的 Mailbox 核对,主题、姓名、Message-ID、文件名、类型带换行即拒绝;附件须有文件名,Content-ID 照写(带换行即拒绝);正文与附件自己组成 mixed 与 alternative。`tests/fixtures/mail/sample.eml` 读全;由它回复写出再读回,各字段与附件(名、类型、Content-ID、含 0 至 255 每个字节)相同;第 1 轮审查后另 7 个结构测试(`tests/mime_tree.rs`:Latin-1 文字附件、`start` 在图后、related 里标为附件的图、附件化的多部分、同型 alternative、digest),去掉各自的修复即失败;第 2 轮审查后:嵌套深于 64 层即报错(先以迭代算出深度,原递归在 2048 层时栈溢出、进程退出);mail-parser 开 `full_encoding`(原 GB2312 等多字节字符集读成替换字符);`Part::params` 带 Content-Type 的参数(不含 name),写出时照写,转发的 Latin-1 附件收件方读出 Café;多部分与 message 附件照原样写出(不编码,7bit 或 8bit,多部分带原分界,RFC 2045 §6.4),SMTP 遇 8 位正文即带 `BODY=8BITMIME`,服务器不支持即在发出前拒绝;related 的根不看处置,其余每个资源整个是一个实体(多部分资源带其 Content-ID 与文件名);带 Content-ID 的正文文字部分另为 cid 目标(related 的根除外);未知处置照附件(RFC 2183 §2.8)。另 9 个测试(`tests/mime_edges.rs`、`tests/mime_forward.rs`,SMTP 一个),去掉各自的修复即失败;第 3 轮审查(最后一轮,修完不再审查)后:深度也数内嵌的邮件(mail-parser 以迭代读出层层相套的 message/rfc822,丢弃时却递归,8192 层即栈溢出),超深的解析结果以迭代逐层拆开再丢;带 Content-ID 的多部分实体(related 的根除外)连同其头另为 cid 目标,子孙照常读;SMTP 按每一层的头块判定是否需 SMTPUTF8(转发的 message/global 里的中文主题),只深入未经传输编码的内嵌邮件;每次发送都先解析邮件,不是邮件即拒绝。另 5 个测试(`tests/mime_deep.rs` 四个、`tests/smtp_utf8.rs` 一个;SMTP 的编码测试与辅助函数移入 `tests/smtp_utf8.rs` 与 `tests/fake/smtp.rs`),去掉各自的修复即失败或栈溢出;原往返测试按子串查 bcc,随机的 Message-ID 约四十次一次含 bcc 而误报,改为查 `Bcc:` 头行;引擎共 61 个测试
- [x] C12.3 本地存储(§12.3、§18.1、§18.3):SQLite 索引(账号、邮箱、消息身份为账号加邮箱加 UIDVALIDITY 加 UID、标记、头字段)、原文按内容存进内容目录、FTS5 全文索引(可重建)、结构版本与升级前备份、升级失败只读;各类大小可查。已做:`Store`(rusqlite,自带 SQLite 3.53.2,FTS5 与 dbstat)在给定文件夹开 `mail.db`(WAL,外键开);表 accounts、mailboxes(账号加名字唯一)、messages(身份为邮箱加 UIDVALIDITY 加 UID,POP3 为邮箱加 UIDL,两者恰居其一,各有部分唯一索引;头字段:Message-ID、In-Reply-To、References、主题、发件人、收件人、抄送(JSON)、日期;附件数在整封到达前为空)、flags(每标记一行);原文按 SHA-256 存在 `content/前两位/散列`(暂存后改名,同内容只存一份,读时核对散列,不符即报“已损坏”,最后一封引用它的邮件删去时删文件);同一身份再来头部只改标记,整封到达即补原文、附件数与正文索引;FTS5 表 `search`(主题、人、正文、附件名,无内容副本,trigram 分词,删邮件的触发器同删索引行),HTML 独有的正文以 html5ever 分词器取文字(略去 script 与 style,标签处断词);`rebuild_search` 由索引行与原文重建;`PRAGMA user_version` 记结构版本,升级前以 `VACUUM INTO` 备份到 `backups/`,各步在一个事务里,备份或升级失败即以只读重开、写入报 `ReadOnly` 并带原因,更新的版本写的库同样只读;`sizes` 给索引、全文索引(dbstat)与原文三类字节数。测试:结构版本 5 个(新库、升级先备份、失败回滚且只读、更新的版本只读、无备份不升级),存储 6 个(整封、先头后整封、UIDVALIDITY 与 POP 身份、共用原文、损坏原文、HTML 正文与重建),去掉各自的规则即失败;引擎共 72 个测试。第 1 轮审查后:重建先删去整张全文表再按库里记的定义重建(损坏的段删不掉);存原文时已有的文件先核对散列,坏了即以本次收到的字节改写;写库失败时删去本次新建且无人引用的原文;`sizes` 的索引按 `mail.db`、`-wal`、`-shm` 的实际字节减去全文页;HTML 取文字照树构建器给分词器的状态(script 为脚本数据,style、iframe、noembed、noframes 为原始文本,title 与 textarea 为 RCDATA,xmp 与 plaintext 照显示,自闭合在 HTML 元素上无效;SVG 与 MathML 里自闭合有效、script 与 style 的文字略去);给不存在的邮件设标记即报错。`tests/store_repair.rs` 6 个,去掉各自的修复即失败;引擎共 78 个测试。第 2 轮审查后:HTML 取文字改走 html5ever 的整个树构建器(`store/html.rs`,自建的节点表作 `TreeSink`,脚本关闭),命名空间、整合点与插入模式全由它定,不再自仿分词器状态;不画的元素照 HTML 渲染规则(script、style、title、head、iframe、template 等)略去;树构建器每个开始标签都扫一遍开放元素栈,嵌套越深越慢(实测 release:十万层 div 28 秒),故按 4 KB 分块喂入,元素深过 512 层(同 Blink 的上限)即停,已读的文字照常入索引,警告写明哪封邮件;暂存原文写入或改名失败时删去暂存文件,删不掉即连同原因报错。`tests/store_repair.rs` 共 8 个;去掉清理、不画元素、MathML 整合点、深度上限即各自失败。第 3 轮审查后(最后一轮,修完不再审查):删除与替换邮件时,原文文件在同一事务提交之前删去,删不掉即整步回滚,邮件与原文都留着、可重试(原先先提交再删文件,失败后失去引用,文件永久残留);提交在删文件之后失败时,下次清理遇文件已不在只记警告;两处超长注释缩短。`tests/store_release.rs`(Windows,以只读共享占住文件)2 个,删去与替换各一,改回先提交即失败;引擎共 82 个测试
- [ ] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试
- [x] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试。已做:`Vault`(keyring:钥匙串、Windows 凭据、Secret Service;拒绝时只在内存,`Kept::Session` 带原因);`oauth::SignIn` 在 127.0.0.1 随机端口等回跳(Google 写 127.0.0.1,Microsoft 写 localhost),state 与 S256 PKCE 各 32 随机字节,回跳的 state 不符或带 error 即报错,其余请求答 404;换令牌与刷新经 ureq(后台线程,30 秒),刷新答 invalid_grant 或 interaction_required 即 `SignInAgain`,其余为 `SignIn`;邮箱取 ID 令牌的 email,Microsoft 无则 preferred_username;客户端 ID 由 build.rs 读环境变量或项目根 `oauth.env`(坏行、未知键即构建失败),缺即 `Unavailable`;`tests/oauth.rs` 以替身令牌端点与替身浏览器核对,`tests/vault.rs` 以替身凭据库核对。审查三轮:第 1 轮四处(Google 授权带 `access_type=offline` 与 `prompt=consent`、换码无刷新令牌即报错;Microsoft 另要 `profile`;`oauth.env` 读不出即构建失败、不存在为未配置;超长注释)、第 2 轮一处(ureq 在 trace 级写出令牌与客户端密钥,`ely_platform::logging` 不记 `ureq`、`ureq_proto` 低于 info 的日志;`tests/private_oauth_logs.rs` 以真日志器在 trace 下核对,改回即失败)已修,第 3 轮通过
- [ ] C12.5 账号(MAIL-01、02):ElyMail 主窗口与添加账号对话框:Gmail、Microsoft、iCloud(应用专用密码向导)、其他 IMAP/SMTP、POP3/SMTP;服务器、端口、TLS 方式、认证方式;连通性检查逐项报结果;多账号;删除账号先写明删去的本地缓存、草稿与凭据,不动服务端
- [ ] C12.6 IMAP 同步(MAIL-14,§12.3,QA-18):文件夹列表与特殊用途;邮件头全部、正文只取 90 天内;UIDVALIDITY 变化即重建该邮箱;服务端删除与标记变化随之;后台任务执行,界面显示最后同步时间、下载范围、未缓存的数量与失败原因
- [ ] C12.7 POP3 收取(MAIL-01,§12.3):按 UIDL 去重下载到本地收件箱,“保留服务端副本”默认开,不做伪文件夹同步
+9
View File
@@ -0,0 +1,9 @@
FAIL
- crates/ely-mail-engine/src/oauth.rs:99 — [P1] Google 授权 URL 没有 `access_type=offline`。[Google 文档](https://developers.google.com/identity/openid-connect/openid-connect#refresh-tokens)规定,请求离线访问才会在换码时取得刷新令牌。首次添加 Gmail 时,即使用户授权成功,也可能只得到短期 access token;到期或重启后无法完成 C12.4 要求的刷新。隔离探针调用实际 `google()` 和 `SignIn::start`,断言该参数时得到 `None`(`.work/C12.4-r1/linux-probes.log`);现有测试的自定义 provider 掩盖了这个缺口。修复方向:Google 授权请求加入离线访问参数,并测试内置 provider 的授权参数与刷新令牌签发条件。
- crates/ely-mail-engine/src/oauth.rs:60 — [P2] Microsoft scope 缺少 `profile`,但邮箱读取依赖 `preferred_username` 作为 `email` 缺失时的回退。[Microsoft 文档](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference)明确该 claim 需要 `profile` scope。因此,未提供 `email` claim 的账号完成授权后,也拿不到承诺的回退值,`Tokens.email` 仍为空。实际 `microsoft()` 的参数探针确认没有申请此 scope(`.work/C12.4-r1/linux-probes.log`)。修复方向:申请该 claim 所需的 scope,补测 Microsoft 无 email、使用 preferred_username 的路径。
- crates/ely-mail-engine/build.rs:16 — [P2] `read_to_string` 的所有错误都直接返回,文件不存在与文件损坏、无法读取被混为一谈。复现:隔离编译原始 build.rs,让其读取含无效 UTF-8 的 `oauth.env`,退出码仍为 0,且没有配置错误提示(`.work/C12.4-r1/config-invalid-utf8.log`)。正常没有环境变量的本机构建会悄悄忽略已有配置,把登录变为 `Unavailable`,违反错误必须暴露与坏配置构建失败的要求。修复方向:只允许 NotFound 表示未配置,其余读取错误使构建失败并说明原因,补测损坏与不可读文件。
- crates/ely-mail-engine/src/oauth/loopback.rs:14 — [P3] 新增注释 `Serves the redirect until it carries a code or an error.` 共 11 词,超过 agents.md 的十词限制;运行项目 `tests/scripts/comments.py` 可复现该行失败。修复方向:缩短到十词以内。
+3
View File
@@ -0,0 +1,3 @@
FAIL
- crates/ely-mail-engine/src/oauth/token.rs:81 — [P1] 读取令牌回复时,ureq-proto 会在 trace 级别逐字节记录整个 JSON,访问令牌与刷新令牌因此进入普通诊断日志,违反 §18.2 与 agents.md 的日志规则。复现:以 `ELY_LOG=trace` 初始化真实的 `ely_platform::logging`,调用实际 `oauth::refresh`,替身端点返回 `ACCESSMARKER` 与 `ROTATEDMARKER`;两者均出现在 `ely.log`(`.work/C12.4-r2/oauth-logger/ely.log`,核对输出在 `actual-logger.log`)。换码同样经过此读取路径;现有日志隐私测试只跑邮件协议,没有覆盖 OAuth,所以 Windows 97 个、Linux 94 个测试全部通过仍未发现泄漏。修复方向:阻止 HTTP 库在任何日志级别记录令牌回复的敏感载荷,并以真实日志器补测换码与刷新的令牌不进入日志。
+1
View File
@@ -0,0 +1 @@
PASS