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:
19 files changed
+1065
-6
No files matched your search
Generated
+3
@@ -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]]
|
||||
|
||||
@@ -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 的日志。
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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 去重下载到本地收件箱,“保留服务端副本”默认开,不做伪文件夹同步
|
||||
|
||||
@@ -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` 可复现该行失败。修复方向:缩短到十词以内。
|
||||
@@ -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 库在任何日志级别记录令牌回复的敏感载荷,并以真实日志器补测换码与刷新的令牌不进入日志。
|
||||
@@ -0,0 +1 @@
|
||||
PASS
|
||||
Reference in new issue
Block a user