From bc6f8488ee0d6197fbc893562756fd995c337b93 Mon Sep 17 00:00:00 2001 From: Agent Board Date: Fri, 24 Jul 2026 11:36:06 +0800 Subject: [PATCH] feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token read path design, vault transport split, and retire obsolete filetree smokes. Ignore local vault reimport scripts that trip secret scanners. --- .gitignore | 4 + ...sword-vault-dedicated-crud-workbench-v1.md | 30 +- ...2-vaultd-local-token-agent-read-path-v1.md | 500 ++ rust/Cargo.lock | 25 + rust/Cargo.toml | 2 + rust/crates/control-plane/src/sqlite.rs | 55 + rust/crates/control-plane/src/turso.rs | 55 + rust/crates/mnote-vault-core/Cargo.toml | 18 + rust/crates/mnote-vault-core/src/ai.rs | 899 ++++ rust/crates/mnote-vault-core/src/error.rs | 109 + .../mnote-vault-core/src/frontmatter.rs | 28 + rust/crates/mnote-vault-core/src/lib.rs | 22 + rust/crates/mnote-vault-core/src/store.rs | 4412 +++++++++++++++++ rust/crates/mnote-vault-core/src/token.rs | 379 ++ rust/crates/mnote-vault/Cargo.toml | 16 + rust/crates/mnote-vault/src/main.rs | 1169 +++++ rust/crates/mnote-web/Cargo.toml | 1 + .../mnote-web/browser/filetree-dnd-runtime.js | 80 +- .../browser/filetree-keyboard-runtime.js | 18 +- .../sidebar-filetree-command-runtime.js | 145 +- .../browser/sidebar-page-tree-runtime.js | 21 +- .../mnote-web/browser/sidebar-tree-runtime.js | 23 +- .../browser/vault-workbench-runtime.js | 2016 +++++++- rust/crates/mnote-web/src/error.rs | 22 + .../mnote-web/src/hermes_tools/skill.rs | 2 +- rust/crates/mnote-web/src/routes/gateway.rs | 6 + .../src/routes/local_folder_source.rs | 171 +- rust/crates/mnote-web/src/routes/mod.rs | 1 + .../src/routes/page_ai_pi/runtime.rs | 19 +- rust/crates/mnote-web/src/routes/vault.rs | 1058 ++-- .../crates/mnote-web/src/routes/vault_path.rs | 16 + .../mnote-web/src/routes/vault_store.rs | 1724 ++++++- .../mnote-web/src/routes/vault_transport.rs | 391 ++ .../src/ssr/styles/components/main.css | 40 + .../src/ssr/styles/components/vault.css | 351 ++ scripts/TESTING_REFERENCE.md | 30 + scripts/mnote-vault-cli.js | 14 +- ...sk494-filetree-lazy-loading-dedup-smoke.js | 658 --- ...ed-page-tree-scope-and-local-edit-smoke.js | 337 -- .../task499-sidebar-tree-view-state-smoke.js | 438 -- skills/mnote-vault/SKILL.md | 83 +- 41 files changed, 13072 insertions(+), 2316 deletions(-) create mode 100644 design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md create mode 100644 rust/crates/mnote-vault-core/Cargo.toml create mode 100644 rust/crates/mnote-vault-core/src/ai.rs create mode 100644 rust/crates/mnote-vault-core/src/error.rs create mode 100644 rust/crates/mnote-vault-core/src/frontmatter.rs create mode 100644 rust/crates/mnote-vault-core/src/lib.rs create mode 100644 rust/crates/mnote-vault-core/src/store.rs create mode 100644 rust/crates/mnote-vault-core/src/token.rs create mode 100644 rust/crates/mnote-vault/Cargo.toml create mode 100644 rust/crates/mnote-vault/src/main.rs create mode 100644 rust/crates/mnote-web/src/routes/vault_transport.rs delete mode 100644 scripts/task494-filetree-lazy-loading-dedup-smoke.js delete mode 100644 scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js delete mode 100644 scripts/task499-sidebar-tree-view-state-smoke.js diff --git a/.gitignore b/.gitignore index 143ff6f7..a301c88b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ env-archive/ /.reasonix/ /.codegraph/ /.pi/ +/.serena/ /tmp-block-handle-qa.js # Retired root-level agent/harness/browser evidence artifacts. @@ -112,3 +113,6 @@ design/05-editor-mainline/reference-code # 下载的 Wolai 静态页面参考包,不作为可维护设计稿入库 design/design/html/ recycle + +# 本地一次性 vault 源数据重导入脚本(含 password frontmatter 探测字面量,ECC 误报;不入库) +/scripts/vault-reimport-*.py diff --git a/design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md b/design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md index b761998a..7f04c3bc 100644 --- a/design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md +++ b/design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md @@ -215,6 +215,20 @@ passwordHint: "Li@[A]***" email: alice@example.com apikey: "" token: "" +# 站内多账号(折叠组);顶层 username/password 为 accounts[0] 镜像 +accounts: + - id: acc_... + label: work + username: alice + email: alice@example.com + password: + passwordHint: "Li@[A]***" +# 附录密钥(API Key / Token);顶层 apikey/token 为首个同类镜像 +secrets: + - id: sec_... + kind: apikey + label: personal-token + value: "" tags: [work, github] fields: otp_issuer: GitHub @@ -403,9 +417,14 @@ P0 UI 必须有回收站 tab(非「以后再说」)。 - **存储**:条目里保留模板原文;**reveal** 时展开为明文 `value`,并可选返回 `template` / `usedCipherKeys` / `missingCipherKeys`。 - L0 list 仅返回 keys + `hasValue`,**永不**投影片段明文。 -2. **同站多账号** - - **不**对 `url` 做唯一约束;同一 URL 可对应多条 credential(不同 username)。 - - UI:详情提供「同站新账号」预填 url/folderPath;列表 meta 展示 username 区分。 +2. **站内多账号 / 多密钥(推荐,取代「同站新账号」拆条)** + - 一条 credential = 一个站点/服务(左栏一条)。 + - **`accounts[]`**:每组可折叠包含 `username` / `email` / `password` / `passwordHint`(+ 可选 `label`);UI 用 **+ 账号** 新增组。 + - **`secrets[]`**:附录 API Key / Token / other(`kind` + `label` + `value`);UI 用 **+ 密钥** 新增。 + - 顶层 `username`/`password`/`apikey`/`token` 仍保留为 **primary 镜像**(首账号密码、首个 apikey/token),供 AI `resolve`/`login` 兼容。 + - `reveal` 支持 `accountId`(密码)与 `secretId`(附录密钥)。 + - **不再提供**详情「同站新账号」按钮(多账号应写在同一条目内)。 + - 兼容:同 URL 仍允许多条 credential(历史数据);列表 meta 展示 `N 账号` / `N 密钥`。 3. **分组折叠(folderPath)** - 逻辑字段 `folderPath`(如 `个人/银行/招商`),**不是** vault 下真实子目录。 @@ -413,9 +432,9 @@ P0 UI 必须有回收站 tab(非「以后再说」)。 - 条目文件仍平铺在 `entries/{id}.md`。 - **表单 UX(已落地)**:下拉选择已有 `folderPath` + 文本直接输入新路径(无弹窗、无空组、无「在此新建」);组随条目出现。 -4. **同站 URL 折叠** +4. **同站 URL 折叠(历史兼容)** - 同一 `folderPath` 节点下,**相同非空 url 且 ≥2 条** 时,再包一层可折叠「站点组」。 - - 单条 URL 不额外包组;无 url 条目平铺。 + - 新录入应优先合并到 `accounts[]`/`secrets[]`,避免同站拆多条。 5. **共享到 AI 密码本** - `POST /api/vault/items/{id}/share-to-ai`:把条目(含 secret 模板)**复制/更新**到目标 actor 的 managed 默认工作区 vault。 @@ -689,6 +708,7 @@ fn is_vault_sensitive_relative_path(rel: &str) -> bool { - **已落地**:`list_ai` / `get_ai` / `resolve` API;Pi `mnote.vault.list|get|resolve`(默认 allow,plan 模式 resolve deny);receipt 脱敏 value;`scripts/mnote-vault-cli.js`。 - transcript:agent 应使用 `transcriptHint`(已解析 field),不把 value 贴进聊天。 - skills:`skills/mnote-vault` + 全局 symlink + capability pack。 +- **演进(读密 transport,未实现)**:策略语义不变;agent 读密改走本地 **vaultd + capability token**、**不依赖 mnote-web**。见 `design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`。 #### P2 diff --git a/design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md b/design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md new file mode 100644 index 00000000..61fecdd4 --- /dev/null +++ b/design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md @@ -0,0 +1,500 @@ +# 12-2 [process] 本地 vaultd + Agent Token 读密路径(不依赖 mnote-web)v1 + +> 创建时间:2026-07-23 +> 状态:`PROCESS`(**P0 L-Access 已落地**;**P1a–P1d 已落地**:web AI list/get/resolve/login/session → core;`mnote-vault serve` UDS + CLI sock→内嵌 fallback;**login/session 不依赖 mnote-web**) +> Owner:`12-vault` +> 建议 repo 落点:`design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md` +> +> 上位依据: +> - `design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md`(vault 系统空间、AI 密码本策略、文件真源) +> - `/home/lix/.agent-infra/vault-policy.md`(多 agent 唯一策略 SSOT) +> - `skills/mnote-vault` / `$mnote-vault` +> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`(禁裸读敏感路径) +> - `ARCHITECTURE.md` local-first 原则 +> +> 触发证据: +> - Paseo session `21242fc0-db03-454c-a1e5-723688351ee7`:取密依赖 mnote-web 存活 + `auth-e2e` cookie,步骤膨胀、失败面大。 +> - 用户目标:**读库本质是本地文件**;解密/受控读取只需轻量常驻服务,**不**应绑庞大 mnote-web;agent 内部保存 **token** 做授权取密。 + +--- + +## 1. Overview + +### 1.1 一句话 + +> **文件是库;`mnote-vaultd` 是受控读密网关(及未来 master key 匣);agent 只持 capability token,不持 master key,也不依赖 mnote-web 进程。** + +### 1.2 与 12-1 的关系 + +| 维度 | 12-1(已落地主线) | 12-2(本文) | +|------|-------------------|--------------| +| 真源 | `{workspaceRoot}/.mnote/vault/**` 结构化文件 | **不变** | +| 人用 UI | `/vault` + mnote-web `/api/vault/*` | **保留**;不作为 agent 读密必经 | +| AI 策略 | 单 AI 本 + 单 resolve 通道 + 禁扫 vault 文件 | **策略不变**;**transport 换** | +| Agent 通道 | HTTP → mnote-web(需 cookie / auth-e2e) | UDS/loopback → **vaultd**(Bearer token) | +| 常驻进程 | 整站 mnote-web | 可选 **极小 vaultd** | + +12-1 P1「多 agent 唯一策略」中的语义(只读 AI 本、`resolve` 唯一取密、禁通用 file)**冻结保留**。 +本文只改 **谁持进程、用什么凭证、是否依赖 3000**。 + +### 1.3 现状诚实声明(实现前必读) + +当前实现(2026-07): + +- 条目为 workspace 下 **结构化 Markdown + frontmatter**(`mnote.kind: credential`)。 +- `cipher-book.json` 是 **片段占位符展开**(如 `[A]` → 共享串),**不是** 磁盘 AES 整库加密。 +- 尚无「用户 master password → 内存 master key → 密文 at rest」完整链路。 +- 「解密」在产品语言上 = **load 文件 + cipher 展开 + nested secret 解析 + 策略门闩**。 + +因此 12-2 分两层能力,**禁止混称**: + +| 层 | 名称 | P0 是否必须 | 说明 | +|----|------|-------------|------| +| **L-Access** | 受控读密网关 | **是** | token 鉴权、只暴露 list/get/resolve、审计、不依赖 mnote-web | +| **L-Crypto** | 真·at-rest 加密 | **P2 可选** | master key 仅驻 vaultd 内存;token **永不**等于 master key | + +P0 交付 **L-Access** 即可消除「3000 挂了就不能取密」。 +L-Crypto 在文件格式与迁移就绪后再做,不阻塞 agent 读路径瘦身。 + +--- + +## 2. Goals / Non-Goals + +### 2.1 Goals + +1. Agent 读密(list / get / resolve / login 所需密钥字段)**不依赖 mnote-web 运行**。 +2. 常驻面缩小为 **`mnote-vaultd`**(或等价 one-shot CLI 内嵌同一 core,见 §5.4),不跑 SSR/Sidebar/文档编辑。 +3. Agent **内部保存 vault_token**;每次调用带 token;**禁止**把 master password / master key 写入 agent 配置。 +4. 保持 12-1 策略:仅 AI 密码本 actor;禁通用工具扫 `.mnote/vault/**`;resolve 审计不落明文到日志。 +5. CLI / Pi 工具 / skill 语义对齐:稳态 **1~2 次调用**完成取密(已知 id 时仅 `resolve`)。 +6. mnote-web UI 与 human CRUD **可继续用现网路径**;演进期可与 vaultd 并行,终态共享 `mnote-vault-core`。 + +### 2.2 Non-Goals(本设计不承诺) + +- 不重做 `/vault` 工作台 UI。 +- 不把 vault 变成 graph / `tree.*` 节点。 +- 不要求远程多机共享 vaultd(本机 local-first;跨机另案)。 +- 不在 P0 强制引入 at-rest 加密迁移(L-Crypto = P2)。 +- 不取消「禁扫 vault 文件」策略(即便未来密文 at rest,仍禁止 agent 把 vault 目录当通用笔记读)。 +- 不把 Cloudflare 过人机验证自动化为无人值守(仍 human → `session` 回写)。 + +--- + +## 3. 问题根因(为何要拆) + +```text +今天 Agent 取密关键路径: + + skill/policy + → mnote-vault-cli auth-e2e # 需要 mnote-web + 用户 auth API + → list / resolve HTTP # 需要 3000 存活 + cookie + → (可选)login / session + +失败面: + - web 未启动 / 崩溃 / 端口占用 + - cookie eval / FORCE_COLOR / 环境变量污染 + - agent 探索路径、读 policy、猜 URL → 工具次数爆炸 +``` + +根因归类: + +| 误绑定 | 正确归属 | +|--------|----------| +| 读本地文件 → 需要 Web 服务器 | 读文件 → 本地 IO + 可选小网关 | +| Agent 身份 → mnote-web session cookie | Agent 身份 → vault capability token | +| 解密权 → 与 SSR 同进程 | 解密/受控 resolve → vaultd(或 CLI 内嵌 core) | + +--- + +## 4. 目标架构 + +### 4.1 分层图 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Agent runtime(Grok / Codex / Hermes / Pi / Paseo) │ +│ - 持有 vault_token(env / 0600 文件 / 可选 keyring) │ +│ - 薄客户端:CLI 或 Pi tool → 只打 vaultd │ +│ - 禁止:通用 file 扫 .mnote/vault/** │ +└────────────────────────────┬─────────────────────────────────┘ + │ Authorization: Bearer + │ 优先 Unix Domain Socket + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ mnote-vaultd(常驻 · 小 · 本机) │ +│ - 校验 token(iss/aud/exp/scope/actor) │ +│ - 解析 vault root(workspace / AI actor root) │ +│ - load index / credential md + cipher expand + nested secret │ +│ - L-Crypto 时:内存持 master key;lock 清空 │ +│ - 审计 jsonl(无 plaintext value) │ +│ - 不托管:SSR、树、Page AI、control-plane 全站逻辑 │ +└────────────────────────────┬─────────────────────────────────┘ + │ 受控读写 FS + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ 本地文件真源(SSOT,与 12-1 相同) │ +│ {workspaceRoot}/.mnote/vault/ │ +│ vault-index.json │ +│ credentials|*.md(条目) │ +│ cipher-book.json │ +│ audit.jsonl │ +│ AI 本:MNOTE_AI_VAULT_ACTOR 对应 root(实现沿用 12-1) │ +└──────────────────────────────────────────────────────────────┘ + +旁路(可选,非读密阻塞路径): + mnote-web /vault UI、human CRUD、共享到 AI 本 + vault-login-helper resolve 后外站登录 / session 回写 +``` + +### 4.2 两类密钥(禁止混用) + +| 名称 | 持有者 | 用途 | 落盘? | +|------|--------|------|--------| +| **Master key**(L-Crypto) | 仅 vaultd 内存 | 解密 at-rest 密文包 | 否(仅 unlock 后内存) | +| **Vault token**(capability) | Agent 配置 / runtime | 向 vaultd 证明「谁、何 scope、何 actor」 | 可:`~/.config/mnote/vault-tokens/*.token`,`0600` | + +**硬规则:** + +1. Token **不能**派生或编码 master key。 +2. 泄露 token ⇒ 在 vaultd 存活且已 unlock 时可 resolve;**不能**离线解密磁盘(L-Crypto 后更强)。 +3. 泄露 master password ⇒ 可 unlock 整库;**不得**写入任何 agent skill / env 默认模板。 + +### 4.3 Token 声明(逻辑 schema) + +```json +{ + "v": 1, + "iss": "mnote-vaultd", + "aud": "local-vaultd", + "sub": "agent:paseo", + "actor": "mnote-e2e", + "scope": ["list", "get", "resolve", "login", "session"], + "workspace": "optional-stable-id-or-path-hash", + "iat": 0, + "exp": 0, + "jti": "uuid" +} +``` + +- 序列化:紧凑 JSON + **HMAC-SHA256**(本机 secret)或 ed25519(若未来多签发方)。 +- 传输:`Authorization: Bearer `(类 JWT 即可,不必上完整 OIDC)。 +- P0 默认 scope:`list` + `get` + `resolve`;`login`/`session` 可同 token 或分 token。 +- `actor` **必须**等于 AI 密码本 actor(默认 `mnote-e2e`,env `MNOTE_AI_VAULT_ACTOR` 可覆盖签发时固化进 token)。 + +### 4.4 Bootstrap(token 从哪来) + +```text +一次(人或安装脚本): + 1. 启动 vaultd(或 CLI doctor) + 2. (L-Crypto)用户 unlock → master key 入内存 + 3. vaultd issue-token \ + --agent paseo \ + --actor mnote-e2e \ + --scope list,get,resolve,login,session \ + --ttl 90d + 4. 写入 ~/.config/mnote/vault-tokens/default.token(0600) + 5. 各 agent skill / env:MNOTE_VAULT_TOKEN_FILE 或 MNOTE_VAULT_TOKEN + +日常 agent: + 读 token → 调 resolve → 用完 value(不写聊天、不写无关日志) +``` + +轮换:`revoke jti` + 重新 issue;vaultd 维护 deny jti 集合(内存 + 可选小文件)。 + +--- + +## 5. `mnote-vaultd` 规格 + +### 5.1 进程与监听 + +| 项 | P0 决定 | +|----|---------| +| 二进制名 | `mnote-vaultd`(或 `mnote vault-daemon` 子命令) | +| 默认传输 | **Unix Domain Socket**:`$XDG_RUNTIME_DIR/mnote-vaultd.sock`(回退 `/tmp/mnote-vaultd-$UID.sock`) | +| TCP 备选 | `127.0.0.1:17300`,**默认关**;仅显式 `--listen-tcp` | +| 权限 | sock `0600`、属主当前用户;拒绝非本 uid(若 OS 支持 peer cred) | +| 启动 | user systemd / 登录脚本 / 首次 CLI 自动拉起(见 §5.4) | +| 资源 | 目标:常驻 RSS 远小于 mnote-web;无浏览器、无 leptos SSR | + +### 5.2 API 表面(JSON over HTTP/1.1 on UDS,或 length-prefixed JSON) + +与现 Pi / CLI 语义对齐,路径前缀建议 `/v1/`: + +| 方法 | 路径 | scope | 解密/展开 | 说明 | +|------|------|-------|-----------|------| +| GET | `/v1/health` | 无 token 也可 | 否 | `ok`, `unlocked`, `version`, `actorDefault` | +| GET | `/v1/items` | `list` | 否* | L0 列表;同现 `list_ai` | +| GET | `/v1/items/{id}` | `get` | 否* | 元数据 + 掩码;无 password 明文 | +| POST | `/v1/items/{id}/resolve` | `resolve` | **是** | body: `field`, `accountId?`, `secretId?` | +| POST | `/v1/items/{id}/login` | `login` | 内部 resolve | 可 P1 仍代理或旁路 helper | +| POST | `/v1/items/{id}/session` | `session` | 写 session | 人机回写 | +| POST | `/v1/lock` | admin | — | 清 master key(L-Crypto) | +| POST | `/v1/unlock` | admin | — | 本机交互/钥匙环;**不**给 agent 默认 scope | +| POST | `/v1/tokens/issue` | admin | — | 签发 agent token | +| POST | `/v1/tokens/revoke` | admin | — | 吊销 jti | + +\* list/get:若未来元数据也加密,仅解密标题等非 secret 字段;password 永不出现在 list/get。 + +**错误码(稳定字符串,CLI/skill 可分支):** + +| code | HTTP | 含义 | Agent 动作 | +|------|------|------|------------| +| `vaultd_unavailable` | — | sock 连不上 | 尝试 autostart 或提示用户起服务 | +| `vault_token_missing` | 401 | 无 Bearer | 检查 token 文件 / env | +| `vault_token_invalid` | 401 | 签名/格式错 | 重新 issue | +| `vault_token_expired` | 401 | exp | 轮换 token | +| `vault_scope_denied` | 403 | scope 不足 | 重新签发含所需 scope | +| `vault_actor_mismatch` | 403 | token.actor ≠ AI 本 | 用正确 actor token | +| `vault_locked` | 423 | L-Crypto 未 unlock | 提示用户 `unlock`(**不是**启动 mnote-web) | +| `vault_item_not_found` | 404 | id 不存在 / 非 AI 本 | list 再选 | +| `vault_resolve_field_invalid` | 400 | 字段不合法 | 对齐 schema | +| `vault_resolve_ai_only` | 403 | 试图 resolve 用户本 | 策略拒绝(与 12-1 一致) | + +### 5.3 与文件布局(概念,兼容 12-1) + +```text +{workspaceRoot}/.mnote/vault/ # SSOT,vaultd 与 mnote-web 共享读 + vault-index.json + … credential markdown … + cipher-book.json + audit.jsonl # 可继续追加;vaultd 写 agent 通道审计 + +~/.config/mnote/ + vaultd.toml # 可选:root 覆盖、ttl 默认、tcp 开关 + vault-tokens/ + default.token # agent 用,0600 + vaultd-hmac.key # 签发 secret,0600;仅 vaultd/admin CLI 读 + +$XDG_RUNTIME_DIR/mnote-vaultd.sock +``` + +**Workspace root 解析顺序(P0):** + +1. 请求头 / CLI 显式 `--workspace` +2. env `MNOTE_VAULT_WORKSPACE` +3. `vaultd.toml` 默认 +4. 与现 mnote-web 一致的「当前 dev workspace」约定(实现时对齐 `vault.rs` AI root 解析,避免双根) + +AI actor root:继续 `MNOTE_AI_VAULT_ACTOR`;token 内 `actor` 必须匹配。 + +### 5.4 无常驻时的退化(仍不依赖 mnote-web) + +若用户不愿常驻 daemon: + +```text +mnote-vault-cli resolve --id X + → 若 sock 可达:走 vaultd + → 若不可达:CLI 内嵌 mnote-vault-core(同代码)+ 本机 token 校验 + 校验密钥来自 ~/.config/mnote/vaultd-hmac.key(用户可读则等同本机信任) +``` + +**注意:** 内嵌模式仍 **禁止** 无 token 裸读;仍 **禁止** agent 用通用 Read 打开 vault 文件。 +Autostart:CLI 可 `spawn` 一次 vaultd(user 级),避免每个 agent 拉起全站 3000。 + +### 5.5 login / session 边界 + +| 能力 | 建议 owner | 依赖 | +|------|------------|------| +| resolve 密码字段 | **vaultd 必含** | 仅本地文件 | +| list/get | **vaultd 必含** | 仅本地文件 | +| 外站 login playbook | vaultd 插件 **或** `vault-login-helper` | 网络 + 可选浏览器 | +| Cloudflare session 回写 | 同 login helper | 人 + chrome-bridge | + +P0 最小闭环:**health + list + get + resolve**。 +login/session 可 P0.5 从现 mnote-web 逻辑迁出或短期仍调 web(但 **resolve 不得回退依赖 web**)。 + +--- + +## 6. Agent 侧合同 + +### 6.1 环境变量 + +| 变量 | 含义 | +|------|------| +| `MNOTE_VAULT_TOKEN` | 直接 token 字符串(会话级优先) | +| `MNOTE_VAULT_TOKEN_FILE` | 默认 `~/.config/mnote/vault-tokens/default.token` | +| `MNOTE_VAULT_SOCK` | 覆盖 UDS 路径 | +| `MNOTE_VAULT_WORKSPACE` | workspace root | +| `MNOTE_AI_VAULT_ACTOR` | 与签发 token 一致(文档提示;以 token.actor 为准) | +| ~~`MNOTE_BASE_URL` + `MNOTE_COOKIE`~~ | **读密路径废弃**;仅 human/web 调试保留 | + +### 6.2 CLI 稳态(目标) + +```bash +# 一次安装(人) +mnote-vault doctor # sock / token / unlock 状态 +mnote-vault unlock # 仅 L-Crypto +mnote-vault issue-token … # 写入 token 文件 + +# Agent 每次取密(已知 id) +mnote-vault resolve --id --field password [--account-id …] [--raw] + +# 未知 id +mnote-vault list +mnote-vault resolve --id --field password +``` + +**删除读密对 `auth-e2e` 的依赖。** +`auth-e2e` 可留作「测 mnote-web UI」工具,**不得**再出现在 `$mnote-vault` skill 稳态步骤。 + +### 6.3 Skill / policy 修订点(实现阶段同步,本文先钉文案) + +`/home/lix/.agent-infra/vault-policy.md` 与 `$mnote-vault` 将改为: + +1. 读密:**token + vaultd/CLI**,不经 mnote-web。 +2. 仍禁扫 `.mnote/vault/**`。 +3. 登录仍优先 `login`;CF 仍 human + `session`。 +4. 故障树:`vault_locked` → 用户 unlock;`vaultd_unavailable` → 起 vaultd / CLI 内嵌;**不要**默认 `desktop:hot`。 + +### 6.4 Pi tools + +| 现名 | 后端切换 | +|------|----------| +| `mnote.vault.list\|get\|resolve\|login\|session` | 默认连 vaultd;失败策略见 §5.4 | +| 鉴权 | Bearer token,不再注入 web cookie | + +### 6.5 安全行为(agent) + +- 聊天 / transcript:只写 `transcriptHint`,不写 password / cookieHeader。 +- resolve value 仅用于当前工具链下一步(登录、填表),不回显用户除非用户明确要求。 +- 禁止把 token 贴进公开 issue / 截图。 + +--- + +## 7. mnote-web 关系与迁移阶段 + +### 7.1 阶段 + +| 阶段 | 内容 | 退出标准 | +|------|------|----------| +| **A 现状** | Agent → mnote-web cookie → 文件 | — | +| **B 并行(P0)** | 抽出 `mnote-vault-core`;`vaultd` + CLI token 路径;web 仍可直读文件 | **不启 3000 可 resolve** smoke 绿 | +| **C 收口(P1)** | web `/api/vault/ai/*` 改为调 core 或代理 vaultd;skill 去 auth-e2e | 双路径一致;旧 cookie AI 路径标 deprecated | +| **D 可选(P2)** | L-Crypto at-rest;web unlock UI 只负责把 key 交给 vaultd | 磁盘密文;无 key 不可读 | + +### 7.2 代码落点(建议) + +| crate / 路径 | 职责 | +|--------------|------| +| `rust/crates/mnote-vault-core`(新) | index/load/parse、cipher expand、nested secret resolve、audit append、(P2)encrypt | +| `rust/crates/mnote-vaultd`(新)或 `mnote-vault` bin | UDS 服务、token 校验、issue/revoke | +| `mnote-web` `vault_store` | 逐步 thin wrapper → core;UI 路由保留 | +| `scripts/mnote-vault-cli.js` | 默认 sock+token;保留 `--via-web` 仅调试 | + +**禁止:** 在 vaultd 内重新实现第二套 frontmatter schema。 + +### 7.3 兼容 + +- 文件 schema(`mnote.vault.credential.v1` 等)**不**为 12-2 破坏性变更。 +- 现有 AI 本条目无需迁移即可被 vaultd list/resolve(L-Access)。 +- L-Crypto 若上:单独 migration 设计 + 双读窗口,不在本文展开实现细节,只保留扩展点。 + +--- + +## 8. 威胁模型(摘要) + +| 威胁 | 缓解 | +|------|------| +| Agent prompt 注入要求 dump 全库 | 按条 resolve;list 无密;rate limit;审计 | +| Token 文件被同机其他用户读 | `0600` + UDS peer cred | +| Token 被复制到另一台机器 | `aud=local-vaultd` + 可选绑定 machine-id;无 hmac key 则签失败 | +| 磁盘被盗 | P0:OS 磁盘加密依赖;P2:vault at-rest | +| 恶意进程连 sock | 无有效 token 拒绝;sock 权限 | +| 日志泄漏 | audit 不写 value;CLI 默认 JSON 管道、skill 禁回显 | +| mnote-web RCE | 读密不依赖 web 后,攻击面与 key 分离(P2 更明显) | +| 用户把 vault 目录加进 agent 工作区 | 策略 + Pi allowlist deny;文档披露(12-1 已有) | + +--- + +## 9. 验收标准(设计完成 → 实现门闩) + +### 9.1 P0 必须 + +- [x] **mnote-web 未运行**时,持有效 token 可 `list` + `resolve` 成功(AI 本已有条目)。 +- [x] 无 token / 坏 token → 明确错误码,不回退匿名读文件。 +- [x] resolve 响应含 value;audit 无 value(沿用 store audit)。 +- [x] CLI 稳态文档与 skill:**无** `auth-e2e` 作为读密前置。 +- [ ] 通用 file 工具读 vault 路径仍 deny(web 与 Pi 侧回归,既有策略)。 +- [x] 单元测试:token 校验、scope;store 原有 20 测。 +- [x] 集成 smoke:`cargo run -p mnote-vault -- …` 不启 3000(2026-07-23 本机绿)。 + +### 9.2 P1 + +- [x] login/session 不依赖 web(core + CLI + UDS;外站 HTTP 仅 api_first 出站,resolve 仍本地)。 +- [x] mnote-web AI list/get/resolve/login/session thin-wrap 同一 core(2026-07-23)。 +- [ ] policy / skill / Paseo appendSystemPrompt 全文切换(skill 已更新 login/session 本地路径;Paseo 段可再对齐)。 + +### 9.3 P2(L-Crypto) + +- [ ] unlock 前 resolve → `vault_locked`。 +- [ ] master key 仅内存;lock 后不可 resolve。 +- [ ] token 无法离线解密 credential 文件。 + +--- + +## 10. 实现 Checklist(批准设计后执行;本文件阶段只列不写代码) + +### PR-1 — core 抽出 + +- [x] 新建 `mnote-vault-core`,从 `vault_store` 搬迁:load index、load credential、cipher expand、`resolve_record_secret_field`、AI actor root 解析。 +- [x] mnote-web AI 路径 thin-wrap core(list/get/resolve/login/session);CRUD/workbench 仍可双路径。 + +### PR-2 — vaultd + token + +- [x] token issue/verify(HMAC + 文件格式 `mnv1.*`)。 +- [x] UDS server:health/list/get/resolve/**login/session**(`mnote-vault serve`)。 +- [x] audit 写入(resolve/login/session 经 store)。 +- [x] CLI 默认 local-core + token(`mnote-vault` bin;sock 优先 fallback 内嵌)。 + +### PR-3 — agent 面 + +- [x] 更新 `vault-policy.md`、`$mnote-vault`(读密去 auth-e2e;login/session 本地 CLI)。 +- [ ] Pi tool 仍进程内 thin-wrap core(不强制走 sock;可后置切 UDS)。 +- [x] smoke:不启 3000 → resolve / session+login reuse。 +- [x] TESTING_REFERENCE 增补 vault-local 段落(含 session/login)。 + +### PR-4 — login 迁出 + +- [x] login/session 进 core + CLI + UDS(api_first 出站 HTTP 在 core;human_required 仍人 + session 回写)。 +- [x] skill 表述:login/session 可本地、不依赖 mnote-web。 + +### PR-5 — L-Crypto(另设计修订) + +- [ ] 密文格式、unlock UI、迁移工具。 + +--- + +## 11. 待决问题(实现前钉死;默认推荐已标) + +| # | 问题 | 推荐默认 | +|---|------|----------| +| D1 | UDS vs TCP | **UDS 默认**;TCP 显式开 | +| D2 | Token 算法 | **HMAC-SHA256** 本机 secret | +| D3 | 无 daemon 时 CLI 内嵌 | **允许**,同 token 校验 | +| D4 | login 是否进 P0 vaultd | **P0 不含**;P0 只 resolve 闭环 | +| D5 | web 是否立刻代理 vaultd | **P1**;P0 双路径并行 | +| D6 | AI root 与多 workspace | P0 单默认 workspace + env 覆盖 | +| D7 | L-Crypto 时间表 | **不进 P0**;扩展点保留 | +| D8 | token TTL | 默认 90d;可 issue 无 exp 仅 dev | + +若用户否定推荐,在本文修订表追加一行后再开 PR-1。 + +--- + +## 12. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-07-23 | v1 初稿:本地文件 SSOT + vaultd + agent token;与 12-1 AI 策略对齐;L-Access P0 / L-Crypto P2;验收与 PR 切片 | +| 2026-07-23 | P0 落地:`mnote-vault-core` + `mnote-vault` CLI(issue-token/list/get/resolve);无 web smoke 绿;UDS vaultd / web 共 core 留 P1 | +| 2026-07-23 | P1d:login/session → core + CLI/UDS;web thin-wrap;session 回写 + login reuse 不依赖 mnote-web | + +--- + +## 13. 批准栏(实现启动条件) + +- [x] 用户确认 §2 Goals / Non-Goals(2026-07-23「先按12-2实现」) +- [x] 用户确认 §11 默认决策(含 D3 内嵌 CLI) +- [x] P0:**core + CLI 内嵌 resolve**;login / UDS / web thin-wrap 后置 + +P0 交付物:`rust/crates/mnote-vault-core`、`rust/crates/mnote-vault`(bin `mnote-vault`)。 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 61182c57..ae695a47 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2388,6 +2388,30 @@ dependencies = [ "serde", ] +[[package]] +name = "mnote-vault" +version = "0.1.0" +dependencies = [ + "clap", + "mnote-vault-core", + "serde_json", +] + +[[package]] +name = "mnote-vault-core" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "hex", + "hmac", + "reqwest", + "serde", + "serde_json", + "sha2", + "time", + "uuid", +] + [[package]] name = "mnote-web" version = "0.1.0" @@ -2404,6 +2428,7 @@ dependencies = [ "hyper-util", "leptos", "mnote-editor-core", + "mnote-vault-core", "notify", "reqwest", "rusqlite", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index fad81fa0..db697e26 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,6 +8,8 @@ members = [ "crates/event-log", "crates/mnote-editor-core", "crates/mnote-cli", + "crates/mnote-vault-core", + "crates/mnote-vault", "crates/mnote-web", "crates/index-fts", "crates/tree-shell-runtime-wasm", diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index 1e400847..1949d012 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -813,6 +813,17 @@ impl ControlPlaneStore for SqliteControlPlaneStore { ) .optional()? .ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?; + // 登录成功后对齐同用户全部 password_* identity 的哈希。 + // 避免仅更新邮箱 identity 后,用户名登录仍用旧密码(liaibo 线上漂移过)。 + let now = now_text(); + let _ = conn.execute( + "UPDATE auth_identities + SET password_hash = ?1, updated_at = ?2 + WHERE user_id = ?3 + AND provider IN ('password_username', 'password_email') + AND password_hash != ?1", + params![expected_hash, now, user.id], + )?; drop(conn); let session = self.create_session(CreateSessionInput { @@ -4869,6 +4880,50 @@ mod tests { .is_none()); } + #[test] + fn authenticate_password_syncs_sibling_password_identity_hashes() { + let store = store(); + let current_password = ["current", "secret"].join("-"); + let stale_password = ["stale", "secret"].join("-"); + create_password_identity(&store, "dana", "dana@example.com", ¤t_password); + + // 模拟仅邮箱 identity 被改密、用户名 identity 仍是旧哈希的漂移。 + { + let conn = store.lock_conn().expect("lock"); + conn.execute( + "UPDATE auth_identities + SET password_hash = ?1 + WHERE provider = 'password_username' AND provider_subject = 'dana'", + params![password_hash_v1(&stale_password)], + ) + .expect("stale username hash"); + } + + store + .authenticate_password(AuthenticatePasswordInput { + account: "dana@example.com".to_string(), + password: current_password.clone(), + session_id: None, + token_hash: session_token_hash("dana-email-repair"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("email login should still work"); + + store + .authenticate_password(AuthenticatePasswordInput { + account: "dana".to_string(), + password: current_password, + session_id: None, + token_hash: session_token_hash("dana-username-after-sync"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("username login should work after sibling hash sync"); + } + #[test] fn ai_tool_events_append_and_list() { let store = store(); diff --git a/rust/crates/control-plane/src/turso.rs b/rust/crates/control-plane/src/turso.rs index 16a3cfd2..52e55896 100644 --- a/rust/crates/control-plane/src/turso.rs +++ b/rust/crates/control-plane/src/turso.rs @@ -1337,6 +1337,17 @@ impl ControlPlaneStore for TursoControlPlaneStore { ) .optional()? .ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?; + // 登录成功后对齐同用户全部 password_* identity 的哈希。 + // 避免仅更新邮箱 identity 后,用户名登录仍用旧密码(liaibo 线上漂移过)。 + let now = now_text(); + let _ = conn.execute( + "UPDATE auth_identities + SET password_hash = ?1, updated_at = ?2 + WHERE user_id = ?3 + AND provider IN ('password_username', 'password_email') + AND password_hash != ?1", + params![expected_hash, now, user.id], + )?; drop(conn); let session = self.create_session(CreateSessionInput { @@ -5235,6 +5246,50 @@ mod tests { .is_none()); } + #[test] + fn authenticate_password_syncs_sibling_password_identity_hashes() { + let store = store(); + let current_password = ["current", "secret"].join("-"); + let stale_password = ["stale", "secret"].join("-"); + create_password_identity(&store, "dana", "dana@example.com", ¤t_password); + + // 模拟仅邮箱 identity 被改密、用户名 identity 仍是旧哈希的漂移。 + { + let conn = store.lock_conn().expect("lock"); + conn.execute( + "UPDATE auth_identities + SET password_hash = ?1 + WHERE provider = 'password_username' AND provider_subject = 'dana'", + params![password_hash_v1(&stale_password)], + ) + .expect("stale username hash"); + } + + store + .authenticate_password(AuthenticatePasswordInput { + account: "dana@example.com".to_string(), + password: current_password.clone(), + session_id: None, + token_hash: session_token_hash("dana-email-repair"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("email login should still work"); + + store + .authenticate_password(AuthenticatePasswordInput { + account: "dana".to_string(), + password: current_password, + session_id: None, + token_hash: session_token_hash("dana-username-after-sync"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("username login should work after sibling hash sync"); + } + // --- Fault injection tests --- #[test] diff --git a/rust/crates/mnote-vault-core/Cargo.toml b/rust/crates/mnote-vault-core/Cargo.toml new file mode 100644 index 00000000..a474d64f --- /dev/null +++ b/rust/crates/mnote-vault-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mnote-vault-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Local-first password vault store + agent token (no mnote-web)" + +[dependencies] +base64 = "0.22" +hex = "0.4" +hmac = "0.12" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +time = { version = "0.3", features = ["formatting", "local-offset"] } +uuid = { version = "1", features = ["v4"] } diff --git a/rust/crates/mnote-vault-core/src/ai.rs b/rust/crates/mnote-vault-core/src/ai.rs new file mode 100644 index 00000000..381483fd --- /dev/null +++ b/rust/crates/mnote-vault-core/src/ai.rs @@ -0,0 +1,899 @@ +//! AI password-book path (list / get / resolve / login / session) without mnote-web. + +use crate::error::VaultError; +use crate::store::{ + self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials, + load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher, + project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session, + resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord, + VaultItemStatus, VaultLoginPlaybook, VaultLoginSession, +}; +use crate::token::DEFAULT_ACTOR; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +const DEFAULT_DATA_BASE: &str = "/mnt/Data1T/Mnote_data"; + +pub fn ai_vault_actor_id() -> String { + std::env::var("MNOTE_AI_VAULT_ACTOR") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_ACTOR.to_string()) +} + +/// Encode actor id for managed path segment (aligned with mnote-web). +pub fn encode_actor_segment(actor_id: &str) -> String { + actor_id + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c.to_string(), + other => format!("~{:02x}", other as u32), + }) + .collect() +} + +pub fn managed_data_base() -> PathBuf { + std::env::var("MNOTE_DATA_DIR") + .or_else(|_| std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_BASE)) +} + +pub fn ai_vault_workspace_root() -> PathBuf { + if let Ok(p) = std::env::var("MNOTE_VAULT_WORKSPACE") { + let p = p.trim(); + if !p.is_empty() { + return PathBuf::from(p); + } + } + let actor = encode_actor_segment(&ai_vault_actor_id()); + managed_data_base() + .join("users") + .join(actor) + .join("workspaces") + .join("my-space") +} + +pub fn ensure_ai_vault_workspace() -> Result { + let root = ai_vault_workspace_root(); + if !root.exists() { + std::fs::create_dir_all(root.join(".mnote")).map_err(|e| { + VaultError::bad_request_code( + "vault_ai_root_unavailable", + format!("无法创建 AI 密码本工作区 {}: {e}", root.display()), + ) + })?; + } + let root = root.canonicalize().map_err(|e| { + VaultError::bad_request_code( + "vault_ai_root_unavailable", + format!("无法访问 AI 密码本工作区: {e}"), + ) + })?; + let _ = ensure_vault_directories(&root)?; + Ok(root) +} + +fn normalize_secret_field(field: &str) -> Result<&'static str, VaultError> { + match field.trim() { + "password" => Ok("password"), + "apikey" | "apiKey" | "api_key" => Ok("apikey"), + "token" => Ok("token"), + "username" => Ok("username"), + "email" => Ok("email"), + other => Err(VaultError::bad_request_code( + "vault_resolve_field_invalid", + format!("不支持字段: {other};允许 password|apikey|token|username|email"), + )), + } +} + +pub fn list_ai_vault_items(status: VaultItemStatus) -> Result { + let root = ensure_ai_vault_workspace()?; + let (index, items) = list_credentials(&root, status)?; + let book = load_cipher_book(&root).ok(); + let book_ref = book.as_ref(); + let projected: Vec = items + .iter() + .map(|e| project_list_entry_with_cipher(e, book_ref)) + .collect(); + Ok(json!({ + "schema": "mnote.vault.list.v1", + "status": status.as_str(), + "revision": index.revision, + "updatedAt": index.updated_at, + "items": projected, + "isAiVault": true, + "aiVaultActorId": ai_vault_actor_id(), + "vaultRole": "ai", + "transport": "local-core", + })) +} + +pub fn get_ai_vault_item(credential_id: &str) -> Result { + let root = ensure_ai_vault_workspace()?; + let record = get_credential(&root, credential_id)?; + Ok(json!({ + "item": project_item_l0_with_cipher(&record, Some(&root)), + "isAiVault": true, + "aiVaultActorId": ai_vault_actor_id(), + "transport": "local-core", + })) +} + +pub fn resolve_ai_vault_secret( + credential_id: &str, + field: &str, + actor: &str, + request_id: Option<&str>, + account_id: Option<&str>, + secret_id: Option<&str>, +) -> Result { + let field_norm = normalize_secret_field(field)?; + let account_id = account_id.map(str::trim).filter(|v| !v.is_empty()); + let secret_id = secret_id.map(str::trim).filter(|v| !v.is_empty()); + let root = ensure_ai_vault_workspace()?; + let record = match get_credential(&root, credential_id) { + Ok(r) => r, + Err(err) => { + let _ = append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + false, + ); + return Err(err); + } + }; + if record.status != VaultItemStatus::Active { + let _ = append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + false, + ); + return Err(VaultError::bad_request_code( + "vault_resolve_inactive", + "只能 resolve 在用条目", + )); + } + let secret = resolve_record_secret_field(&record, field_norm, account_id, secret_id); + let secret_proj = match project_secret_revealed(&root, secret) { + Ok(v) => v, + Err(err) => { + let _ = append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + false, + ); + return Err(err); + } + }; + let _ = append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + true, + ); + let value = secret_proj + .get("value") + .and_then(Value::as_str) + .unwrap_or(""); + let state = secret_proj + .get("state") + .and_then(Value::as_str) + .unwrap_or("absent"); + let mut out = json!({ + "schema": "mnote.vault.resolve.v1", + "id": credential_id, + "field": field_norm, + "resolved": state == "revealed", + "state": state, + "value": if state == "revealed" { Value::String(value.to_string()) } else { Value::Null }, + "usedCipherKeys": secret_proj.get("usedCipherKeys").cloned().unwrap_or(json!([])), + "missingCipherKeys": secret_proj.get("missingCipherKeys").cloned().unwrap_or(json!([])), + "template": secret_proj.get("template").cloned().unwrap_or(Value::Null), + "aiVaultActorId": ai_vault_actor_id(), + "transcriptHint": format!("已解析 {field_norm}"), + "note": "多 agent 唯一读密通道;勿把 value 写入聊天/commit/RAG", + "transport": "local-core", + }); + if let Some(aid) = account_id { + out["accountId"] = json!(aid); + } + if let Some(sid) = secret_id { + out["secretId"] = json!(sid); + } + let _ = store::vault_root(Path::new(&root)); + Ok(out) +} + +fn default_session_expires_hours() -> i64 { + std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .filter(|h| *h > 0) + .unwrap_or(168) // 7 days +} + +fn session_expires_rfc3339_from_now() -> String { + let hours = default_session_expires_hours(); + let t = time::OffsetDateTime::now_utc() + time::Duration::hours(hours); + t.format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| now_rfc3339()) +} + +fn origin_from_credential_url(url: Option<&str>) -> Result { + let raw = url + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + VaultError::bad_request_code( + "vault_login_url_required", + "条目缺少 url,无法推导登录 origin", + ) + })?; + let with_scheme = if raw.contains("://") { + raw.to_string() + } else { + format!("https://{raw}") + }; + let parsed = reqwest::Url::parse(&with_scheme).map_err(|e| { + VaultError::bad_request_code( + "vault_login_url_invalid", + format!("无法解析 url: {e}"), + ) + })?; + if let Some(port) = parsed.port() { + return Ok(format!( + "{}://{}:{}", + parsed.scheme(), + parsed.host_str().unwrap_or(""), + port + )); + } + let origin = format!( + "{}://{}", + parsed.scheme(), + parsed.host_str().unwrap_or("") + ); + if origin.ends_with("://") { + return Err(VaultError::bad_request_code( + "vault_login_url_invalid", + "url 缺少 host", + )); + } + Ok(origin) +} + +fn pick_login_account( + workspace_root: &Path, + record: &VaultCredentialRecord, + preferred: &str, +) -> String { + let pref = preferred.trim().to_ascii_lowercase(); + let expand = |raw: Option<&str>| -> Option { + let t = raw.map(str::trim).filter(|s| !s.is_empty())?; + match resolve_secret_with_cipher_book(workspace_root, Some(t)) { + Ok(Some(resolved)) => { + let v = resolved.value.trim().to_string(); + if v.is_empty() { + None + } else { + Some(v) + } + } + _ => Some(t.to_string()), + } + }; + let email = expand(record.email.as_deref()).or_else(|| { + record + .accounts + .first() + .and_then(|a| expand(a.email.as_deref())) + }); + let user = expand(record.username.as_deref()).or_else(|| { + record + .accounts + .first() + .and_then(|a| expand(a.username.as_deref())) + }); + match pref.as_str() { + "email" => email.or(user).unwrap_or_default(), + "username" => user.or(email).unwrap_or_default(), + _ => email.or(user).unwrap_or_default(), + } +} + +fn merge_set_cookie_into_header(existing: &str, set_cookies: &[String]) -> String { + use std::collections::BTreeMap; + let mut map: BTreeMap = BTreeMap::new(); + for part in existing.split(';') { + let p = part.trim(); + if p.is_empty() { + continue; + } + if let Some((k, v)) = p.split_once('=') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + for sc in set_cookies { + let first = sc.split(';').next().unwrap_or("").trim(); + if let Some((k, v)) = first.split_once('=') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + map.iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("; ") +} + +/// Write browser-captured cookies into AI password book (no mnote-web). +pub fn put_ai_vault_session( + credential_id: &str, + cookie_header: &str, + expires_at: Option<&str>, + source: &str, + actor: &str, + request_id: Option<&str>, +) -> Result { + let root = ensure_ai_vault_workspace()?; + let cookie = cookie_header.trim(); + if cookie.is_empty() { + return Err(VaultError::bad_request_code( + "vault_session_cookie_required", + "cookieHeader 不能为空", + )); + } + let now = time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| now_rfc3339()); + let expires = expires_at + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(session_expires_rfc3339_from_now); + let session = VaultLoginSession { + cookie_header: Some(cookie.to_string()), + expires_at: Some(expires.clone()), + last_login_at: Some(now), + source: Some(source.trim().to_string()), + }; + let record = put_login_session(&root, credential_id, session)?; + let _ = append_vault_audit( + &root, + "session_put", + actor, + credential_id, + Some(source), + request_id, + true, + ); + Ok(json!({ + "schema": "mnote.vault.session.v1", + "id": credential_id, + "ok": true, + "hasLoginSession": true, + "expiresAt": expires, + "source": source, + "item": project_item_l0_with_cipher(&record, Some(&root)), + "transcriptHint": "已回写登录态", + "transport": "local-core", + })) +} + +/// Local login against AI password book (no mnote-web): +/// reuse fresh session → else api_first password login → save session. +/// Cloudflare / captcha → human_required (agent uses browser tools then `session`). +pub fn login_ai_vault_credential( + credential_id: &str, + force_refresh: bool, + actor: &str, + request_id: Option<&str>, +) -> Result { + let root = ensure_ai_vault_workspace()?; + let mut record = get_credential(&root, credential_id)?; + if record.status != VaultItemStatus::Active { + return Err(VaultError::bad_request_code( + "vault_login_inactive", + "只能登录在用条目", + )); + } + + if record.login_playbook.is_none() { + let pb = VaultLoginPlaybook::default_for_credential( + record.email.as_deref(), + record.username.as_deref(), + ); + record = put_login_playbook(&root, credential_id, pb)?; + } + let playbook = record.login_playbook.clone().unwrap_or_else(|| { + VaultLoginPlaybook::default_for_credential( + record.email.as_deref(), + record.username.as_deref(), + ) + }); + + if playbook.is_human_required() && !force_refresh { + if let Some(sess) = record.login_session.as_ref() { + if login_session_is_fresh(sess) { + let cookie = sess.cookie_header.clone().unwrap_or_default(); + let _ = append_vault_audit( + &root, + "login_reuse", + actor, + credential_id, + Some("session"), + request_id, + true, + ); + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": true, + "mode": "session", + "cookieHeader": cookie, + "expiresAt": sess.expires_at, + "loginPlaybook": playbook, + "transcriptHint": "已复用登录态", + "note": "playbook=human_required;有可用 session 直接复用", + "transport": "local-core", + })); + } + } + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "human_required", + "ok": false, + "code": "vault_login_human_required", + "message": "需要人完成验证码/Cloudflare 后回写登录态", + "loginPlaybook": playbook, + "humanInstructions": { + "local": "用 chrome-bridge 打开 loginUrl 完成验证并登录,然后 mnote-vault session --id --cookie-header '…'", + "remote": "用 Paseo 浏览器工具完成验证后同样调用 session 回写", + "cli": format!("mnote-vault session --id {credential_id} --cookie-header ''"), + }, + "transcriptHint": "需要人工验证后回写 session", + "transport": "local-core", + })); + } + + if !force_refresh { + if let Some(sess) = record.login_session.as_ref() { + if login_session_is_fresh(sess) { + let cookie = sess.cookie_header.clone().unwrap_or_default(); + let _ = append_vault_audit( + &root, + "login_reuse", + actor, + credential_id, + Some("session"), + request_id, + true, + ); + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": true, + "mode": "session", + "cookieHeader": cookie, + "expiresAt": sess.expires_at, + "source": sess.source, + "loginPlaybook": { + "mode": playbook.mode, + "preferredAccount": playbook.preferred_account, + }, + "accountHint": pick_login_account(&root, &record, &playbook.preferred_account), + "url": record.url, + "transcriptHint": "已复用登录态", + "transport": "local-core", + })); + } + } + } + + let password_resolved = resolve_ai_vault_secret( + credential_id, + "password", + actor, + request_id, + None, + None, + )?; + if password_resolved + .get("resolved") + .and_then(Value::as_bool) + != Some(true) + { + return Err(VaultError::bad_request_code( + "vault_login_no_password", + "条目无可用 password,无法自动登录", + )); + } + let password = password_resolved + .get("value") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let account = pick_login_account(&root, &record, &playbook.preferred_account); + if account.is_empty() { + return Err(VaultError::bad_request_code( + "vault_login_no_account", + "条目缺少 email/username", + )); + } + + let mode = playbook.mode.trim().to_ascii_lowercase(); + if mode == "browser" { + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "browser", + "ok": false, + "code": "vault_login_browser_required", + "message": "playbook 要求浏览器登录;agent 用 chrome-bridge/Paseo 浏览器按 selectors 填表后 session 回写", + "loginPlaybook": playbook, + "account": account, + "passwordResolved": true, + "url": record.url, + "transcriptHint": "需要浏览器按 playbook 登录后回写 session", + "humanInstructions": { + "local": "chrome-bridge 登录后 mnote-vault session", + "cli": format!("mnote-vault session --id {credential_id} --cookie-header ''"), + }, + "transport": "local-core", + })); + } + + // api_first (default) — outbound HTTP only; does not need mnote-web. + let origin = origin_from_credential_url(record.url.as_deref())?; + let api_path = playbook + .api_path + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("/api/auth"); + let login_url = if api_path.starts_with("http") { + api_path.to_string() + } else { + format!( + "{}{}", + origin.trim_end_matches('/'), + if api_path.starts_with('/') { + api_path.to_string() + } else { + format!("/{api_path}") + } + ) + }; + + let body = json!({ + "action": "auth:signIn", + "args": { + "provider": "password", + "params": { + "password": password, + "flow": "signIn", + "account": account, + "email": if account.contains('@') { Value::String(account.clone()) } else { Value::Null }, + "name": if account.contains('@') { + account.split('@').next().unwrap_or(&account) + } else { + account.as_str() + }, + } + } + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| { + VaultError::bad_request_code("vault_login_client_failed", format!("HTTP 客户端: {e}")) + })?; + + let response = client + .post(&login_url) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&body) + .send() + .map_err(|e| { + let msg = e.to_string(); + if msg.to_ascii_lowercase().contains("cloudflare") + || msg.to_ascii_lowercase().contains("403") + { + VaultError::bad_request_code( + "vault_login_human_required", + format!("登录请求失败,可能需人机验证: {e}"), + ) + } else { + VaultError::bad_request_code( + "vault_login_http_failed", + format!("登录请求失败: {e}"), + ) + } + })?; + + let status = response.status(); + let set_cookies: Vec = response + .headers() + .get_all(reqwest::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok().map(str::to_string)) + .collect(); + let resp_text = response.text().unwrap_or_default(); + + let lower = resp_text.to_ascii_lowercase(); + if status.as_u16() == 403 + || lower.contains("cloudflare") + || lower.contains("cf-challenge") + || lower.contains("captcha") + || lower.contains("just a moment") + { + let _ = append_vault_audit( + &root, + "login_challenge", + actor, + credential_id, + Some("human"), + request_id, + false, + ); + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "human_required", + "ok": false, + "code": "vault_login_human_required", + "message": "检测到验证码/Cloudflare/拦截,需人完成验证后回写 session", + "httpStatus": status.as_u16(), + "loginPlaybook": playbook, + "url": record.url, + "transcriptHint": "需要人工验证后回写 session", + "humanInstructions": { + "local": "chrome-bridge 完成验证登录 → mnote-vault session", + "cli": format!("mnote-vault session --id {credential_id} --cookie-header ''"), + }, + "transport": "local-core", + })); + } + + let mut json_ok = false; + if let Ok(v) = serde_json::from_str::(&resp_text) { + if v.get("error").is_none() && (status.is_success() || status.as_u16() == 200) { + json_ok = true; + } + if let Some(err) = v.get("error").and_then(Value::as_str) { + let _ = append_vault_audit( + &root, + "login_failed", + actor, + credential_id, + Some("api"), + request_id, + false, + ); + return Err(VaultError::bad_request_code( + "vault_login_rejected", + format!("远端登录拒绝: {err}"), + )); + } + } + if !status.is_success() && !json_ok && set_cookies.is_empty() { + let _ = append_vault_audit( + &root, + "login_failed", + actor, + credential_id, + Some("api"), + request_id, + false, + ); + return Err(VaultError::bad_request_code( + "vault_login_rejected", + format!("远端登录失败 HTTP {}", status.as_u16()), + )); + } + + let cookie_header = merge_set_cookie_into_header("", &set_cookies); + if cookie_header.is_empty() { + return Err(VaultError::bad_request_code( + "vault_login_no_cookie", + "登录响应未包含 Set-Cookie;请改用浏览器登录并 session 回写", + )); + } + + let now = time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "now".into()); + let expires = session_expires_rfc3339_from_now(); + let session = VaultLoginSession { + cookie_header: Some(cookie_header.clone()), + expires_at: Some(expires.clone()), + last_login_at: Some(now), + source: Some("api".into()), + }; + let _ = put_login_session(&root, credential_id, session)?; + let _ = append_vault_audit( + &root, + "login_ok", + actor, + credential_id, + Some("api"), + request_id, + true, + ); + + Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "api", + "ok": true, + "cookieHeader": cookie_header, + "expiresAt": expires, + "account": account, + "url": record.url, + "loginPlaybook": { + "mode": playbook.mode, + "preferredAccount": playbook.preferred_account, + }, + "transcriptHint": "已登录并保存登录态", + "note": "下次同 id 调用 login 将优先复用 session,直到过期或 forceRefresh", + "transport": "local-core", + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{ + create_credential, purge_credential, soft_delete_credential, VaultCreateInput, + }; + use std::sync::Mutex; + use std::time::{SystemTime, UNIX_EPOCH}; + + static AI_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn with_temp_workspace(f: F) -> R + where + F: FnOnce(&Path) -> R, + { + let _guard = AI_TEST_LOCK.lock().unwrap(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("mnote-vault-ai-session-{nanos}")); + std::fs::create_dir_all(&root).unwrap(); + std::env::set_var("MNOTE_VAULT_WORKSPACE", &root); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(root.as_path()))); + std::env::remove_var("MNOTE_VAULT_WORKSPACE"); + let _ = std::fs::remove_dir_all(&root); + match result { + Ok(v) => v, + Err(payload) => std::panic::resume_unwind(payload), + } + } + + #[test] + fn put_session_then_login_reuses_without_http() { + with_temp_workspace(|root| { + let created = create_credential( + root, + VaultCreateInput { + title: "session-reuse-core".into(), + url: Some("https://example.com/auth".into()), + email: Some("a@example.com".into()), + password: Some("secret-pass".into()), + tags: vec!["ai-shared".into()], + ..Default::default() + }, + ) + .expect("create"); + let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(2)) + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + let put = put_ai_vault_session( + &created.id, + "mnote_session=abc; other=1", + Some(&expires), + "human_bridge", + "tester", + Some("req-session"), + ) + .expect("session put"); + assert_eq!(put["ok"], true); + assert_eq!(put["hasLoginSession"], true); + assert_eq!(put["transport"], "local-core"); + + let login = login_ai_vault_credential(&created.id, false, "tester", None) + .expect("login reuse"); + assert_eq!(login["reused"], true); + assert_eq!(login["mode"], "session"); + assert_eq!(login["cookieHeader"], "mnote_session=abc; other=1"); + assert_eq!(login["transport"], "local-core"); + + let _ = soft_delete_credential(root, &created.id); + let _ = purge_credential(root, &created.id); + }); + } + + #[test] + fn human_required_without_session_returns_instructions() { + with_temp_workspace(|root| { + let created = create_credential( + root, + VaultCreateInput { + title: "human-required-core".into(), + url: Some("https://example.com/login".into()), + email: Some("h@example.com".into()), + password: Some("pw".into()), + tags: vec!["ai-shared".into()], + ..Default::default() + }, + ) + .expect("create"); + let mut pb = VaultLoginPlaybook::default_for_credential(Some("h@example.com"), None); + pb.mode = "human_required".into(); + let _ = put_login_playbook(root, &created.id, pb).expect("playbook"); + + let login = login_ai_vault_credential(&created.id, false, "tester", None) + .expect("login human"); + assert_eq!(login["reused"], false); + assert_eq!(login["mode"], "human_required"); + assert_eq!(login["ok"], false); + assert_eq!(login["code"], "vault_login_human_required"); + assert!(login["humanInstructions"]["cli"].as_str().unwrap().contains(&created.id)); + + let _ = soft_delete_credential(root, &created.id); + let _ = purge_credential(root, &created.id); + }); + } + + #[test] + fn put_session_rejects_empty_cookie() { + with_temp_workspace(|root| { + let created = create_credential( + root, + VaultCreateInput { + title: "empty-cookie".into(), + email: Some("e@example.com".into()), + password: Some("pw".into()), + tags: vec!["ai-shared".into()], + ..Default::default() + }, + ) + .expect("create"); + let err = put_ai_vault_session(&created.id, " ", None, "api", "tester", None) + .expect_err("empty cookie"); + assert_eq!(err.code, "vault_session_cookie_required"); + let _ = soft_delete_credential(root, &created.id); + let _ = purge_credential(root, &created.id); + }); + } +} diff --git a/rust/crates/mnote-vault-core/src/error.rs b/rust/crates/mnote-vault-core/src/error.rs new file mode 100644 index 00000000..f3cafff8 --- /dev/null +++ b/rust/crates/mnote-vault-core/src/error.rs @@ -0,0 +1,109 @@ +//! Vault errors without axum — map to HTTP-like codes for CLI / vaultd. + +use serde_json::Value; +use std::fmt; + +/// Logical status (subset used by vault_store). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VaultStatus { + BadRequest, + NotFound, + Conflict, + Unauthorized, + Forbidden, + Locked, + Unavailable, + Internal, +} + +impl VaultStatus { + pub fn as_u16(self) -> u16 { + match self { + Self::BadRequest => 400, + Self::Unauthorized => 401, + Self::Forbidden => 403, + Self::NotFound => 404, + Self::Conflict => 409, + Self::Locked => 423, + Self::Unavailable => 503, + Self::Internal => 500, + } + } +} + +#[derive(Debug, Clone)] +pub struct VaultError { + pub status: VaultStatus, + pub code: &'static str, + pub message: String, + pub details: Option, +} + +impl VaultError { + pub fn new(status: VaultStatus, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + details: None, + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(VaultStatus::BadRequest, "bad_request", message) + } + + pub fn bad_request_code(code: &'static str, message: impl Into) -> Self { + Self::new(VaultStatus::BadRequest, code, message) + } + + pub fn not_found_code(code: &'static str, message: impl Into) -> Self { + Self::new(VaultStatus::NotFound, code, message) + } + + pub fn unauthorized_code(code: &'static str, message: impl Into) -> Self { + Self::new(VaultStatus::Unauthorized, code, message) + } + + pub fn forbidden_code(code: &'static str, message: impl Into) -> Self { + Self::new(VaultStatus::Forbidden, code, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(VaultStatus::Internal, "internal_error", message) + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + + /// Compatible with mnote-web `WebError::code()` (store was copied as-is). + pub fn code(&self) -> &str { + self.code + } + + pub fn status(&self) -> VaultStatus { + self.status + } +} + +impl fmt::Display for VaultError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for VaultError {} + +// Compatibility aliases used by store.rs (former WebError + StatusCode patterns). +impl VaultError { + /// Former `WebError::new(StatusCode::NOT_FOUND, code, msg)`. + pub fn from_status( + status: VaultStatus, + code: &'static str, + message: impl Into, + ) -> Self { + Self::new(status, code, message) + } +} diff --git a/rust/crates/mnote-vault-core/src/frontmatter.rs b/rust/crates/mnote-vault-core/src/frontmatter.rs new file mode 100644 index 00000000..4980664a --- /dev/null +++ b/rust/crates/mnote-vault-core/src/frontmatter.rs @@ -0,0 +1,28 @@ +//! Minimal frontmatter splitter (copied from mnote-web local_markdown_parser). + +/// Split YAML frontmatter between leading `---\\n` and next `\\n---\\n`. +pub fn split_frontmatter(markdown: &str) -> (Option, &str) { + let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown); + if !normalized.starts_with("---\n") { + return (None, normalized); + } + let rest = &normalized[4..]; + if let Some(end) = rest.find("\n---\n") { + let frontmatter = rest[..end].to_string(); + let body = &rest[end + 5..]; + return (Some(frontmatter), body); + } + (None, normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_basic() { + let (fm, body) = split_frontmatter("---\nid: x\n---\n\nhello\n"); + assert_eq!(fm.as_deref(), Some("id: x")); + assert!(body.contains("hello")); + } +} diff --git a/rust/crates/mnote-vault-core/src/lib.rs b/rust/crates/mnote-vault-core/src/lib.rs new file mode 100644 index 00000000..b884d0b6 --- /dev/null +++ b/rust/crates/mnote-vault-core/src/lib.rs @@ -0,0 +1,22 @@ +//! Local-first password vault core (12-2). +//! +//! File SSOT under `{workspace}/.mnote/vault/`. Read path for agents does **not** +//! require mnote-web — use token + list/get/resolve APIs in this crate / `mnote-vault` CLI. + +pub mod ai; +pub mod error; +pub mod frontmatter; +pub mod store; +pub mod token; + +pub use ai::{ + ai_vault_actor_id, ai_vault_workspace_root, ensure_ai_vault_workspace, get_ai_vault_item, + list_ai_vault_items, login_ai_vault_credential, put_ai_vault_session, resolve_ai_vault_secret, +}; +pub use error::{VaultError, VaultStatus}; +pub use store::{VaultCredentialRecord, VaultItemStatus}; +pub use token::{ + default_hmac_key_path, default_sock_path, default_token_path, issue_token, load_or_create_hmac_key, + read_token_from_env_or_file, require_actor, require_scope, verify_token, write_token_file, + VaultTokenClaims, +}; diff --git a/rust/crates/mnote-vault-core/src/store.rs b/rust/crates/mnote-vault-core/src/store.rs new file mode 100644 index 00000000..26b285d1 --- /dev/null +++ b/rust/crates/mnote-vault-core/src/store.rs @@ -0,0 +1,4412 @@ +//! Local-first password vault store under `{workspaceRoot}/.mnote/vault/`. +//! +//! Disk: per-entry Markdown + L0 `vault-index.json` + append-only `audit.jsonl` +//! + optional `cipher-book.json` (shared fragments, e.g. `[A]` / `[QY]` → value). +//! Cipher placeholders apply to password/apikey/token **and** username/email +//! (store templates; expand on list/detail view, reveal, resolve, login). +//! Secrets never appear in the index; API projections mask them by default. +//! +//! Multi-account / multi-secret (preferred): one site entry holds `accounts[]` +//! each with nested `secrets[]` (API keys / tokens as appendix under that login). +//! Top-level `secrets[]` is a flattened mirror of all account secrets (legacy +//! files + L0/AI resolve). Legacy username/password/apikey/token remain primary +//! mirrors of the first account / first matching secret. +//! Multiple equivalent site URLs: `urls[]` with primary `url` = first entry +//! (fallback list: VPS / FRP / local, etc.). +//! Optional `folderPath` (`a/b/c`) drives collapsible tree grouping (not a FS path). + +use crate::error::{VaultError as WebError, VaultStatus as StatusCode}; +use crate::frontmatter::split_frontmatter; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; +use uuid::Uuid; + +pub const VAULT_INDEX_SCHEMA: &str = "mnote.vault.index.v1"; +pub const VAULT_CREDENTIAL_SCHEMA: &str = "mnote.vault.credential.v1"; +pub const VAULT_CIPHER_BOOK_SCHEMA: &str = "mnote.vault.cipherBook.v1"; +pub const VAULT_KIND: &str = "credential"; + +const FORBIDDEN_FIELD_KEYS: &[&str] = &[ + "password", + "token", + "apikey", + "api_key", + "secret", + "otp_secret", + "otpsecret", + "recovery", + "recovery_code", + "recoverycode", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VaultItemStatus { + Active, + Deleted, +} + +impl VaultItemStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Deleted => "deleted", + } + } + + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "active" => Some(Self::Active), + "deleted" => Some(Self::Deleted), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultAttachmentRef { + pub ref_id: String, + pub kind: String, + pub relative_path: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, +} + +/// One login identity under a site credential (collapsible group in the form). +/// Appendix secrets (API Key / Token) hang under the account, not the whole site. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultAccountSlot { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_hint: Option, + /// Per-account appendix secrets (API Key / Token / other). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secrets: Vec, +} + +/// Appendix secret (API Key / Token / other) under an account (preferred) +/// or top-level flatten mirror for legacy / L0 resolve. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultSecretSlot { + pub id: String, + /// `apikey` | `token` | `other` + #[serde(default = "default_secret_kind")] + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + /// Owning account id when flattened at top-level (optional on nested form). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, +} + +fn default_secret_kind() -> String { + "apikey".into() +} + +/// Outbound link: this credential was shared to an AI password-book copy. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VaultShareToAi { + pub target_actor_id: String, + pub target_id: String, + pub shared_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_synced_at: Option, +} + +/// Inbound link: this credential is a copy shared from a user's vault. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VaultShareFrom { + pub source_actor_id: String, + pub source_id: String, + pub shared_at: String, +} + +/// How agents should log in with this credential (non-secret playbook). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultLoginPlaybook { + /// api_first | browser | human_required + #[serde(default)] + pub mode: String, + /// email | username | auto + #[serde(default)] + pub preferred_account: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub success_url_contains: Option, + /// Guidance when Cloudflare / captcha needs a human. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub human_note: Option, +} + +/// Captured browser/API session for multi-agent reuse (cookie header is secret). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultLoginSession { + /// Full Cookie request header value (secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cookie_header: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_login_at: Option, + /// api | browser | human_bridge + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +impl VaultLoginPlaybook { + pub fn default_for_credential(email: Option<&str>, username: Option<&str>) -> Self { + let preferred = if email.map(str::trim).filter(|s| !s.is_empty()).is_some() { + "email" + } else if username.map(str::trim).filter(|s| !s.is_empty()).is_some() { + "username" + } else { + "auto" + }; + Self { + mode: "api_first".into(), + preferred_account: preferred.into(), + api_path: Some("/api/auth".into()), + account_selector: Some("#account".into()), + password_selector: Some("#password".into()), + submit_selector: Some("button[type=submit]".into()), + success_url_contains: Some("/".into()), + human_note: Some( + "若 Cloudflare/验证码拦截:人用本机 chrome-bridge 或 Paseo 远程浏览器完成验证后,调用 vault session 回写 cookie。" + .into(), + ), + } + } + + pub fn is_human_required(&self) -> bool { + self.mode.trim().eq_ignore_ascii_case("human_required") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultCredentialRecord { + pub id: String, + pub title: String, + /// Primary / preferred URL (mirror of `urls[0]` when multi-url is used). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Equivalent access URLs for the same site (fallbacks: VPS, FRP, LAN, …). + /// First entry is always synced into `url`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub urls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub apikey: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + /// Multiple login identities for one site (preferred over multiple list entries). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub accounts: Vec, + /// Multiple API keys / tokens for one site (appendix). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secrets: Vec, + /// Logical tree folder for UI grouping, e.g. `个人/银行/招商` (not a disk path). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_path: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub fields: BTreeMap, + #[serde(default)] + pub attachments: Vec, + #[serde(default)] + pub notes_markdown: String, + pub status: VaultItemStatus, + pub created_at: String, + pub updated_at: String, + pub relative_path: String, + #[serde(default)] + pub revision: u64, + /// Present on user vault entries that have been shared to AI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_to_ai: Option, + /// Present on AI vault copies cloned from a user credential. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_from: Option, + /// Agent login method (non-secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_playbook: Option, + /// Reusable login session (cookie secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_session: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultIndexEntry { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Extra equivalent URLs (excluding primary `url`); full list = url + urls. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub urls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_path: Option, + #[serde(default)] + pub tags: Vec, + pub has_password: bool, + pub has_apikey: bool, + pub has_token: bool, + #[serde(default)] + pub account_count: usize, + #[serde(default)] + pub secret_count: usize, + pub attachment_count: usize, + pub relative_path: String, + pub updated_at: String, + pub status: VaultItemStatus, + #[serde(default)] + pub revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_to_ai: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_from: Option, + /// Non-secret: whether a login session cookie is stored. + #[serde(default)] + pub has_login_session: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_mode: Option, +} + +/// Shared password-fragment book: password templates use `[Key]` placeholders. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CipherBook { + pub schema: String, + pub revision: u64, + pub updated_at: String, + /// Key → plaintext fragment. Never projected in L0 list. + #[serde(default)] + pub fragments: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct ResolvedSecret { + pub value: String, + pub template: Option, + pub used_keys: Vec, + pub missing_keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultIndex { + pub schema: String, + pub revision: u64, + pub updated_at: String, + pub entries: BTreeMap, +} + +#[derive(Debug, Clone, Default)] +pub struct VaultCreateInput { + pub title: String, + pub url: Option, + /// When non-empty, replaces/merges with `url` into ordered unique list. + pub urls: Vec, + pub username: Option, + pub password: Option, + pub password_hint: Option, + pub email: Option, + pub apikey: Option, + pub token: Option, + pub accounts: Vec, + pub secrets: Vec, + pub folder_path: Option, + pub tags: Vec, + pub fields: BTreeMap, + pub notes_markdown: String, +} + +/// Secret field patch: omit / keep / clear / set. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum SecretPatch { + #[default] + Omit, + Clear, + Set(String), +} + +#[derive(Debug, Clone, Default)] +pub struct VaultUpdateInput { + pub title: Option, + pub url: Option>, + /// When `Some`, fully replace equivalent URL list (empty clears all URLs). + /// Primary `url` is derived as `urls[0]`. + pub urls: Option>, + pub username: Option>, + pub email: Option>, + pub password_hint: Option>, + pub folder_path: Option>, + pub tags: Option>, + pub fields: Option>, + pub notes_markdown: Option, + pub password: SecretPatch, + pub apikey: SecretPatch, + pub token: SecretPatch, + /// When `Some`, fully replace multi-account slots (empty vec clears). + pub accounts: Option>, + /// When `Some`, fully replace multi-secret appendix (empty vec clears). + pub secrets: Option>, + pub base_revision: Option, +} + +pub fn vault_root(workspace_root: &Path) -> PathBuf { + workspace_root.join(".mnote").join("vault") +} + +pub fn ensure_vault_directories(workspace_root: &Path) -> Result { + let root = vault_root(workspace_root); + for relative in [ + "", + "entries", + "attachments", + "trash", + "trash/entries", + "trash/attachments", + ] { + let path = if relative.is_empty() { + root.clone() + } else { + root.join(relative) + }; + fs::create_dir_all(&path).map_err(|error| { + WebError::bad_request_code( + "vault_ensure_failed", + format!("无法创建密码箱目录 {}: {error}", path.display()), + ) + })?; + } + Ok(root) +} + +pub fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()) +} + +fn new_credential_id() -> String { + format!("cred_{}", Uuid::new_v4().simple()) +} + +fn new_account_id() -> String { + format!("acc_{}", Uuid::new_v4().simple()) +} + +fn new_secret_id() -> String { + format!("sec_{}", Uuid::new_v4().simple()) +} + +fn opt_nonempty(value: Option) -> Option { + value.and_then(|v| { + let t = v.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }) +} + +fn normalize_secret_kind(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "api_key" | "api-key" | "apikey" | "key" => "apikey".into(), + "token" | "access_token" | "bearer" => "token".into(), + other if !other.is_empty() => other.to_string(), + _ => "apikey".into(), + } +} + +fn sanitize_account_slot(mut slot: VaultAccountSlot) -> Result { + if slot.id.trim().is_empty() { + slot.id = new_account_id(); + } + slot.label = opt_nonempty(slot.label); + slot.username = opt_nonempty(slot.username); + slot.email = opt_nonempty(slot.email); + slot.password_hint = opt_nonempty(slot.password_hint); + if let Some(password) = slot.password.as_deref() { + if is_mask_sentinel(password) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入 password", + )); + } + if password.is_empty() { + slot.password = None; + } + } + validate_hint_not_secret(slot.password_hint.as_deref(), slot.password.as_deref())?; + slot.secrets = sanitize_secrets(slot.secrets)?; + // Stamp account_id on nested secrets for flatten / resolve. + let aid = slot.id.clone(); + for sec in &mut slot.secrets { + sec.account_id = Some(aid.clone()); + } + Ok(slot) +} + +fn sanitize_secret_slot(mut slot: VaultSecretSlot) -> Result { + if slot.id.trim().is_empty() { + slot.id = new_secret_id(); + } + slot.kind = normalize_secret_kind(&slot.kind); + slot.label = opt_nonempty(slot.label); + slot.account_id = opt_nonempty(slot.account_id); + if let Some(value) = slot.value.as_deref() { + if is_mask_sentinel(value) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密钥字段", + )); + } + if value.is_empty() { + slot.value = None; + } + } + Ok(slot) +} + +fn sanitize_accounts(slots: Vec) -> Result, WebError> { + let mut out = Vec::new(); + for slot in slots { + let slot = sanitize_account_slot(slot)?; + // Drop completely empty slots (no identity and no nested secrets). + if slot.username.is_none() + && slot.email.is_none() + && slot.password.is_none() + && slot.password_hint.is_none() + && slot.label.is_none() + && slot.secrets.is_empty() + { + continue; + } + out.push(slot); + } + Ok(out) +} + +fn sanitize_secrets(slots: Vec) -> Result, WebError> { + let mut out = Vec::new(); + for slot in slots { + let slot = sanitize_secret_slot(slot)?; + if slot.value.is_none() && slot.label.is_none() { + continue; + } + out.push(slot); + } + Ok(out) +} + +/// Flatten all per-account secrets into top-level `secrets[]` (stable order). +fn flatten_secrets_from_accounts(accounts: &[VaultAccountSlot]) -> Vec { + let mut out = Vec::new(); + for acc in accounts { + for sec in &acc.secrets { + let mut s = sec.clone(); + s.account_id = Some(acc.id.clone()); + out.push(s); + } + } + out +} + +/// Count secrets across accounts (preferred) or top-level legacy list. +pub fn record_secret_count(record: &VaultCredentialRecord) -> usize { + let nested: usize = record.accounts.iter().map(|a| a.secrets.len()).sum(); + if nested > 0 { + nested + } else { + record.secrets.len() + } +} + +/// Iterate secrets: nested under accounts first; if none, top-level legacy. +#[allow(dead_code)] +fn for_each_secret_slot<'a, F>(record: &'a VaultCredentialRecord, mut f: F) +where + F: FnMut(&'a VaultSecretSlot, Option<&'a str>), +{ + let mut any_nested = false; + for acc in &record.accounts { + for sec in &acc.secrets { + any_nested = true; + f(sec, Some(acc.id.as_str())); + } + } + if !any_nested { + for sec in &record.secrets { + f(sec, sec.account_id.as_deref()); + } + } +} + +/// Find secret by id across nested accounts or top-level list. +#[allow(dead_code)] +fn find_secret_by_id_mut<'a>( + record: &'a mut VaultCredentialRecord, + secret_id: &str, +) -> Option<&'a mut VaultSecretSlot> { + // Index-based lookup avoids conflicting exclusive borrows across loop iterations. + let nested = record.accounts.iter().enumerate().find_map(|(ai, acc)| { + acc.secrets + .iter() + .position(|s| s.id == secret_id) + .map(|si| (ai, si)) + }); + if let Some((ai, si)) = nested { + return record.accounts.get_mut(ai)?.secrets.get_mut(si); + } + record.secrets.iter_mut().find(|s| s.id == secret_id) +} + +fn find_secret_by_id<'a>( + record: &'a VaultCredentialRecord, + secret_id: &str, +) -> Option<&'a VaultSecretSlot> { + for acc in &record.accounts { + if let Some(sec) = acc.secrets.iter().find(|s| s.id == secret_id) { + return Some(sec); + } + } + record.secrets.iter().find(|s| s.id == secret_id) +} + +/// Normalize multi-URL list and keep primary `url` = first entry. +/// Dedupes case-sensitive exact matches; trims whitespace; drops empties. +pub fn normalize_record_urls(record: &mut VaultCredentialRecord) { + let mut ordered: Vec = Vec::new(); + let mut push = |raw: &str| { + let t = raw.trim(); + if t.is_empty() { + return; + } + if ordered.iter().any(|u| u == t) { + return; + } + ordered.push(t.to_string()); + }; + // Prefer explicit urls list order; then legacy primary url if missing. + for u in &record.urls { + push(u); + } + if let Some(u) = record.url.as_deref() { + // If primary was not already first, put it first when urls was empty of it. + if !ordered.iter().any(|x| x == u.trim()) { + ordered.insert(0, u.trim().to_string()); + } else if ordered.first().map(|x| x.as_str()) != Some(u.trim()) { + // Keep user's primary as first when both present and disagree. + let primary = u.trim().to_string(); + ordered.retain(|x| x != &primary); + ordered.insert(0, primary); + } + } + if ordered.is_empty() { + record.url = None; + record.urls = Vec::new(); + return; + } + record.url = Some(ordered[0].clone()); + record.urls = ordered; +} + +/// Ensure accounts/secrets exist from legacy fields; keep primary mirrors in sync. +/// Prefer secrets nested under accounts; top-level `secrets[]` is a flatten mirror. +pub fn ensure_slots_and_primary_sync(record: &mut VaultCredentialRecord) { + normalize_record_urls(record); + if record.accounts.is_empty() + && (record.username.is_some() + || record.email.is_some() + || secret_present(record.password.as_deref()) + || record.password_hint.is_some()) + { + record.accounts.push(VaultAccountSlot { + id: new_account_id(), + label: None, + username: record.username.clone(), + email: record.email.clone(), + password: record.password.clone(), + password_hint: record.password_hint.clone(), + secrets: Vec::new(), + }); + } + + // Migrate top-level secrets into accounts when accounts have no nested secrets. + let any_nested = record.accounts.iter().any(|a| !a.secrets.is_empty()); + if !any_nested && !record.secrets.is_empty() { + // Attach secrets that declare account_id; rest go to first account. + let mut remaining = std::mem::take(&mut record.secrets); + let first_acc_id = record.accounts.first().map(|a| a.id.clone()); + if first_acc_id.is_none() && !remaining.is_empty() { + // No account yet: synthesize one so secrets have a home. + record.accounts.push(VaultAccountSlot { + id: new_account_id(), + label: None, + username: record.username.clone(), + email: record.email.clone(), + password: record.password.clone(), + password_hint: record.password_hint.clone(), + secrets: Vec::new(), + }); + } + let first_acc_id = record.accounts.first().map(|a| a.id.clone()); + for mut sec in remaining.drain(..) { + let target = sec + .account_id + .clone() + .filter(|id| record.accounts.iter().any(|a| a.id == *id)) + .or_else(|| first_acc_id.clone()); + if let Some(tid) = target { + sec.account_id = Some(tid.clone()); + if let Some(acc) = record.accounts.iter_mut().find(|a| a.id == tid) { + // Avoid duplicate ids when re-running sync. + if !acc.secrets.iter().any(|s| s.id == sec.id) { + acc.secrets.push(sec); + } + } + } + } + } + + // Legacy primary apikey/token → nest under first account when no secrets at all. + let nested_empty = record.accounts.iter().all(|a| a.secrets.is_empty()); + if nested_empty && record.secrets.is_empty() { + if secret_present(record.apikey.as_deref()) || secret_present(record.token.as_deref()) { + if record.accounts.is_empty() { + record.accounts.push(VaultAccountSlot { + id: new_account_id(), + label: None, + username: record.username.clone(), + email: record.email.clone(), + password: record.password.clone(), + password_hint: record.password_hint.clone(), + secrets: Vec::new(), + }); + } + if let Some(acc) = record.accounts.first_mut() { + if secret_present(record.apikey.as_deref()) { + acc.secrets.push(VaultSecretSlot { + id: new_secret_id(), + kind: "apikey".into(), + label: Some("API Key".into()), + value: record.apikey.clone(), + account_id: Some(acc.id.clone()), + }); + } + if secret_present(record.token.as_deref()) { + acc.secrets.push(VaultSecretSlot { + id: new_secret_id(), + kind: "token".into(), + label: Some("Token".into()), + value: record.token.clone(), + account_id: Some(acc.id.clone()), + }); + } + } + } + } + + // Stamp account_id on nested secrets + rebuild top-level flatten mirror. + for acc in &mut record.accounts { + let aid = acc.id.clone(); + for sec in &mut acc.secrets { + sec.account_id = Some(aid.clone()); + } + } + record.secrets = flatten_secrets_from_accounts(&record.accounts); + + if let Some(acc) = record.accounts.first() { + record.username = acc.username.clone(); + record.email = acc.email.clone(); + record.password = acc.password.clone(); + record.password_hint = acc.password_hint.clone(); + } + + record.apikey = record + .secrets + .iter() + .find(|s| s.kind == "apikey" && secret_present(s.value.as_deref())) + .and_then(|s| s.value.clone()) + .or_else(|| { + record + .secrets + .iter() + .find(|s| secret_present(s.value.as_deref()) && s.kind != "token") + .and_then(|s| s.value.clone()) + }); + record.token = record + .secrets + .iter() + .find(|s| s.kind == "token" && secret_present(s.value.as_deref())) + .and_then(|s| s.value.clone()); +} + +fn record_has_password(record: &VaultCredentialRecord) -> bool { + secret_present(record.password.as_deref()) + || record + .accounts + .iter() + .any(|a| secret_present(a.password.as_deref())) +} + +fn record_has_apikey(record: &VaultCredentialRecord) -> bool { + secret_present(record.apikey.as_deref()) + || record.accounts.iter().any(|a| { + a.secrets + .iter() + .any(|s| s.kind == "apikey" && secret_present(s.value.as_deref())) + }) + || record + .secrets + .iter() + .any(|s| s.kind == "apikey" && secret_present(s.value.as_deref())) +} + +fn record_has_token(record: &VaultCredentialRecord) -> bool { + secret_present(record.token.as_deref()) + || record.accounts.iter().any(|a| { + a.secrets + .iter() + .any(|s| s.kind == "token" && secret_present(s.value.as_deref())) + }) + || record + .secrets + .iter() + .any(|s| s.kind == "token" && secret_present(s.value.as_deref())) +} + +/// Resolve a field for reveal/login/resolve. Supports primary fields + multi slots. +/// Includes non-secret account fields (`username` / `email`) so cipher placeholders +/// can expand the same way as password templates. +pub fn resolve_record_secret_field<'a>( + record: &'a VaultCredentialRecord, + field: &str, + account_id: Option<&str>, + secret_id: Option<&str>, +) -> Option<&'a str> { + if let Some(sid) = secret_id.map(str::trim).filter(|s| !s.is_empty()) { + return find_secret_by_id(record, sid) + .and_then(|s| s.value.as_deref()) + .filter(|v| !v.is_empty()); + } + let field = match field { + "apiKey" | "api_key" => "apikey", + other => other, + }; + if let Some(aid) = account_id.map(str::trim).filter(|s| !s.is_empty()) { + if field == "password" { + return record + .accounts + .iter() + .find(|a| a.id == aid) + .and_then(|a| a.password.as_deref()) + .filter(|v| !v.is_empty()); + } + if field == "passwordHint" || field == "password_hint" { + return record + .accounts + .iter() + .find(|a| a.id == aid) + .and_then(|a| a.password_hint.as_deref()) + .filter(|v| !v.is_empty()); + } + if field == "username" { + return record + .accounts + .iter() + .find(|a| a.id == aid) + .and_then(|a| a.username.as_deref()) + .filter(|v| !v.is_empty()); + } + if field == "email" { + return record + .accounts + .iter() + .find(|a| a.id == aid) + .and_then(|a| a.email.as_deref()) + .filter(|v| !v.is_empty()); + } + } + match field { + "password" => record + .password + .as_deref() + .filter(|v| !v.is_empty()) + .or_else(|| { + record + .accounts + .first() + .and_then(|a| a.password.as_deref()) + .filter(|v| !v.is_empty()) + }), + "apikey" => record + .apikey + .as_deref() + .filter(|v| !v.is_empty()) + .or_else(|| { + record + .secrets + .iter() + .find(|s| s.kind == "apikey") + .and_then(|s| s.value.as_deref()) + .filter(|v| !v.is_empty()) + }), + "token" => record + .token + .as_deref() + .filter(|v| !v.is_empty()) + .or_else(|| { + record + .secrets + .iter() + .find(|s| s.kind == "token") + .and_then(|s| s.value.as_deref()) + .filter(|v| !v.is_empty()) + }), + "username" => record + .username + .as_deref() + .filter(|v| !v.is_empty()) + .or_else(|| { + record + .accounts + .first() + .and_then(|a| a.username.as_deref()) + .filter(|v| !v.is_empty()) + }), + "email" => record + .email + .as_deref() + .filter(|v| !v.is_empty()) + .or_else(|| { + record + .accounts + .first() + .and_then(|a| a.email.as_deref()) + .filter(|v| !v.is_empty()) + }), + _ => None, + } +} + +/// All field templates that may contain `[Key]` placeholders (secrets + username/email). +fn collect_all_secret_templates(record: &VaultCredentialRecord) -> Vec> { + let mut out = vec![ + record.password.as_deref(), + record.apikey.as_deref(), + record.token.as_deref(), + record.username.as_deref(), + record.email.as_deref(), + ]; + for acc in &record.accounts { + out.push(acc.password.as_deref()); + out.push(acc.username.as_deref()); + out.push(acc.email.as_deref()); + for sec in &acc.secrets { + out.push(sec.value.as_deref()); + } + } + // Top-level secrets only if not already nested (legacy pre-sync). + if record.accounts.iter().all(|a| a.secrets.is_empty()) { + for sec in &record.secrets { + out.push(sec.value.as_deref()); + } + } + out +} + +/// Expand a stored text field with cipher-book. Returns (display_value, raw_template_if_changed). +fn expand_text_with_cipher( + raw: Option<&str>, + book: Option<&CipherBook>, +) -> (Option, Option) { + let Some(t) = raw.map(str::trim).filter(|v| !v.is_empty()) else { + return (None, None); + }; + let Some(book) = book else { + return (Some(t.to_string()), None); + }; + if !t.contains('[') { + return (Some(t.to_string()), None); + } + let resolved = resolve_cipher_placeholders(t, book); + if resolved.template.is_some() { + (Some(resolved.value), Some(t.to_string())) + } else { + (Some(resolved.value), None) + } +} + +fn index_path(vault_root: &Path) -> PathBuf { + vault_root.join("vault-index.json") +} + +fn audit_path(vault_root: &Path) -> PathBuf { + vault_root.join("audit.jsonl") +} + +fn cipher_book_path(vault_root: &Path) -> PathBuf { + vault_root.join("cipher-book.json") +} + +fn entry_rel_path(id: &str, status: VaultItemStatus) -> String { + match status { + VaultItemStatus::Active => format!("entries/{id}.md"), + VaultItemStatus::Deleted => format!("trash/entries/{id}.md"), + } +} + +fn abs_from_vault_rel(vault_root: &Path, relative: &str) -> Result { + let normalized = relative.replace('\\', "/"); + if normalized.is_empty() + || normalized.starts_with('/') + || normalized + .split('/') + .any(|part| part == ".." || part.is_empty()) + { + return Err(WebError::bad_request_code( + "vault_path_invalid", + "密码箱相对路径非法", + )); + } + let target = vault_root.join(&normalized); + if !target.starts_with(vault_root) { + return Err(WebError::bad_request_code( + "vault_path_escape", + "密码箱路径不能越过 vault root", + )); + } + Ok(target) +} + +pub fn append_vault_audit( + workspace_root: &Path, + action: &str, + actor_id: &str, + credential_id: &str, + field: Option<&str>, + request_id: Option<&str>, + ok: bool, +) -> Result<(), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let path = audit_path(&vault); + let mut line = json!({ + "ts": now_rfc3339(), + "action": action, + "actorId": actor_id, + "credentialId": credential_id, + "ok": ok, + }); + if let Some(field) = field { + line["field"] = json!(field); + } + if let Some(request_id) = request_id { + line["requestId"] = json!(request_id); + } + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| { + WebError::bad_request_code( + "vault_audit_write_failed", + format!("无法写入审计日志: {error}"), + ) + })?; + writeln!(file, "{line}").map_err(|error| { + WebError::bad_request_code( + "vault_audit_write_failed", + format!("无法追加审计日志: {error}"), + ) + })?; + Ok(()) +} + +fn empty_index() -> VaultIndex { + VaultIndex { + schema: VAULT_INDEX_SCHEMA.to_string(), + revision: 0, + updated_at: now_rfc3339(), + entries: BTreeMap::new(), + } +} + +fn load_index(vault_root: &Path) -> Result { + let path = index_path(vault_root); + if !path.exists() { + return Ok(empty_index()); + } + let content = fs::read_to_string(&path).map_err(|error| { + WebError::bad_request_code( + "vault_index_read_failed", + format!("无法读取密码箱索引: {error}"), + ) + })?; + match serde_json::from_str::(&content) { + Ok(index) => Ok(index), + Err(_) => rebuild_index(vault_root), + } +} + +fn write_index_atomic(vault_root: &Path, index: &VaultIndex) -> Result<(), WebError> { + let path = index_path(vault_root); + let tmp = vault_root.join("vault-index.json.tmp"); + let body = serde_json::to_string_pretty(index).map_err(|error| { + WebError::bad_request_code( + "vault_index_serialize_failed", + format!("无法序列化密码箱索引: {error}"), + ) + })?; + fs::write(&tmp, body).map_err(|error| { + WebError::bad_request_code( + "vault_index_write_failed", + format!("无法写入密码箱索引临时文件: {error}"), + ) + })?; + fs::rename(&tmp, &path).map_err(|error| { + WebError::bad_request_code( + "vault_index_write_failed", + format!("无法提交密码箱索引: {error}"), + ) + })?; + Ok(()) +} + +fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry { + let primary_username = record + .username + .clone() + .or_else(|| record.accounts.first().and_then(|a| a.username.clone())); + let primary_email = record + .email + .clone() + .or_else(|| record.accounts.first().and_then(|a| a.email.clone())); + let primary_hint = record + .password_hint + .clone() + .or_else(|| record.accounts.first().and_then(|a| a.password_hint.clone())); + let index_urls: Vec = record + .urls + .iter() + .skip(1) + .cloned() + .collect(); + VaultIndexEntry { + id: record.id.clone(), + title: record.title.clone(), + url: record.url.clone(), + urls: index_urls, + username: primary_username, + email: primary_email, + password_hint: primary_hint, + folder_path: record.folder_path.clone(), + tags: record.tags.clone(), + has_password: record_has_password(record), + has_apikey: record_has_apikey(record), + has_token: record_has_token(record), + account_count: record.accounts.len(), + secret_count: record_secret_count(record), + attachment_count: record.attachments.len(), + relative_path: record.relative_path.clone(), + updated_at: record.updated_at.clone(), + status: record.status, + revision: record.revision, + shared_to_ai: record.shared_to_ai.clone(), + shared_from: record.shared_from.clone(), + has_login_session: record + .login_session + .as_ref() + .and_then(|s| s.cookie_header.as_ref()) + .map(|c| !c.trim().is_empty()) + .unwrap_or(false), + session_expires_at: record + .login_session + .as_ref() + .and_then(|s| s.expires_at.clone()), + login_mode: record + .login_playbook + .as_ref() + .map(|p| p.mode.clone()) + .filter(|m| !m.is_empty()), + } +} + +/// Normalize logical folder path: trim, collapse `//`, strip leading/trailing `/`. +pub fn normalize_folder_path(raw: Option<&str>) -> Option { + let Some(s) = raw.map(str::trim).filter(|v| !v.is_empty()) else { + return None; + }; + let parts: Vec<&str> = s + .split(|c| c == '/' || c == '\\') + .map(str::trim) + .filter(|p| !p.is_empty() && *p != "." && *p != "..") + .collect(); + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + +/// Validate cipher-book key: `[A-Za-z0-9_]+`, 1..=32 chars (matches `[Key]` in passwords). +pub fn validate_cipher_key(key: &str) -> Result<(), WebError> { + let key = key.trim(); + if key.is_empty() || key.len() > 32 { + return Err(WebError::bad_request_code( + "vault_cipher_key_invalid", + "密文簿 key 长度须为 1–32", + )); + } + if !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(WebError::bad_request_code( + "vault_cipher_key_invalid", + "密文簿 key 仅允许字母、数字、下划线(密码中写 [A] 或 [Key])", + )); + } + Ok(()) +} + +fn empty_cipher_book() -> CipherBook { + CipherBook { + schema: VAULT_CIPHER_BOOK_SCHEMA.to_string(), + revision: 0, + updated_at: now_rfc3339(), + fragments: BTreeMap::new(), + } +} + +pub fn load_cipher_book(workspace_root: &Path) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let path = cipher_book_path(&vault); + if !path.exists() { + return Ok(empty_cipher_book()); + } + let content = fs::read_to_string(&path).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_read_failed", + format!("无法读取密文簿: {error}"), + ) + })?; + match serde_json::from_str::(&content) { + Ok(book) => Ok(book), + Err(error) => Err(WebError::bad_request_code( + "vault_cipher_book_corrupt", + format!("密文簿损坏: {error}"), + )), + } +} + +fn write_cipher_book_atomic(vault_root: &Path, book: &CipherBook) -> Result<(), WebError> { + let path = cipher_book_path(vault_root); + let tmp = vault_root.join("cipher-book.json.tmp"); + let body = serde_json::to_string_pretty(book).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_serialize_failed", + format!("无法序列化密文簿: {error}"), + ) + })?; + fs::write(&tmp, body).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_write_failed", + format!("无法写入密文簿临时文件: {error}"), + ) + })?; + fs::rename(&tmp, &path).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_write_failed", + format!("无法提交密文簿: {error}"), + ) + })?; + Ok(()) +} + +/// L0 projection: keys + hasValue only — never fragment plaintext. +pub fn project_cipher_book_l0(book: &CipherBook) -> Value { + let keys: Vec = book + .fragments + .iter() + .map(|(k, v)| { + json!({ + "key": k, + "hasValue": !v.is_empty(), + }) + }) + .collect(); + json!({ + "schema": VAULT_CIPHER_BOOK_SCHEMA, + "revision": book.revision, + "updatedAt": book.updated_at, + "keys": keys, + }) +} + +pub fn set_cipher_fragment( + workspace_root: &Path, + key: &str, + value: &str, +) -> Result { + validate_cipher_key(key)?; + if value.is_empty() { + return Err(WebError::bad_request_code( + "vault_cipher_value_required", + "密文片段不能为空;删除请用 DELETE", + )); + } + if is_mask_sentinel(value) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密文簿", + )); + } + let vault = ensure_vault_directories(workspace_root)?; + let mut book = load_cipher_book(workspace_root)?; + book.fragments.insert(key.trim().to_string(), value.to_string()); + book.revision = book.revision.saturating_add(1); + book.updated_at = now_rfc3339(); + write_cipher_book_atomic(&vault, &book)?; + Ok(book) +} + +pub fn delete_cipher_fragment( + workspace_root: &Path, + key: &str, +) -> Result { + validate_cipher_key(key)?; + let vault = ensure_vault_directories(workspace_root)?; + let mut book = load_cipher_book(workspace_root)?; + if book.fragments.remove(key.trim()).is_none() { + return Err(WebError::new( + StatusCode::NotFound, + "vault_cipher_key_not_found", + format!("密文簿中不存在 key: {key}"), + )); + } + book.revision = book.revision.saturating_add(1); + book.updated_at = now_rfc3339(); + write_cipher_book_atomic(&vault, &book)?; + Ok(book) +} + +pub fn get_cipher_fragment( + workspace_root: &Path, + key: &str, +) -> Result { + validate_cipher_key(key)?; + let book = load_cipher_book(workspace_root)?; + book.fragments + .get(key.trim()) + .filter(|v| !v.is_empty()) + .cloned() + .ok_or_else(|| { + WebError::new( + StatusCode::NotFound, + "vault_cipher_key_not_found", + format!("密文簿中不存在 key: {key}"), + ) + }) +} + +/// Expand `[Key]` placeholders using the cipher book. +/// Unknown keys are left as `[Key]` and listed in `missing_keys`. +pub fn resolve_cipher_placeholders(template: &str, book: &CipherBook) -> ResolvedSecret { + let bytes = template.as_bytes(); + let mut out = String::with_capacity(template.len()); + let mut used: Vec = Vec::new(); + let mut missing: Vec = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'[' { + if let Some(end) = template[i + 1..].find(']') { + let key = &template[i + 1..i + 1 + end]; + if !key.is_empty() + && key.len() <= 32 + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + if let Some(frag) = book.fragments.get(key) { + out.push_str(frag); + if !used.iter().any(|k| k == key) { + used.push(key.to_string()); + } + } else { + out.push('['); + out.push_str(key); + out.push(']'); + if !missing.iter().any(|k| k == key) { + missing.push(key.to_string()); + } + } + i = i + 1 + end + 1; + continue; + } + } + } + out.push(bytes[i] as char); + i += 1; + } + let changed = out != template; + ResolvedSecret { + value: out, + template: if changed { + Some(template.to_string()) + } else { + None + }, + used_keys: used, + missing_keys: missing, + } +} + +pub fn resolve_secret_with_cipher_book( + workspace_root: &Path, + template: Option<&str>, +) -> Result, WebError> { + let Some(t) = template.filter(|v| !v.is_empty()) else { + return Ok(None); + }; + if !t.contains('[') { + return Ok(Some(ResolvedSecret { + value: t.to_string(), + template: None, + used_keys: Vec::new(), + missing_keys: Vec::new(), + })); + } + let book = load_cipher_book(workspace_root)?; + Ok(Some(resolve_cipher_placeholders(t, &book))) +} + +fn secret_present(value: Option<&str>) -> bool { + value.map(|v| !v.is_empty()).unwrap_or(false) +} + +fn validate_fields(fields: &BTreeMap) -> Result<(), WebError> { + for key in fields.keys() { + let lower = key.to_ascii_lowercase(); + if FORBIDDEN_FIELD_KEYS + .iter() + .any(|forbidden| lower.contains(forbidden)) + { + return Err(WebError::bad_request_code( + "vault_fields_secret_forbidden", + format!("fields 禁止密钥类键: {key};请使用 password / apikey / token"), + )); + } + } + Ok(()) +} + +fn validate_hint_not_secret( + hint: Option<&str>, + password: Option<&str>, +) -> Result<(), WebError> { + let hint = hint.map(str::trim).filter(|v| !v.is_empty()); + let password = password.map(str::trim).filter(|v| !v.is_empty()); + if let (Some(h), Some(p)) = (hint, password) { + if h == p { + return Err(WebError::bad_request_code( + "vault_hint_equals_secret", + "passwordHint 不能与 password 相同", + )); + } + } + Ok(()) +} + +pub fn is_mask_sentinel(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() { + return false; + } + let bullets = trimmed + .chars() + .all(|c| matches!(c, '•' | '*' | '●' | '○' | '·')); + bullets && trimmed.chars().count() >= 3 +} + +fn yaml_escape(value: &str) -> String { + if value.is_empty() { + return "\"\"".to_string(); + } + if value.chars().any(|c| { + c.is_whitespace() + || matches!(c, ':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>' | '%' | '@' | '`') + }) { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + value.to_string() + } +} + +fn render_entry_markdown(record: &VaultCredentialRecord) -> String { + let mut out = String::from("---\n"); + out.push_str(&format!("mnote.kind: {VAULT_KIND}\n")); + out.push_str(&format!("schema: {VAULT_CREDENTIAL_SCHEMA}\n")); + out.push_str(&format!("id: {}\n", record.id)); + out.push_str(&format!("title: {}\n", yaml_escape(&record.title))); + if let Some(url) = &record.url { + out.push_str(&format!("url: {}\n", yaml_escape(url))); + } + if record.urls.len() > 1 { + out.push_str("urls:\n"); + for u in &record.urls { + out.push_str(&format!(" - {}\n", yaml_escape(u))); + } + } + if let Some(username) = &record.username { + out.push_str(&format!("username: {}\n", yaml_escape(username))); + } + if let Some(password) = &record.password { + out.push_str(&format!("password: {}\n", yaml_escape(password))); + } + if let Some(hint) = &record.password_hint { + out.push_str(&format!("passwordHint: {}\n", yaml_escape(hint))); + } + if let Some(email) = &record.email { + out.push_str(&format!("email: {}\n", yaml_escape(email))); + } + if let Some(apikey) = &record.apikey { + out.push_str(&format!("apikey: {}\n", yaml_escape(apikey))); + } + if let Some(token) = &record.token { + out.push_str(&format!("token: {}\n", yaml_escape(token))); + } + if record.accounts.is_empty() { + out.push_str("accounts: []\n"); + } else { + out.push_str("accounts:\n"); + for acc in &record.accounts { + out.push_str(&format!(" - id: {}\n", yaml_escape(&acc.id))); + if let Some(label) = &acc.label { + out.push_str(&format!(" label: {}\n", yaml_escape(label))); + } + if let Some(username) = &acc.username { + out.push_str(&format!(" username: {}\n", yaml_escape(username))); + } + if let Some(email) = &acc.email { + out.push_str(&format!(" email: {}\n", yaml_escape(email))); + } + if let Some(password) = &acc.password { + out.push_str(&format!(" password: {}\n", yaml_escape(password))); + } + if let Some(hint) = &acc.password_hint { + out.push_str(&format!(" passwordHint: {}\n", yaml_escape(hint))); + } + // Nested appendix secrets under this account. + if acc.secrets.is_empty() { + out.push_str(" secrets: []\n"); + } else { + out.push_str(" secrets:\n"); + for sec in &acc.secrets { + out.push_str(&format!(" - id: {}\n", yaml_escape(&sec.id))); + out.push_str(&format!(" kind: {}\n", yaml_escape(&sec.kind))); + if let Some(label) = &sec.label { + out.push_str(&format!(" label: {}\n", yaml_escape(label))); + } + if let Some(value) = &sec.value { + out.push_str(&format!(" value: {}\n", yaml_escape(value))); + } + } + } + } + } + // Top-level secrets kept as flatten mirror for legacy readers / AI resolve. + if record.secrets.is_empty() { + out.push_str("secrets: []\n"); + } else { + out.push_str("secrets:\n"); + for sec in &record.secrets { + out.push_str(&format!(" - id: {}\n", yaml_escape(&sec.id))); + out.push_str(&format!(" kind: {}\n", yaml_escape(&sec.kind))); + if let Some(label) = &sec.label { + out.push_str(&format!(" label: {}\n", yaml_escape(label))); + } + if let Some(value) = &sec.value { + out.push_str(&format!(" value: {}\n", yaml_escape(value))); + } + if let Some(aid) = &sec.account_id { + out.push_str(&format!(" accountId: {}\n", yaml_escape(aid))); + } + } + } + if let Some(folder) = &record.folder_path { + out.push_str(&format!("folderPath: {}\n", yaml_escape(folder))); + } + if record.tags.is_empty() { + out.push_str("tags: []\n"); + } else { + out.push_str("tags:\n"); + for tag in &record.tags { + out.push_str(&format!(" - {}\n", yaml_escape(tag))); + } + } + if record.fields.is_empty() { + out.push_str("fields: {}\n"); + } else { + out.push_str("fields:\n"); + for (k, v) in &record.fields { + out.push_str(&format!(" {}: {}\n", k, yaml_escape(v))); + } + } + if record.attachments.is_empty() { + out.push_str("attachments: []\n"); + } else { + out.push_str("attachments:\n"); + for att in &record.attachments { + out.push_str(&format!(" - refId: {}\n", yaml_escape(&att.ref_id))); + out.push_str(&format!(" kind: {}\n", yaml_escape(&att.kind))); + out.push_str(&format!( + " relativePath: {}\n", + yaml_escape(&att.relative_path) + )); + out.push_str(&format!(" label: {}\n", yaml_escape(&att.label))); + if let Some(ct) = &att.content_type { + out.push_str(&format!(" contentType: {}\n", yaml_escape(ct))); + } + } + } + if let Some(link) = &record.shared_to_ai { + out.push_str("sharedToAi:\n"); + out.push_str(&format!( + " targetActorId: {}\n", + yaml_escape(&link.target_actor_id) + )); + out.push_str(&format!(" targetId: {}\n", yaml_escape(&link.target_id))); + out.push_str(&format!(" sharedAt: {}\n", yaml_escape(&link.shared_at))); + if let Some(synced) = &link.last_synced_at { + out.push_str(&format!(" lastSyncedAt: {}\n", yaml_escape(synced))); + } + } + if let Some(origin) = &record.shared_from { + out.push_str("sharedFrom:\n"); + out.push_str(&format!( + " sourceActorId: {}\n", + yaml_escape(&origin.source_actor_id) + )); + out.push_str(&format!(" sourceId: {}\n", yaml_escape(&origin.source_id))); + out.push_str(&format!(" sharedAt: {}\n", yaml_escape(&origin.shared_at))); + } + if let Some(pb) = &record.login_playbook { + out.push_str("loginPlaybook:\n"); + if !pb.mode.is_empty() { + out.push_str(&format!(" mode: {}\n", yaml_escape(&pb.mode))); + } + if !pb.preferred_account.is_empty() { + out.push_str(&format!( + " preferredAccount: {}\n", + yaml_escape(&pb.preferred_account) + )); + } + if let Some(v) = &pb.api_path { + out.push_str(&format!(" apiPath: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.account_selector { + out.push_str(&format!(" accountSelector: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.password_selector { + out.push_str(&format!(" passwordSelector: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.submit_selector { + out.push_str(&format!(" submitSelector: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.success_url_contains { + out.push_str(&format!(" successUrlContains: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.human_note { + out.push_str(&format!(" humanNote: {}\n", yaml_escape(v))); + } + } + if let Some(sess) = &record.login_session { + out.push_str("loginSession:\n"); + if let Some(v) = &sess.cookie_header { + out.push_str(&format!(" cookieHeader: {}\n", yaml_escape(v))); + } + if let Some(v) = &sess.expires_at { + out.push_str(&format!(" expiresAt: {}\n", yaml_escape(v))); + } + if let Some(v) = &sess.last_login_at { + out.push_str(&format!(" lastLoginAt: {}\n", yaml_escape(v))); + } + if let Some(v) = &sess.source { + out.push_str(&format!(" source: {}\n", yaml_escape(v))); + } + } + out.push_str(&format!("createdAt: {}\n", record.created_at)); + out.push_str(&format!("updatedAt: {}\n", record.updated_at)); + out.push_str(&format!("revision: {}\n", record.revision)); + out.push_str(&format!("status: {}\n", record.status.as_str())); + out.push_str("---\n\n"); + if record.notes_markdown.trim().is_empty() { + out.push_str("## Notes\n\n"); + } else { + out.push_str(&record.notes_markdown); + if !record.notes_markdown.ends_with('\n') { + out.push('\n'); + } + } + out +} + +fn parse_scalar(raw: &str) -> String { + let trimmed = raw.trim(); + if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2) + { + let inner = &trimmed[1..trimmed.len() - 1]; + return inner.replace("\\\"", "\"").replace("\\\\", "\\"); + } + // strip inline comment + if let Some((value, _)) = trimmed.split_once(" #") { + return value.trim().to_string(); + } + trimmed.to_string() +} + +fn parse_frontmatter_map(frontmatter: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut lines = frontmatter.lines().peekable(); + while let Some(line) = lines.next() { + let trimmed = line.trim_end(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + // Only accept top-level keys (no leading spaces). + if trimmed.starts_with(' ') { + continue; + } + let Some((key, rest)) = trimmed.split_once(':') else { + continue; + }; + let key = key.trim().to_string(); + let rest = rest.trim(); + if rest.is_empty() { + // nested block or list + let mut nested_map = Map::new(); + let mut nested_list = Vec::new(); + let mut mode = "unknown"; + while let Some(peek) = lines.peek() { + let peek_trim = peek.trim_end(); + if peek_trim.is_empty() { + lines.next(); + continue; + } + let indent = peek.chars().take_while(|c| *c == ' ').count(); + if indent == 0 { + break; + } + let line = lines.next().unwrap(); + let content = line.trim(); + if content.starts_with("- ") { + mode = "list"; + let item = content[2..].trim(); + // Object list item: `- key: value` with a simple identifier key. + // Do NOT treat URLs like `https://…` as objects (they also contain `:`). + let is_object_item = item + .split_once(':') + .map(|(k, _)| { + let k = k.trim(); + !k.is_empty() + && !item.contains("://") + && k.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + }) + .unwrap_or(false); + if is_object_item { + // object list item start (supports one extra nested list, e.g. account.secrets) + let mut obj = Map::new(); + if let Some((ik, iv)) = item.split_once(':') { + obj.insert(ik.trim().to_string(), Value::String(parse_scalar(iv))); + } + while let Some(peek2) = lines.peek() { + let p2 = peek2.trim_end(); + if p2.is_empty() { + lines.next(); + continue; + } + let indent2 = p2.chars().take_while(|c| *c == ' ').count(); + // Sibling list item or shallower key ends this object. + if indent2 <= indent { + break; + } + let nested_line = lines.next().unwrap(); + let nested = nested_line.trim(); + // Nested list under this object: `secrets:` then ` - id: ...` + if nested.starts_with("- ") { + // Orphan nested dash without key — skip (should not happen at this indent). + continue; + } + if let Some((nk, nv)) = nested.split_once(':') { + let nk = nk.trim().to_string(); + let nv = nv.trim(); + if nv.is_empty() { + // nested list or map under object field + let mut child_list = Vec::new(); + let mut child_map = Map::new(); + let mut child_mode = "unknown"; + while let Some(peek3) = lines.peek() { + let p3 = peek3.trim_end(); + if p3.is_empty() { + lines.next(); + continue; + } + let indent3 = + p3.chars().take_while(|c| *c == ' ').count(); + if indent3 <= indent2 { + break; + } + let child_line = lines.next().unwrap(); + let child = child_line.trim(); + if child.starts_with("- ") { + child_mode = "list"; + let citem = child[2..].trim(); + let is_child_obj = citem + .split_once(':') + .map(|(k, _)| { + let k = k.trim(); + !k.is_empty() + && !citem.contains("://") + && k.chars().all(|c| { + c.is_ascii_alphanumeric() + || c == '_' + || c == '-' + }) + }) + .unwrap_or(false); + if is_child_obj { + let mut cobj = Map::new(); + if let Some((ck, cv)) = citem.split_once(':') { + cobj.insert( + ck.trim().to_string(), + Value::String(parse_scalar(cv)), + ); + } + while let Some(peek4) = lines.peek() { + let p4 = peek4.trim_end(); + if p4.is_empty() { + lines.next(); + continue; + } + let indent4 = p4 + .chars() + .take_while(|c| *c == ' ') + .count(); + if indent4 <= indent3 + || p4.trim().starts_with("- ") + { + break; + } + let deep_line = lines.next().unwrap(); + let deep = deep_line.trim(); + if let Some((dk, dv)) = deep.split_once(':') { + cobj.insert( + dk.trim().to_string(), + Value::String(parse_scalar(dv)), + ); + } + } + child_list.push(Value::Object(cobj)); + } else { + child_list + .push(Value::String(parse_scalar(citem))); + } + } else if let Some((ck, cv)) = child.split_once(':') { + child_mode = "map"; + child_map.insert( + ck.trim().to_string(), + Value::String(parse_scalar(cv)), + ); + } + } + if child_mode == "list" { + obj.insert(nk, Value::Array(child_list)); + } else if child_mode == "map" { + obj.insert(nk, Value::Object(child_map)); + } else { + obj.insert(nk, Value::String(String::new())); + } + } else if nv == "[]" { + obj.insert(nk, Value::Array(vec![])); + } else if nv == "{}" { + obj.insert(nk, Value::Object(Map::new())); + } else { + obj.insert(nk, Value::String(parse_scalar(nv))); + } + } + } + nested_list.push(Value::Object(obj)); + } else { + nested_list.push(Value::String(parse_scalar(item))); + } + } else if let Some((nk, nv)) = content.split_once(':') { + mode = "map"; + let nk = nk.trim().to_string(); + let nv = nv.trim(); + if nv.is_empty() { + nested_map.insert(nk, Value::String(String::new())); + } else if nv == "[]" { + nested_map.insert(nk, Value::Array(vec![])); + } else { + nested_map.insert(nk, Value::String(parse_scalar(nv))); + } + } + } + if mode == "list" { + map.insert(key, Value::Array(nested_list)); + } else if mode == "map" { + map.insert(key, Value::Object(nested_map)); + } else { + map.insert(key, Value::String(String::new())); + } + } else if rest == "[]" { + map.insert(key, Value::Array(vec![])); + } else if rest == "{}" { + map.insert(key, Value::Object(Map::new())); + } else { + map.insert(key, Value::String(parse_scalar(rest))); + } + } + map +} + + +fn string_field(map: &BTreeMap, keys: &[&str]) -> Option { + for key in keys { + if let Some(Value::String(value)) = map.get(*key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn parse_tags(map: &BTreeMap) -> Vec { + match map.get("tags") { + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| item.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + .collect(), + Some(Value::String(raw)) => raw + .split(',') + .map(|s| s.trim().trim_matches(|c| c == '[' || c == ']').to_string()) + .filter(|s| !s.is_empty()) + .collect(), + _ => Vec::new(), + } +} + +fn parse_fields(map: &BTreeMap) -> BTreeMap { + let mut out = BTreeMap::new(); + if let Some(Value::Object(obj)) = map.get("fields") { + for (k, v) in obj { + if let Some(s) = v.as_str() { + out.insert(k.clone(), s.to_string()); + } + } + } + out +} + +fn parse_attachments(map: &BTreeMap) -> Vec { + let Some(Value::Array(items)) = map.get("attachments") else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let obj = item.as_object()?; + Some(VaultAttachmentRef { + ref_id: obj + .get("refId") + .or_else(|| obj.get("ref_id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + kind: obj + .get("kind") + .and_then(Value::as_str) + .unwrap_or("other") + .to_string(), + relative_path: obj + .get("relativePath") + .or_else(|| obj.get("relative_path")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + label: obj + .get("label") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + content_type: obj + .get("contentType") + .or_else(|| obj.get("content_type")) + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .filter(|a| !a.ref_id.is_empty()) + .collect() +} + +fn nested_opt_string(obj: &Map, keys: &[&str]) -> Option { + for key in keys { + if let Some(Value::String(value)) = obj.get(*key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn parse_urls_list(map: &BTreeMap) -> Vec { + let Some(Value::Array(items)) = map.get("urls") else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let s = item.as_str()?.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }) + .collect() +} + +fn parse_secret_slot_from_obj(obj: &Map) -> VaultSecretSlot { + let id = nested_opt_string(obj, &["id"]).unwrap_or_else(new_secret_id); + let kind = nested_opt_string(obj, &["kind"]).unwrap_or_else(|| "apikey".into()); + VaultSecretSlot { + id, + kind: normalize_secret_kind(&kind), + label: nested_opt_string(obj, &["label"]), + value: nested_opt_string(obj, &["value"]), + account_id: nested_opt_string(obj, &["accountId", "account_id"]), + } +} + +fn parse_secrets_array(items: &[Value]) -> Vec { + items + .iter() + .filter_map(|item| { + let obj = item.as_object()?; + Some(parse_secret_slot_from_obj(obj)) + }) + .collect() +} + +fn parse_accounts(map: &BTreeMap) -> Vec { + let Some(Value::Array(items)) = map.get("accounts") else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let obj = item.as_object()?; + let id = nested_opt_string(obj, &["id"]).unwrap_or_else(new_account_id); + let nested_secrets = match obj.get("secrets") { + Some(Value::Array(arr)) => parse_secrets_array(arr), + _ => Vec::new(), + }; + Some(VaultAccountSlot { + id, + label: nested_opt_string(obj, &["label"]), + username: nested_opt_string(obj, &["username"]), + email: nested_opt_string(obj, &["email"]), + password: nested_opt_string(obj, &["password"]), + password_hint: nested_opt_string(obj, &["passwordHint", "password_hint"]), + secrets: nested_secrets, + }) + }) + .collect() +} + +fn parse_secrets(map: &BTreeMap) -> Vec { + let Some(Value::Array(items)) = map.get("secrets") else { + return Vec::new(); + }; + parse_secrets_array(items) +} + +fn nested_string(obj: &Map, keys: &[&str]) -> Option { + for key in keys { + if let Some(Value::String(value)) = obj.get(*key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn parse_share_to_ai(map: &BTreeMap) -> Option { + let obj = map + .get("sharedToAi") + .or_else(|| map.get("shared_to_ai"))? + .as_object()?; + let target_id = nested_string(obj, &["targetId", "target_id"])?; + let target_actor_id = + nested_string(obj, &["targetActorId", "target_actor_id"]).unwrap_or_default(); + let shared_at = nested_string(obj, &["sharedAt", "shared_at"]).unwrap_or_else(now_rfc3339); + let last_synced_at = nested_string(obj, &["lastSyncedAt", "last_synced_at"]); + Some(VaultShareToAi { + target_actor_id, + target_id, + shared_at, + last_synced_at, + }) +} + +fn parse_share_from(map: &BTreeMap) -> Option { + let obj = map + .get("sharedFrom") + .or_else(|| map.get("shared_from"))? + .as_object()?; + let source_id = nested_string(obj, &["sourceId", "source_id"])?; + let source_actor_id = + nested_string(obj, &["sourceActorId", "source_actor_id"]).unwrap_or_default(); + let shared_at = nested_string(obj, &["sharedAt", "shared_at"]).unwrap_or_else(now_rfc3339); + Some(VaultShareFrom { + source_actor_id, + source_id, + shared_at, + }) +} + +fn parse_login_playbook(map: &BTreeMap) -> Option { + let obj = map + .get("loginPlaybook") + .or_else(|| map.get("login_playbook"))? + .as_object()?; + let mode = nested_string(obj, &["mode"]).unwrap_or_else(|| "api_first".into()); + let preferred_account = + nested_string(obj, &["preferredAccount", "preferred_account"]).unwrap_or_else(|| "auto".into()); + Some(VaultLoginPlaybook { + mode, + preferred_account, + api_path: nested_string(obj, &["apiPath", "api_path"]), + account_selector: nested_string(obj, &["accountSelector", "account_selector"]), + password_selector: nested_string(obj, &["passwordSelector", "password_selector"]), + submit_selector: nested_string(obj, &["submitSelector", "submit_selector"]), + success_url_contains: nested_string(obj, &["successUrlContains", "success_url_contains"]), + human_note: nested_string(obj, &["humanNote", "human_note"]), + }) +} + +fn parse_login_session(map: &BTreeMap) -> Option { + let obj = map + .get("loginSession") + .or_else(|| map.get("login_session"))? + .as_object()?; + let cookie_header = nested_string(obj, &["cookieHeader", "cookie_header"]); + let expires_at = nested_string(obj, &["expiresAt", "expires_at"]); + let last_login_at = nested_string(obj, &["lastLoginAt", "last_login_at"]); + let source = nested_string(obj, &["source"]); + if cookie_header.is_none() + && expires_at.is_none() + && last_login_at.is_none() + && source.is_none() + { + return None; + } + Some(VaultLoginSession { + cookie_header, + expires_at, + last_login_at, + source, + }) +} + +fn parse_entry_markdown( + content: &str, + relative_path: &str, + status: VaultItemStatus, +) -> Result { + let (frontmatter, body) = split_frontmatter(content); + let Some(fm) = frontmatter else { + return Err(WebError::bad_request_code( + "vault_entry_invalid", + "密码箱条目缺少 frontmatter", + )); + }; + let map = parse_frontmatter_map(&fm); + let id = string_field(&map, &["id"]).ok_or_else(|| { + WebError::bad_request_code("vault_entry_invalid", "密码箱条目缺少 id") + })?; + let title = string_field(&map, &["title"]).unwrap_or_else(|| id.clone()); + let revision = string_field(&map, &["revision"]) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + let status = string_field(&map, &["status"]) + .and_then(|v| VaultItemStatus::parse(&v)) + .unwrap_or(status); + let mut record = VaultCredentialRecord { + id, + title, + url: string_field(&map, &["url"]), + urls: parse_urls_list(&map), + username: string_field(&map, &["username"]), + password: string_field(&map, &["password"]), + password_hint: string_field(&map, &["passwordHint", "password_hint"]), + email: string_field(&map, &["email"]), + apikey: string_field(&map, &["apikey", "apiKey", "api_key"]), + token: string_field(&map, &["token"]), + accounts: parse_accounts(&map), + secrets: parse_secrets(&map), + folder_path: normalize_folder_path( + string_field(&map, &["folderPath", "folder_path", "group"]).as_deref(), + ), + tags: parse_tags(&map), + fields: parse_fields(&map), + attachments: parse_attachments(&map), + notes_markdown: body.trim_start_matches('\n').to_string(), + status, + created_at: string_field(&map, &["createdAt", "created_at"]) + .unwrap_or_else(now_rfc3339), + updated_at: string_field(&map, &["updatedAt", "updated_at"]) + .unwrap_or_else(now_rfc3339), + relative_path: relative_path.to_string(), + revision, + shared_to_ai: parse_share_to_ai(&map), + shared_from: parse_share_from(&map), + login_playbook: parse_login_playbook(&map), + login_session: parse_login_session(&map), + }; + // In-memory only: synthesize multi slots for legacy single-field entries. + ensure_slots_and_primary_sync(&mut record); + Ok(record) +} + +/// L0-only rebuild: never logs secret values. +pub fn rebuild_index(vault_root: &Path) -> Result { + let mut index = empty_index(); + let mut max_revision = 0u64; + for (status, subdir) in [ + (VaultItemStatus::Active, "entries"), + (VaultItemStatus::Deleted, "trash/entries"), + ] { + let dir = vault_root.join(subdir); + if !dir.is_dir() { + continue; + } + let read_dir = fs::read_dir(&dir).map_err(|error| { + WebError::bad_request_code( + "vault_reindex_failed", + format!("无法扫描密码箱目录: {error}"), + ) + })?; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let file_name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("unknown.md"); + let relative = format!("{subdir}/{file_name}"); + let content = match fs::read_to_string(&path) { + Ok(v) => v, + Err(_) => { + // log only id/path/code — no body + eprintln!( + "vault_reindex_skip path={relative} code=vault_entry_read_failed" + ); + continue; + } + }; + match parse_entry_markdown(&content, &relative, status) { + Ok(record) => { + max_revision = max_revision.max(record.revision); + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + } + Err(error) => { + eprintln!( + "vault_reindex_skip path={relative} code={}", + error.code() + ); + } + } + } + } + index.revision = max_revision; + index.updated_at = now_rfc3339(); + write_index_atomic(vault_root, &index)?; + Ok(index) +} + +fn read_record_from_disk( + vault_root: &Path, + id: &str, + index: &VaultIndex, +) -> Result { + let entry = index.entries.get(id).ok_or_else(|| { + WebError::new( + StatusCode::NotFound, + "vault_item_not_found", + format!("密码箱条目不存在: {id}"), + ) + })?; + let path = abs_from_vault_rel(vault_root, &entry.relative_path)?; + let content = fs::read_to_string(&path).map_err(|_| { + WebError::new( + StatusCode::NotFound, + "vault_item_not_found", + format!("密码箱条目文件缺失: {id}"), + ) + })?; + parse_entry_markdown(&content, &entry.relative_path, entry.status) +} + +fn write_record_to_disk( + vault_root: &Path, + record: &VaultCredentialRecord, +) -> Result<(), WebError> { + let path = abs_from_vault_rel(vault_root, &record.relative_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "vault_write_failed", + format!("无法创建密码箱条目目录: {error}"), + ) + })?; + } + let body = render_entry_markdown(record); + let tmp = path.with_extension("md.tmp"); + fs::write(&tmp, body).map_err(|error| { + WebError::bad_request_code( + "vault_write_failed", + format!("无法写入密码箱条目: {error}"), + ) + })?; + fs::rename(&tmp, &path).map_err(|error| { + WebError::bad_request_code( + "vault_write_failed", + format!("无法提交密码箱条目: {error}"), + ) + })?; + Ok(()) +} + +pub fn list_credentials( + workspace_root: &Path, + status: VaultItemStatus, +) -> Result<(VaultIndex, Vec), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let index = load_index(&vault)?; + let mut items: Vec = index + .entries + .values() + .filter(|entry| entry.status == status) + .cloned() + .collect(); + items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(a.title.cmp(&b.title))); + Ok((index, items)) +} + +pub fn get_credential( + workspace_root: &Path, + id: &str, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let index = load_index(&vault)?; + read_record_from_disk(&vault, id, &index) +} + +/// Outcome of sharing a credential across workspaces (e.g. user vault → AI vault). +#[derive(Debug, Clone)] +pub struct ShareCredentialOutcome { + pub item: VaultCredentialRecord, + /// Updated source record with `shared_to_ai` link. + pub source: VaultCredentialRecord, + /// True when an existing AI copy was updated instead of creating a new one. + pub updated_existing: bool, + /// Cipher keys newly written into the target cipher-book. + pub cipher_keys_copied: Vec, + /// Keys already present (non-empty) on target; not overwritten. + pub cipher_keys_skipped_existing: Vec, + /// Keys referenced by the credential but missing/empty on source. + pub cipher_keys_missing_in_source: Vec, +} + +/// Collect unique `[Key]` placeholders from secret templates (empty book → all keys "missing"). +pub fn collect_cipher_keys_from_templates<'a, I>(templates: I) -> Vec +where + I: IntoIterator>, +{ + let empty = empty_cipher_book(); + let mut keys: Vec = Vec::new(); + for template in templates.into_iter().flatten() { + if !template.contains('[') { + continue; + } + let resolved = resolve_cipher_placeholders(template, &empty); + for key in resolved.missing_keys { + if !keys.iter().any(|k| k == &key) { + keys.push(key); + } + } + } + keys +} + +/// Copy missing cipher fragments from source vault to target vault. +/// Never overwrites a non-empty target fragment. +pub fn sync_cipher_keys_for_share( + source_workspace: &Path, + target_workspace: &Path, + keys: &[String], +) -> Result<(Vec, Vec, Vec), WebError> { + if keys.is_empty() { + return Ok((Vec::new(), Vec::new(), Vec::new())); + } + let source_book = load_cipher_book(source_workspace)?; + let mut target_book = load_cipher_book(target_workspace)?; + let mut copied = Vec::new(); + let mut skipped_existing = Vec::new(); + let mut missing_in_source = Vec::new(); + let mut changed = false; + + for key in keys { + let key = key.trim(); + if key.is_empty() { + continue; + } + let Some(value) = source_book + .fragments + .get(key) + .filter(|v| !v.is_empty()) + .cloned() + else { + missing_in_source.push(key.to_string()); + continue; + }; + if target_book + .fragments + .get(key) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + skipped_existing.push(key.to_string()); + continue; + } + target_book + .fragments + .insert(key.to_string(), value); + copied.push(key.to_string()); + changed = true; + } + + if changed { + let vault = ensure_vault_directories(target_workspace)?; + target_book.revision = target_book.revision.saturating_add(1); + target_book.updated_at = now_rfc3339(); + write_cipher_book_atomic(&vault, &target_book)?; + } + + Ok((copied, skipped_existing, missing_in_source)) +} + +fn merge_ai_shared_tags(mut tags: Vec, extra_tags: &[String]) -> Vec { + for t in extra_tags { + let t = t.trim(); + if !t.is_empty() && !tags.iter().any(|x| x == t) { + tags.push(t.to_string()); + } + } + if !tags.iter().any(|t| t == "ai-shared") { + tags.push("ai-shared".to_string()); + } + tags +} + +/// Persist share-link fields on an existing record (rewrite index + disk). +fn write_record_with_index( + workspace_root: &Path, + record: &VaultCredentialRecord, +) -> Result<(), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + write_record_to_disk(&vault, record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(record)); + index.revision = index.revision.saturating_add(1).max(record.revision); + index.updated_at = record.updated_at.clone(); + write_index_atomic(&vault, &index)?; + Ok(()) +} + +/// Copy mutable credential fields from source onto an existing AI copy (keep id / share origin). +fn apply_source_fields_to_ai_copy( + target: &mut VaultCredentialRecord, + source: &VaultCredentialRecord, + folder_path_override: Option<&str>, + extra_tags: &[String], +) { + target.title = source.title.clone(); + target.url = source.url.clone(); + target.urls = source.urls.clone(); + target.username = source.username.clone(); + target.password = source.password.clone(); + target.password_hint = source.password_hint.clone(); + target.email = source.email.clone(); + target.apikey = source.apikey.clone(); + target.token = source.token.clone(); + target.accounts = source.accounts.clone(); + target.secrets = source.secrets.clone(); + target.fields = source.fields.clone(); + target.notes_markdown = source.notes_markdown.clone(); + target.folder_path = folder_path_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| source.folder_path.clone()) + .or_else(|| target.folder_path.clone()); + target.tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); + // Keep AI-side login session always; seed playbook only if missing. + if target.login_playbook.is_none() { + target.login_playbook = source.login_playbook.clone().or_else(|| { + Some(VaultLoginPlaybook::default_for_credential( + source.email.as_deref(), + source.username.as_deref(), + )) + }); + } + normalize_record_urls(target); + target.updated_at = now_rfc3339(); + target.revision = target.revision.saturating_add(1); +} + +/// Push source content to an existing AI copy by id (used by edit-sync and re-share). +pub fn sync_shared_ai_copy( + source_workspace: &Path, + source: &VaultCredentialRecord, + target_workspace: &Path, + target_id: &str, + source_actor_id: &str, + target_actor_id: &str, + folder_path_override: Option<&str>, + sync_cipher_keys: bool, +) -> Result { + let mut cipher_keys_copied = Vec::new(); + let mut cipher_keys_skipped_existing = Vec::new(); + let mut cipher_keys_missing_in_source = Vec::new(); + if sync_cipher_keys { + let templates = collect_all_secret_templates(&source); + let keys = collect_cipher_keys_from_templates(templates); + let (copied, skipped, missing) = + sync_cipher_keys_for_share(source_workspace, target_workspace, &keys)?; + cipher_keys_copied = copied; + cipher_keys_skipped_existing = skipped; + cipher_keys_missing_in_source = missing; + } + + let mut target = get_credential(target_workspace, target_id)?; + if target.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_share_target_inactive", + "AI 密码本副本已删除,请重新共享", + )); + } + let now = now_rfc3339(); + apply_source_fields_to_ai_copy(&mut target, source, folder_path_override, &[]); + let shared_at = source + .shared_to_ai + .as_ref() + .map(|l| l.shared_at.clone()) + .or_else(|| target.shared_from.as_ref().map(|o| o.shared_at.clone())) + .unwrap_or_else(|| now.clone()); + target.shared_from = Some(VaultShareFrom { + source_actor_id: source_actor_id.to_string(), + source_id: source.id.clone(), + shared_at: shared_at.clone(), + }); + write_record_with_index(target_workspace, &target)?; + + let mut source_updated = source.clone(); + source_updated.shared_to_ai = Some(VaultShareToAi { + target_actor_id: target_actor_id.to_string(), + target_id: target.id.clone(), + shared_at, + last_synced_at: Some(now), + }); + source_updated.updated_at = now_rfc3339(); + // Do not bump source revision solely for link metadata if already written by update — + // callers that just updated will rewrite; bump lightly so index carries link. + source_updated.revision = source_updated.revision.saturating_add(1); + write_record_with_index(source_workspace, &source_updated)?; + + Ok(ShareCredentialOutcome { + item: target, + source: source_updated, + updated_existing: true, + cipher_keys_copied, + cipher_keys_skipped_existing, + cipher_keys_missing_in_source, + }) +} + +/// Copy a credential (including secret templates) into another workspace vault. +/// Secrets are re-written as-is (cipher `[Key]` templates preserved). +/// When `sync_cipher_keys` is true, missing target cipher fragments are copied +/// from the source book (existing non-empty target keys are never overwritten). +/// If source already has `shared_to_ai` and the target still exists, **updates** that copy. +/// Tags get `ai-shared` if missing. Optional `folder_path_override` (e.g. `from-user`). +pub fn share_credential_to_workspace( + source_workspace: &Path, + target_workspace: &Path, + id: &str, + folder_path_override: Option<&str>, + extra_tags: &[String], + sync_cipher_keys: bool, + source_actor_id: &str, + target_actor_id: &str, +) -> Result { + let source = get_credential(source_workspace, id)?; + if source.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_share_inactive", + "只能共享在用条目到 AI 密码本", + )); + } + + // Re-share / sync existing linked copy when still present. + if let Some(link) = source.shared_to_ai.clone() { + if !link.target_id.is_empty() + && get_credential(target_workspace, &link.target_id).is_ok() + { + return sync_shared_ai_copy( + source_workspace, + &source, + target_workspace, + &link.target_id, + source_actor_id, + target_actor_id, + folder_path_override, + sync_cipher_keys, + ); + } + } + + let mut cipher_keys_copied = Vec::new(); + let mut cipher_keys_skipped_existing = Vec::new(); + let mut cipher_keys_missing_in_source = Vec::new(); + if sync_cipher_keys { + let templates = collect_all_secret_templates(&source); + let keys = collect_cipher_keys_from_templates(templates); + let (copied, skipped, missing) = + sync_cipher_keys_for_share(source_workspace, target_workspace, &keys)?; + cipher_keys_copied = copied; + cipher_keys_skipped_existing = skipped; + cipher_keys_missing_in_source = missing; + } + + let tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); + let folder = folder_path_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| source.folder_path.clone()); + let mut item = create_credential( + target_workspace, + VaultCreateInput { + title: source.title.clone(), + url: source.url.clone(), + urls: source.urls.clone(), + username: source.username.clone(), + password: source.password.clone(), + password_hint: source.password_hint.clone(), + email: source.email.clone(), + apikey: source.apikey.clone(), + token: source.token.clone(), + accounts: source.accounts.clone(), + secrets: source.secrets.clone(), + folder_path: folder, + tags, + fields: source.fields.clone(), + notes_markdown: source.notes_markdown.clone(), + }, + )?; + let now = now_rfc3339(); + item.shared_from = Some(VaultShareFrom { + source_actor_id: source_actor_id.to_string(), + source_id: source.id.clone(), + shared_at: now.clone(), + }); + item.login_playbook = Some(VaultLoginPlaybook::default_for_credential( + item.email.as_deref(), + item.username.as_deref(), + )); + item.login_session = None; + write_record_with_index(target_workspace, &item)?; + + let mut source_updated = source; + source_updated.shared_to_ai = Some(VaultShareToAi { + target_actor_id: target_actor_id.to_string(), + target_id: item.id.clone(), + shared_at: now.clone(), + last_synced_at: Some(now), + }); + source_updated.revision = source_updated.revision.saturating_add(1); + source_updated.updated_at = now_rfc3339(); + write_record_with_index(source_workspace, &source_updated)?; + + Ok(ShareCredentialOutcome { + item, + source: source_updated, + updated_existing: false, + cipher_keys_copied, + cipher_keys_skipped_existing, + cipher_keys_missing_in_source, + }) +} + +/// After user vault update: if linked to AI copy, push fields + optional cipher keys. +pub fn maybe_sync_after_source_update( + source_workspace: &Path, + source: &VaultCredentialRecord, + target_workspace: &Path, + source_actor_id: &str, + sync_cipher_keys: bool, +) -> Result, WebError> { + let Some(link) = source.shared_to_ai.as_ref() else { + return Ok(None); + }; + if link.target_id.is_empty() { + return Ok(None); + } + match sync_shared_ai_copy( + source_workspace, + source, + target_workspace, + &link.target_id, + source_actor_id, + &link.target_actor_id, + None, + sync_cipher_keys, + ) { + Ok(outcome) => Ok(Some(outcome)), + Err(err) if err.code() == "vault_item_not_found" || err.code() == "vault_share_target_inactive" => { + Ok(None) + } + Err(err) => Err(err), + } +} + +/// Outcome of revoking a user→AI share link. +#[derive(Debug, Clone)] +pub struct UnshareCredentialOutcome { + /// Source credential with `shared_to_ai` cleared. + pub source: VaultCredentialRecord, + /// Target AI copy id that was soft-deleted (if any). + pub removed_target_id: Option, + /// Target actor id from the former link (if any). + #[allow(dead_code)] // surfaced to HTTP handlers / future callers + pub target_actor_id: Option, + /// True when the AI copy was soft-deleted; false if already missing/inactive. + pub target_deleted: bool, + /// True when source had no share link (idempotent no-op beyond re-read). + pub was_shared: bool, +} + +/// Revoke share-to-AI: clear source `shared_to_ai`, soft-delete the AI copy. +/// Cipher-book keys on the AI workspace are left intact (may be shared by other entries). +/// Idempotent: if already unshared, returns the source as-is with `was_shared=false`. +/// If `target_workspace` does not exist or the AI copy is missing, still clears the source link. +pub fn unshare_credential_from_ai( + source_workspace: &Path, + target_workspace: &Path, + id: &str, +) -> Result { + let source = get_credential(source_workspace, id)?; + let Some(link) = source.shared_to_ai.clone() else { + return Ok(UnshareCredentialOutcome { + source, + removed_target_id: None, + target_actor_id: None, + target_deleted: false, + was_shared: false, + }); + }; + + let mut target_deleted = false; + let removed_target_id = if link.target_id.is_empty() { + None + } else if !target_workspace.exists() { + // AI workspace not provisioned — clear link only. + Some(link.target_id.clone()) + } else { + match get_credential(target_workspace, &link.target_id) { + Ok(target) if target.status == VaultItemStatus::Active => { + let _ = soft_delete_credential(target_workspace, &link.target_id)?; + target_deleted = true; + Some(link.target_id.clone()) + } + Ok(_) => { + // Already deleted/inactive — still clear source link. + Some(link.target_id.clone()) + } + Err(err) + if err.code() == "vault_item_not_found" + || err.code() == "vault_share_target_inactive" => + { + // Target gone; still clear source link. + Some(link.target_id.clone()) + } + Err(err) => return Err(err), + } + }; + + let mut source_updated = source; + source_updated.shared_to_ai = None; + source_updated.updated_at = now_rfc3339(); + source_updated.revision = source_updated.revision.saturating_add(1); + write_record_with_index(source_workspace, &source_updated)?; + + Ok(UnshareCredentialOutcome { + source: source_updated, + removed_target_id, + target_actor_id: Some(link.target_actor_id), + target_deleted, + was_shared: true, + }) +} + +pub fn create_credential( + workspace_root: &Path, + input: VaultCreateInput, +) -> Result { + let title = input.title.trim().to_string(); + if title.is_empty() { + return Err(WebError::bad_request_code( + "vault_title_required", + "title 不能为空", + )); + } + if let Some(password) = input.password.as_deref() { + if is_mask_sentinel(password) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入 password", + )); + } + } + if let Some(apikey) = input.apikey.as_deref() { + if is_mask_sentinel(apikey) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入 apikey", + )); + } + } + if let Some(token) = input.token.as_deref() { + if is_mask_sentinel(token) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入 token", + )); + } + } + validate_fields(&input.fields)?; + validate_hint_not_secret(input.password_hint.as_deref(), input.password.as_deref())?; + let accounts = sanitize_accounts(input.accounts)?; + let secrets = sanitize_secrets(input.secrets)?; + + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let id = new_credential_id(); + let now = now_rfc3339(); + let revision = index.revision.saturating_add(1); + let mut record = VaultCredentialRecord { + id: id.clone(), + title, + url: input.url.filter(|v| !v.trim().is_empty()), + urls: input.urls, + username: input.username.filter(|v| !v.trim().is_empty()), + password: input.password.filter(|v| !v.is_empty()), + password_hint: input.password_hint.filter(|v| !v.trim().is_empty()), + email: input.email.filter(|v| !v.trim().is_empty()), + apikey: input.apikey.filter(|v| !v.is_empty()), + token: input.token.filter(|v| !v.is_empty()), + accounts, + secrets, + folder_path: normalize_folder_path(input.folder_path.as_deref()), + tags: input.tags, + fields: input.fields, + attachments: Vec::new(), + notes_markdown: if input.notes_markdown.trim().is_empty() { + "## Notes\n\n".into() + } else { + input.notes_markdown + }, + status: VaultItemStatus::Active, + created_at: now.clone(), + updated_at: now.clone(), + relative_path: entry_rel_path(&id, VaultItemStatus::Active), + revision, + shared_to_ai: None, + shared_from: None, + login_playbook: None, + login_session: None, + }; + ensure_slots_and_primary_sync(&mut record); + write_record_to_disk(&vault, &record)?; + index.entries.insert(id, index_entry_from_record(&record)); + index.revision = revision; + index.updated_at = now; + write_index_atomic(&vault, &index)?; + Ok(record) +} + +fn apply_secret_patch(current: Option, patch: &SecretPatch) -> Result, WebError> { + match patch { + SecretPatch::Omit => Ok(current), + SecretPatch::Clear => Ok(None), + SecretPatch::Set(value) => { + if is_mask_sentinel(value) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密钥字段", + )); + } + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value.clone())) + } + } + } +} + +pub fn update_credential( + workspace_root: &Path, + id: &str, + input: VaultUpdateInput, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let mut record = read_record_from_disk(&vault, id, &index)?; + if record.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_item_not_active", + "只能编辑活跃条目;请先恢复", + )); + } + if let Some(base) = input.base_revision { + if base != record.revision { + return Err(WebError::new( + StatusCode::Conflict, + "vault_revision_conflict", + "密码箱条目已被其他操作更新,请刷新后重试", + )); + } + } + + if let Some(title) = input.title { + let title = title.trim().to_string(); + if title.is_empty() { + return Err(WebError::bad_request_code( + "vault_title_required", + "title 不能为空", + )); + } + record.title = title; + } + if let Some(urls) = input.urls { + // Full replace of equivalent URL list; primary derived as first. + record.urls = urls; + record.url = record.urls.first().cloned(); + normalize_record_urls(&mut record); + } else if let Some(url) = input.url { + // Legacy single-url patch: replace primary, keep other fallbacks. + let new_primary = url.filter(|v| !v.trim().is_empty()); + match new_primary { + None => { + if !record.urls.is_empty() { + record.urls.remove(0); + } + record.url = None; + normalize_record_urls(&mut record); + } + Some(p) => { + if record.urls.is_empty() { + record.urls = vec![p.clone()]; + } else { + record.urls[0] = p.clone(); + } + record.url = Some(p); + normalize_record_urls(&mut record); + } + } + } + let mut primary_touched = false; + let username_patched = input.username.is_some(); + let email_patched = input.email.is_some(); + let hint_patched = input.password_hint.is_some(); + if let Some(username) = input.username { + record.username = username.filter(|v| !v.trim().is_empty()); + primary_touched = true; + } + if let Some(email) = input.email { + record.email = email.filter(|v| !v.trim().is_empty()); + primary_touched = true; + } + if let Some(hint) = input.password_hint { + record.password_hint = hint.filter(|v| !v.trim().is_empty()); + primary_touched = true; + } + if let Some(folder) = input.folder_path { + record.folder_path = normalize_folder_path(folder.as_deref()); + } + if let Some(tags) = input.tags { + record.tags = tags; + } + if let Some(fields) = input.fields { + validate_fields(&fields)?; + record.fields = fields; + } + if let Some(notes) = input.notes_markdown { + record.notes_markdown = notes; + } + if input.password != SecretPatch::Omit { + record.password = apply_secret_patch(record.password, &input.password)?; + primary_touched = true; + } + if input.apikey != SecretPatch::Omit { + record.apikey = apply_secret_patch(record.apikey, &input.apikey)?; + primary_touched = true; + } + if input.token != SecretPatch::Omit { + record.token = apply_secret_patch(record.token, &input.token)?; + primary_touched = true; + } + validate_hint_not_secret(record.password_hint.as_deref(), record.password.as_deref())?; + + if let Some(accounts) = input.accounts { + record.accounts = sanitize_accounts(accounts)?; + // Nested secrets under accounts are source of truth; top-level rebuilt in ensure. + if record.accounts.is_empty() { + // Explicit empty multi-list: clear primary identity mirrors too. + record.username = None; + record.email = None; + record.password = None; + record.password_hint = None; + record.secrets.clear(); + } else { + // Prefer nested; if client still sent empty nested but top-level secrets + // in the same request, merge happens below via input.secrets. + record.secrets = flatten_secrets_from_accounts(&record.accounts); + } + } else if primary_touched { + // Mirror legacy primary identity into first account slot when multi-list not sent. + if record.accounts.is_empty() { + // leave empty; ensure_slots will synthesize + } else if let Some(acc) = record.accounts.first_mut() { + if username_patched { + acc.username = record.username.clone(); + } + if email_patched { + acc.email = record.email.clone(); + } + if hint_patched { + acc.password_hint = record.password_hint.clone(); + } + if input.password != SecretPatch::Omit { + acc.password = record.password.clone(); + } + } + // Mirror primary apikey/token into first account nested secret when multi-lists not sent. + if input.secrets.is_none() { + if input.apikey != SecretPatch::Omit { + let mut found = false; + if let Some(acc) = record.accounts.first_mut() { + if let Some(sec) = acc.secrets.iter_mut().find(|s| s.kind == "apikey") { + sec.value = record.apikey.clone(); + found = true; + } else if secret_present(record.apikey.as_deref()) { + let aid = acc.id.clone(); + acc.secrets.push(VaultSecretSlot { + id: new_secret_id(), + kind: "apikey".into(), + label: Some("API Key".into()), + value: record.apikey.clone(), + account_id: Some(aid), + }); + found = true; + } + } + if !found { + if let Some(sec) = record.secrets.iter_mut().find(|s| s.kind == "apikey") { + sec.value = record.apikey.clone(); + } else if secret_present(record.apikey.as_deref()) { + record.secrets.push(VaultSecretSlot { + id: new_secret_id(), + kind: "apikey".into(), + label: Some("API Key".into()), + value: record.apikey.clone(), + account_id: None, + }); + } + } + } + if input.token != SecretPatch::Omit { + let mut found = false; + if let Some(acc) = record.accounts.first_mut() { + if let Some(sec) = acc.secrets.iter_mut().find(|s| s.kind == "token") { + sec.value = record.token.clone(); + found = true; + } else if secret_present(record.token.as_deref()) { + let aid = acc.id.clone(); + acc.secrets.push(VaultSecretSlot { + id: new_secret_id(), + kind: "token".into(), + label: Some("Token".into()), + value: record.token.clone(), + account_id: Some(aid), + }); + found = true; + } + } + if !found { + if let Some(sec) = record.secrets.iter_mut().find(|s| s.kind == "token") { + sec.value = record.token.clone(); + } else if secret_present(record.token.as_deref()) { + record.secrets.push(VaultSecretSlot { + id: new_secret_id(), + kind: "token".into(), + label: Some("Token".into()), + value: record.token.clone(), + account_id: None, + }); + } + } + } + } + } + if let Some(secrets) = input.secrets { + // Legacy top-level secrets replace: only apply when accounts were not also replaced + // with nested secrets. If accounts already carry nested secrets, ignore top-level + // (prefer nested). Empty top-level with nested accounts: clear only when accounts + // also empty nested. + let nested_any = record.accounts.iter().any(|a| !a.secrets.is_empty()); + if nested_any { + // Accounts are source of truth; rebuild flatten from them. + record.secrets = flatten_secrets_from_accounts(&record.accounts); + } else { + record.secrets = sanitize_secrets(secrets)?; + if record.secrets.is_empty() { + record.apikey = None; + record.token = None; + } + } + } + + ensure_slots_and_primary_sync(&mut record); + validate_hint_not_secret(record.password_hint.as_deref(), record.password.as_deref())?; + for acc in &record.accounts { + validate_hint_not_secret(acc.password_hint.as_deref(), acc.password.as_deref())?; + } + + let now = now_rfc3339(); + record.updated_at = now.clone(); + record.revision = record.revision.saturating_add(1); + write_record_to_disk(&vault, &record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + index.revision = index.revision.saturating_add(1).max(record.revision); + index.updated_at = now; + write_index_atomic(&vault, &index)?; + Ok(record) +} + +fn move_path(from: &Path, to: &Path) -> Result<(), WebError> { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法创建目标目录: {error}"), + ) + })?; + } + fs::rename(from, to).map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法移动密码箱文件: {error}"), + ) + }) +} + +pub fn soft_delete_credential( + workspace_root: &Path, + id: &str, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let mut record = read_record_from_disk(&vault, id, &index)?; + if record.status == VaultItemStatus::Deleted { + return Ok(record); + } + let old_path = abs_from_vault_rel(&vault, &record.relative_path)?; + let new_rel = entry_rel_path(id, VaultItemStatus::Deleted); + let new_path = abs_from_vault_rel(&vault, &new_rel)?; + move_path(&old_path, &new_path)?; + // move attachments dir if present + let active_att = vault.join("attachments").join(id); + let trash_att = vault.join("trash").join("attachments").join(id); + if active_att.exists() { + let _ = move_path(&active_att, &trash_att); + } + for att in &mut record.attachments { + if att.relative_path.starts_with("attachments/") { + att.relative_path = format!("trash/{}", att.relative_path); + } + } + record.status = VaultItemStatus::Deleted; + record.relative_path = new_rel; + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_to_disk(&vault, &record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + index.revision = index.revision.saturating_add(1); + index.updated_at = record.updated_at.clone(); + write_index_atomic(&vault, &index)?; + Ok(record) +} + +pub fn restore_credential( + workspace_root: &Path, + id: &str, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let mut record = read_record_from_disk(&vault, id, &index)?; + if record.status == VaultItemStatus::Active { + return Ok(record); + } + let old_path = abs_from_vault_rel(&vault, &record.relative_path)?; + let new_rel = entry_rel_path(id, VaultItemStatus::Active); + let new_path = abs_from_vault_rel(&vault, &new_rel)?; + move_path(&old_path, &new_path)?; + let trash_att = vault.join("trash").join("attachments").join(id); + let active_att = vault.join("attachments").join(id); + if trash_att.exists() { + let _ = move_path(&trash_att, &active_att); + } + for att in &mut record.attachments { + if let Some(rest) = att.relative_path.strip_prefix("trash/") { + att.relative_path = rest.to_string(); + } + } + record.status = VaultItemStatus::Active; + record.relative_path = new_rel; + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_to_disk(&vault, &record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + index.revision = index.revision.saturating_add(1); + index.updated_at = record.updated_at.clone(); + write_index_atomic(&vault, &index)?; + Ok(record) +} + +pub fn purge_credential(workspace_root: &Path, id: &str) -> Result<(), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let entry = index.entries.get(id).ok_or_else(|| { + WebError::new( + StatusCode::NotFound, + "vault_item_not_found", + format!("密码箱条目不存在: {id}"), + ) + })?; + if entry.status != VaultItemStatus::Deleted { + return Err(WebError::bad_request_code( + "vault_purge_requires_deleted", + "只能永久删除回收站中的条目;请先 soft-delete", + )); + } + let path = abs_from_vault_rel(&vault, &entry.relative_path)?; + if path.exists() { + fs::remove_file(&path).map_err(|error| { + WebError::bad_request_code( + "vault_purge_failed", + format!("无法删除密码箱条目: {error}"), + ) + })?; + } + let trash_att = vault.join("trash").join("attachments").join(id); + if trash_att.exists() { + let _ = fs::remove_dir_all(&trash_att); + } + index.entries.remove(id); + index.revision = index.revision.saturating_add(1); + index.updated_at = now_rfc3339(); + write_index_atomic(&vault, &index)?; + Ok(()) +} + +pub fn secret_field_state(value: Option<&str>) -> Value { + if secret_present(value) { + json!({ "state": "masked" }) + } else { + json!({ "state": "absent" }) + } +} + +/// Raw reveal without cipher expansion (tests / callers that need the template as stored). +#[allow(dead_code)] +pub fn secret_field_revealed(value: Option<&str>) -> Value { + match value { + Some(v) if !v.is_empty() => json!({ "state": "revealed", "value": v }), + _ => json!({ "state": "absent" }), + } +} + +fn project_share_to_ai(link: &VaultShareToAi) -> Value { + json!({ + "targetActorId": link.target_actor_id, + "targetId": link.target_id, + "sharedAt": link.shared_at, + "lastSyncedAt": link.last_synced_at, + }) +} + +fn project_share_from(origin: &VaultShareFrom) -> Value { + json!({ + "sourceActorId": origin.source_actor_id, + "sourceId": origin.source_id, + "sharedAt": origin.shared_at, + }) +} + +fn project_account_slot_l0(acc: &VaultAccountSlot, book: Option<&CipherBook>) -> Value { + let (username, username_template) = + expand_text_with_cipher(acc.username.as_deref(), book); + let (email, email_template) = expand_text_with_cipher(acc.email.as_deref(), book); + let secrets: Vec = acc.secrets.iter().map(project_secret_slot_l0).collect(); + let mut slot = json!({ + "id": acc.id, + "label": acc.label, + "username": username, + "email": email, + "passwordHint": acc.password_hint, + "password": secret_field_state(acc.password.as_deref()), + "secrets": secrets, + }); + // 编辑表单用模板原文;展示用展开后的值。 + if let Some(t) = username_template { + slot["usernameTemplate"] = json!(t); + } + if let Some(t) = email_template { + slot["emailTemplate"] = json!(t); + } + slot +} + +fn project_secret_slot_l0(sec: &VaultSecretSlot) -> Value { + let mut slot = json!({ + "id": sec.id, + "kind": sec.kind, + "label": sec.label, + "value": secret_field_state(sec.value.as_deref()), + }); + if let Some(aid) = &sec.account_id { + slot["accountId"] = json!(aid); + } + slot +} + +/// L0 projection for get/detail. Expands `[Key]` in username/email for display; +/// keeps raw templates as `usernameTemplate` / `emailTemplate` for edit forms. +/// When `workspace_root` is provided, loads the cipher-book; otherwise no expansion. +pub fn project_item_l0_with_cipher( + record: &VaultCredentialRecord, + workspace_root: Option<&Path>, +) -> Value { + let book = workspace_root.and_then(|root| load_cipher_book(root).ok()); + let book_ref = book.as_ref(); + let accounts: Vec = record + .accounts + .iter() + .map(|a| project_account_slot_l0(a, book_ref)) + .collect(); + let secrets: Vec = record.secrets.iter().map(project_secret_slot_l0).collect(); + let (username, username_template) = + expand_text_with_cipher(record.username.as_deref(), book_ref); + let (email, email_template) = expand_text_with_cipher(record.email.as_deref(), book_ref); + let urls_for_api: Vec = if record.urls.is_empty() { + record + .url + .clone() + .into_iter() + .collect() + } else { + record.urls.clone() + }; + let mut item = json!({ + "schema": "mnote.vault.item.v1", + "id": record.id, + "title": record.title, + "url": record.url, + "urls": urls_for_api, + "username": username, + "email": email, + "passwordHint": record.password_hint, + "folderPath": record.folder_path, + "password": secret_field_state(record.password.as_deref()), + "apikey": secret_field_state(record.apikey.as_deref()), + "token": secret_field_state(record.token.as_deref()), + "accounts": accounts, + "secrets": secrets, + "accountCount": record.accounts.len(), + "secretCount": record.secrets.len(), + "tags": record.tags, + "fields": record.fields, + "attachments": record.attachments, + "notesMarkdown": record.notes_markdown, + "status": record.status.as_str(), + "createdAt": record.created_at, + "updatedAt": record.updated_at, + "relativePath": record.relative_path, + "revision": record.revision, + "isSharedToAi": record.shared_to_ai.is_some(), + "isAiSharedCopy": record.shared_from.is_some() + || record.tags.iter().any(|t| t == "ai-shared"), + "hasLoginSession": record + .login_session + .as_ref() + .and_then(|s| s.cookie_header.as_ref()) + .map(|c| !c.trim().is_empty()) + .unwrap_or(false), + "sessionExpiresAt": record + .login_session + .as_ref() + .and_then(|s| s.expires_at.clone()), + "lastLoginAt": record + .login_session + .as_ref() + .and_then(|s| s.last_login_at.clone()), + }); + if let Some(t) = username_template { + item["usernameTemplate"] = json!(t); + } + if let Some(t) = email_template { + item["emailTemplate"] = json!(t); + } + if let Some(link) = &record.shared_to_ai { + item["sharedToAi"] = project_share_to_ai(link); + } + if let Some(origin) = &record.shared_from { + item["sharedFrom"] = project_share_from(origin); + } + if let Some(pb) = &record.login_playbook { + item["loginPlaybook"] = json!({ + "mode": pb.mode, + "preferredAccount": pb.preferred_account, + "apiPath": pb.api_path, + "accountSelector": pb.account_selector, + "passwordSelector": pb.password_selector, + "submitSelector": pb.submit_selector, + "successUrlContains": pb.success_url_contains, + "humanNote": pb.human_note, + }); + } + // Never project cookieHeader in L0. + if let Some(sess) = &record.login_session { + item["loginSession"] = json!({ + "hasCookie": sess.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false), + "expiresAt": sess.expires_at, + "lastLoginAt": sess.last_login_at, + "source": sess.source, + }); + } + item +} + +/// Backward-compatible L0 projection without cipher expansion (tests / callers without root). +#[allow(dead_code)] +pub fn project_item_l0(record: &VaultCredentialRecord) -> Value { + project_item_l0_with_cipher(record, None) +} + +/// Expand username/email for list rows when cipher-book is available. +pub fn project_list_entry_with_cipher(entry: &VaultIndexEntry, book: Option<&CipherBook>) -> Value { + let (username, _) = expand_text_with_cipher(entry.username.as_deref(), book); + let (email, _) = expand_text_with_cipher(entry.email.as_deref(), book); + let list_urls: Vec = { + let mut v = Vec::new(); + if let Some(u) = entry.url.as_ref() { + v.push(u.clone()); + } + for u in &entry.urls { + if !v.iter().any(|x| x == u) { + v.push(u.clone()); + } + } + v + }; + let mut item = json!({ + "id": entry.id, + "title": entry.title, + "url": entry.url, + "urls": list_urls, + "username": username, + "email": email, + "passwordHint": entry.password_hint, + "folderPath": entry.folder_path, + "tags": entry.tags, + "hasPassword": entry.has_password, + "hasApikey": entry.has_apikey, + "hasToken": entry.has_token, + "accountCount": entry.account_count, + "secretCount": entry.secret_count, + "attachmentCount": entry.attachment_count, + "relativePath": entry.relative_path, + "updatedAt": entry.updated_at, + "status": entry.status.as_str(), + "revision": entry.revision, + "isSharedToAi": entry.shared_to_ai.is_some(), + "isAiSharedCopy": entry.shared_from.is_some() + || entry.tags.iter().any(|t| t == "ai-shared"), + "hasLoginSession": entry.has_login_session, + "sessionExpiresAt": entry.session_expires_at, + "loginMode": entry.login_mode, + }); + if let Some(link) = &entry.shared_to_ai { + item["sharedToAi"] = project_share_to_ai(link); + } + if let Some(origin) = &entry.shared_from { + item["sharedFrom"] = project_share_from(origin); + } + item +} + +/// Backward-compatible list projection without cipher expansion. +#[allow(dead_code)] +pub fn project_list_entry(entry: &VaultIndexEntry) -> Value { + project_list_entry_with_cipher(entry, None) +} + +/// Persist login session on a credential (AI vault session write-back). +pub fn put_login_session( + workspace_root: &Path, + id: &str, + session: VaultLoginSession, +) -> Result { + let mut record = get_credential(workspace_root, id)?; + if record.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_session_inactive", + "只能给在用条目写入登录态", + )); + } + record.login_session = Some(session); + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_with_index(workspace_root, &record)?; + Ok(record) +} + +pub fn put_login_playbook( + workspace_root: &Path, + id: &str, + playbook: VaultLoginPlaybook, +) -> Result { + let mut record = get_credential(workspace_root, id)?; + record.login_playbook = Some(playbook); + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_with_index(workspace_root, &record)?; + Ok(record) +} + +/// Whether stored session should be treated as still valid (not expired). +pub fn login_session_is_fresh(session: &VaultLoginSession) -> bool { + let Some(cookie) = session.cookie_header.as_ref().map(|s| s.trim()) else { + return false; + }; + if cookie.is_empty() { + return false; + } + let Some(exp) = session.expires_at.as_ref().map(|s| s.trim()) else { + // No expiry: treat as fresh (TTL applied at write time normally). + return true; + }; + if exp.is_empty() { + return true; + } + // Lexicographic compare works for RFC3339 UTC timestamps (Z suffix). + let now = now_rfc3339(); + exp > now.as_str() +} + +/// Project reveal payload; expands cipher-book `[Key]` placeholders in the secret. +pub fn project_secret_revealed( + workspace_root: &Path, + template: Option<&str>, +) -> Result { + match resolve_secret_with_cipher_book(workspace_root, template)? { + None => Ok(json!({ "state": "absent" })), + Some(resolved) => { + let mut obj = json!({ + "state": "revealed", + "value": resolved.value, + }); + if let Some(t) = resolved.template { + obj["template"] = json!(t); + } + if !resolved.used_keys.is_empty() { + obj["usedCipherKeys"] = json!(resolved.used_keys); + } + if !resolved.missing_keys.is_empty() { + obj["missingCipherKeys"] = json!(resolved.missing_keys); + } + Ok(obj) + } + } +} + +pub fn reindex(workspace_root: &Path) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + rebuild_index(&vault) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_root() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("mnote-vault-store-{nanos}")); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn create_list_get_masks_password() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alice".into()), + password: Some("Li@[A]s3cret".into()), + password_hint: Some("Li@[A]***".into()), + tags: vec!["work".into()], + ..Default::default() + }, + ) + .expect("create"); + assert!(created.password.as_deref() == Some("Li@[A]s3cret")); + + let (_index, items) = list_credentials(&root, VaultItemStatus::Active).expect("list"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].title, "GitHub"); + assert!(items[0].has_password); + let projected = project_list_entry(&items[0]); + let body = projected.to_string(); + assert!(!body.contains("Li@[A]s3cret")); + assert!(projected["hasPassword"].as_bool().unwrap()); + + let got = get_credential(&root, &created.id).expect("get"); + let item = project_item_l0(&got); + assert_eq!(item["password"]["state"], "masked"); + assert!(item.get("password").unwrap().get("value").is_none()); + assert!(!item.to_string().contains("Li@[A]s3cret")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn update_title_only_keeps_password() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Old".into(), + password: Some("keep-me-secret".into()), + ..Default::default() + }, + ) + .unwrap(); + let updated = update_credential( + &root, + &created.id, + VaultUpdateInput { + title: Some("New".into()), + password: SecretPatch::Omit, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.title, "New"); + assert_eq!(updated.password.as_deref(), Some("keep-me-secret")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn mask_sentinel_rejected() { + let root = temp_root(); + let err = create_credential( + &root, + VaultCreateInput { + title: "X".into(), + password: Some("••••••••".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "vault_mask_sentinel_rejected"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn hint_equals_password_rejected() { + let root = temp_root(); + let err = create_credential( + &root, + VaultCreateInput { + title: "X".into(), + password: Some("same".into()), + password_hint: Some("same".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "vault_hint_equals_secret"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn fields_secret_key_forbidden() { + let root = temp_root(); + let mut fields = BTreeMap::new(); + fields.insert("otp_secret".into(), "x".into()); + let err = create_credential( + &root, + VaultCreateInput { + title: "X".into(), + fields, + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "vault_fields_secret_forbidden"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn soft_delete_restore_purge() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Del".into(), + password: Some("p".into()), + ..Default::default() + }, + ) + .unwrap(); + soft_delete_credential(&root, &created.id).unwrap(); + let (_i, active) = list_credentials(&root, VaultItemStatus::Active).unwrap(); + assert!(active.is_empty()); + let (_i, deleted) = list_credentials(&root, VaultItemStatus::Deleted).unwrap(); + assert_eq!(deleted.len(), 1); + assert!(vault_root(&root) + .join("trash") + .join("entries") + .join(format!("{}.md", created.id)) + .exists()); + restore_credential(&root, &created.id).unwrap(); + let (_i, active) = list_credentials(&root, VaultItemStatus::Active).unwrap(); + assert_eq!(active.len(), 1); + soft_delete_credential(&root, &created.id).unwrap(); + purge_credential(&root, &created.id).unwrap(); + let (_i, deleted) = list_credentials(&root, VaultItemStatus::Deleted).unwrap(); + assert!(deleted.is_empty()); + assert!(!vault_root(&root) + .join("trash") + .join("entries") + .join(format!("{}.md", created.id)) + .exists()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn parse_error_log_path_has_no_password_in_rebuild() { + let root = temp_root(); + let vault = ensure_vault_directories(&root).unwrap(); + fs::write( + vault.join("entries").join("bad.md"), + "not a valid entry without fm", + ) + .unwrap(); + // rebuild should not panic; bad entry skipped + let index = rebuild_index(&vault).unwrap(); + assert!(!index.entries.contains_key("bad")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn audit_never_contains_secret_value_key() { + let root = temp_root(); + append_vault_audit( + &root, + "reveal", + "user_x", + "cred_1", + Some("password"), + Some("req_1"), + true, + ) + .unwrap(); + let content = fs::read_to_string(vault_root(&root).join("audit.jsonl")).unwrap(); + assert!(content.contains("\"action\":\"reveal\"")); + assert!(!content.contains("\"value\"")); + assert!(!content.contains("password:")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn cipher_book_expands_brackets_on_resolve() { + let root = temp_root(); + set_cipher_fragment(&root, "A", "Li@").unwrap(); + set_cipher_fragment(&root, "B", "99").unwrap(); + let book = load_cipher_book(&root).unwrap(); + let resolved = resolve_cipher_placeholders("Li@[A]s3cret[B]", &book); + assert_eq!(resolved.value, "Li@Li@s3cret99"); + assert_eq!(resolved.used_keys, vec!["A".to_string(), "B".to_string()]); + assert!(resolved.missing_keys.is_empty()); + assert_eq!( + resolved.template.as_deref(), + Some("Li@[A]s3cret[B]") + ); + + let missing = resolve_cipher_placeholders("pre[Z]post", &book); + assert_eq!(missing.value, "pre[Z]post"); + assert_eq!(missing.missing_keys, vec!["Z".to_string()]); + + let projected = project_secret_revealed(&root, Some("x[A]y")).unwrap(); + assert_eq!(projected["value"], "xLi@y"); + assert_eq!(projected["template"], "x[A]y"); + assert!(!projected.to_string().contains("\"Li@\"") || projected["value"] == "xLi@y"); + // L0 cipher book never exposes fragment plaintext + let l0 = project_cipher_book_l0(&book); + assert!(!l0.to_string().contains("Li@")); + assert_eq!(l0["keys"].as_array().unwrap().len(), 2); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn cipher_expands_username_and_email_on_l0_and_share_collect() { + let root = temp_root(); + set_cipher_fragment(&root, "QY", "qquser@qq.com").unwrap(); + set_cipher_fragment(&root, "U", "alice_u").unwrap(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Mail".into(), + email: Some("[QY]".into()), + username: Some("pre[U]post".into()), + password: Some("plain-pass".into()), + ..Default::default() + }, + ) + .unwrap(); + // Disk keeps raw templates. + assert_eq!(created.email.as_deref(), Some("[QY]")); + assert_eq!(created.username.as_deref(), Some("pre[U]post")); + + let got = get_credential(&root, &created.id).unwrap(); + let item = project_item_l0_with_cipher(&got, Some(&root)); + assert_eq!(item["email"], "qquser@qq.com"); + assert_eq!(item["emailTemplate"], "[QY]"); + assert_eq!(item["username"], "prealice_upost"); + assert_eq!(item["usernameTemplate"], "pre[U]post"); + + // Without cipher root: no expansion (edit-safe raw path). + let raw = project_item_l0(&got); + assert_eq!(raw["email"], "[QY]"); + assert!(raw.get("emailTemplate").is_none()); + + // Share key collect includes username/email placeholders. + let keys = collect_cipher_keys_from_templates(collect_all_secret_templates(&got)); + assert!(keys.iter().any(|k| k == "QY")); + assert!(keys.iter().any(|k| k == "U")); + + // resolve_record_secret_field supports identity fields. + assert_eq!( + resolve_record_secret_field(&got, "email", None, None), + Some("[QY]") + ); + let revealed = project_secret_revealed(&root, Some("[QY]")).unwrap(); + assert_eq!(revealed["value"], "qquser@qq.com"); + assert_eq!(revealed["template"], "[QY]"); + + let (_idx, items) = list_credentials(&root, VaultItemStatus::Active).unwrap(); + let book = load_cipher_book(&root).unwrap(); + let list_row = project_list_entry_with_cipher(&items[0], Some(&book)); + assert_eq!(list_row["email"], "qquser@qq.com"); + assert_eq!(list_row["username"], "prealice_upost"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn same_url_multiple_accounts_allowed() { + let root = temp_root(); + let url = "https://github.com"; + let a = create_credential( + &root, + VaultCreateInput { + title: "GH work".into(), + url: Some(url.into()), + username: Some("work@corp".into()), + password: Some("p1".into()), + folder_path: Some("work/github".into()), + ..Default::default() + }, + ) + .unwrap(); + let b = create_credential( + &root, + VaultCreateInput { + title: "GH personal".into(), + url: Some(url.into()), + username: Some("me@home".into()), + password: Some("p2".into()), + folder_path: Some("personal/github".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(a.id, b.id); + let (_i, items) = list_credentials(&root, VaultItemStatus::Active).unwrap(); + assert_eq!(items.len(), 2); + assert!(items.iter().all(|e| e.url.as_deref() == Some(url))); + assert_eq!(a.folder_path.as_deref(), Some("work/github")); + assert_eq!(b.folder_path.as_deref(), Some("personal/github")); + let got = get_credential(&root, &a.id).unwrap(); + let item = project_item_l0(&got); + assert_eq!(item["folderPath"], "work/github"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn share_credential_copies_secrets_and_tags() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "OpenAI".into(), + url: Some("https://platform.openai.com".into()), + username: Some("bot".into()), + password: Some("Li@[A]s3cret".into()), + apikey: Some("sk-test".into()), + folder_path: Some("ai/keys".into()), + tags: vec!["prod".into()], + ..Default::default() + }, + ) + .unwrap(); + let outcome = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + let shared = outcome.item; + assert_ne!(shared.id, created.id); + assert_eq!(shared.password.as_deref(), Some("Li@[A]s3cret")); + assert_eq!(shared.apikey.as_deref(), Some("sk-test")); + assert_eq!(shared.folder_path.as_deref(), Some("from-user")); + assert!(shared.tags.iter().any(|t| t == "ai-shared")); + assert!(shared.tags.iter().any(|t| t == "prod")); + assert!(shared.shared_from.is_some()); + assert_eq!( + shared.shared_from.as_ref().unwrap().source_id, + created.id + ); + // source now linked + let still = get_credential(&src, &created.id).unwrap(); + assert!(still.shared_to_ai.is_some()); + assert_eq!( + still.shared_to_ai.as_ref().unwrap().target_id, + shared.id + ); + // [A] present in template but missing on source book → reported + assert!(outcome + .cipher_keys_missing_in_source + .iter() + .any(|k| k == "A")); + let (_i, items) = list_credentials(&dst, VaultItemStatus::Active).unwrap(); + assert_eq!(items.len(), 1); + // re-share updates existing, no second copy + let outcome2 = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(outcome2.updated_existing); + assert_eq!(outcome2.item.id, shared.id); + let (_i, items2) = list_credentials(&dst, VaultItemStatus::Active).unwrap(); + assert_eq!(items2.len(), 1); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } + + #[test] + fn share_credential_syncs_missing_cipher_keys_without_overwrite() { + let src = temp_root(); + let dst = temp_root(); + set_cipher_fragment(&src, "A", "from-source").unwrap(); + set_cipher_fragment(&src, "B", "source-b").unwrap(); + // target already has B with different value — must not overwrite + set_cipher_fragment(&dst, "B", "target-b").unwrap(); + let created = create_credential( + &src, + VaultCreateInput { + title: "With cipher".into(), + password: Some("Li@[A]x[B]".into()), + ..Default::default() + }, + ) + .unwrap(); + let outcome = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(outcome.cipher_keys_copied.iter().any(|k| k == "A")); + assert!(outcome.cipher_keys_skipped_existing.iter().any(|k| k == "B")); + assert!(outcome.cipher_keys_missing_in_source.is_empty()); + let dst_book = load_cipher_book(&dst).unwrap(); + assert_eq!(dst_book.fragments.get("A").map(String::as_str), Some("from-source")); + assert_eq!(dst_book.fragments.get("B").map(String::as_str), Some("target-b")); + // resolve on target should expand A, keep B as target value + let resolved = + resolve_secret_with_cipher_book(&dst, Some("Li@[A]x[B]")).unwrap().unwrap(); + assert_eq!(resolved.value, "Li@from-sourcextarget-b"); + // syncCipherKeys=false on a fresh source without link leaves target book empty of new keys + // (use new source without prior shared_to_ai — re-share would update existing) + let src2 = temp_root(); + let dst2 = temp_root(); + let created2 = create_credential( + &src2, + VaultCreateInput { + title: "No sync".into(), + password: Some("Li@[A]x".into()), + ..Default::default() + }, + ) + .unwrap(); + set_cipher_fragment(&src2, "A", "zzz").unwrap(); + let outcome2 = share_credential_to_workspace( + &src2, + &dst2, + &created2.id, + None, + &[], + false, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(outcome2.cipher_keys_copied.is_empty()); + assert!(load_cipher_book(&dst2).unwrap().fragments.is_empty()); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + let _ = fs::remove_dir_all(&src2); + let _ = fs::remove_dir_all(&dst2); + } + + #[test] + fn update_source_auto_syncs_ai_copy_fields() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "Before".into(), + password: Some("old-pass".into()), + username: Some("u1".into()), + ..Default::default() + }, + ) + .unwrap(); + let shared = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + let updated = update_credential( + &src, + &created.id, + VaultUpdateInput { + title: Some("After".into()), + password: SecretPatch::Set("new-pass".into()), + ..Default::default() + }, + ) + .unwrap(); + // link still present after update + assert!(updated.shared_to_ai.is_some()); + let sync = maybe_sync_after_source_update( + &src, + &updated, + &dst, + "user-a", + true, + ) + .unwrap() + .expect("should sync"); + assert_eq!(sync.item.title, "After"); + assert_eq!(sync.item.password.as_deref(), Some("new-pass")); + assert_eq!(sync.item.id, shared.item.id); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } + + #[test] + fn unshare_credential_clears_link_and_soft_deletes_ai_copy() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "Shared then revoke".into(), + password: Some("secret".into()), + ..Default::default() + }, + ) + .unwrap(); + let shared = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(get_credential(&src, &created.id) + .unwrap() + .shared_to_ai + .is_some()); + assert_eq!( + get_credential(&dst, &shared.item.id).unwrap().status, + VaultItemStatus::Active + ); + + let outcome = unshare_credential_from_ai(&src, &dst, &created.id).unwrap(); + assert!(outcome.was_shared); + assert!(outcome.target_deleted); + assert_eq!( + outcome.removed_target_id.as_deref(), + Some(shared.item.id.as_str()) + ); + assert!(outcome.source.shared_to_ai.is_none()); + let source_after = get_credential(&src, &created.id).unwrap(); + assert!(source_after.shared_to_ai.is_none()); + let target_after = get_credential(&dst, &shared.item.id).unwrap(); + assert_eq!(target_after.status, VaultItemStatus::Deleted); + + // Idempotent: second unshare is a no-op on link. + let again = unshare_credential_from_ai(&src, &dst, &created.id).unwrap(); + assert!(!again.was_shared); + assert!(!again.target_deleted); + assert!(again.source.shared_to_ai.is_none()); + + // After unshare, re-share creates a fresh AI copy (old one is deleted). + let reshare = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(!reshare.updated_existing); + assert_ne!(reshare.item.id, shared.item.id); + assert!(get_credential(&src, &created.id) + .unwrap() + .shared_to_ai + .is_some()); + + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } + + #[test] + fn normalize_folder_path_strips_dots() { + assert_eq!( + normalize_folder_path(Some(" 个人//银行/../招商 ")).as_deref(), + Some("个人/银行/招商") + ); + assert_eq!(normalize_folder_path(Some("///")), None); + assert_eq!(normalize_folder_path(Some("")), None); + } + + #[test] + fn multi_accounts_and_secrets_on_one_entry() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Google".into(), + url: Some("https://accounts.google.com".into()), + accounts: vec![ + VaultAccountSlot { + id: "acc_a".into(), + label: Some("主号".into()), + username: Some("a@g.com".into()), + email: Some("a@g.com".into()), + password: Some("Li@[A]1".into()), + password_hint: Some("Li@[A]***".into()), + secrets: vec![ + VaultSecretSlot { + id: "sec_1".into(), + kind: "apikey".into(), + label: Some("Gemini".into()), + value: Some("AIza-one".into()), + account_id: None, + }, + VaultSecretSlot { + id: "sec_2".into(), + kind: "token".into(), + label: Some("App".into()), + value: Some("tok-two".into()), + account_id: None, + }, + ], + }, + VaultAccountSlot { + id: "acc_b".into(), + label: Some("副号".into()), + username: Some("b@g.com".into()), + email: None, + password: Some("p2".into()), + password_hint: None, + secrets: Vec::new(), + }, + ], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(created.accounts.len(), 2); + assert_eq!(created.accounts[0].secrets.len(), 2); + // Top-level secrets are flatten mirror of nested account secrets. + assert_eq!(created.secrets.len(), 2); + assert_eq!(created.username.as_deref(), Some("a@g.com")); + assert_eq!(created.password.as_deref(), Some("Li@[A]1")); + assert_eq!(created.apikey.as_deref(), Some("AIza-one")); + assert_eq!(created.token.as_deref(), Some("tok-two")); + + let item = project_item_l0(&created); + assert_eq!(item["accountCount"], 2); + assert_eq!(item["secretCount"], 2); + assert_eq!(item["accounts"][0]["password"]["state"], "masked"); + assert_eq!(item["secrets"][0]["value"]["state"], "masked"); + assert!(!item.to_string().contains("AIza-one")); + assert!(!item.to_string().contains("Li@[A]1")); + + assert_eq!( + resolve_record_secret_field(&created, "password", Some("acc_b"), None), + Some("p2") + ); + assert_eq!( + resolve_record_secret_field(&created, "apikey", None, Some("sec_1")), + Some("AIza-one") + ); + + let got = get_credential(&root, &created.id).unwrap(); + assert_eq!(got.accounts.len(), 2); + assert_eq!(got.accounts[0].secrets.len(), 2); + assert_eq!(got.secrets.len(), 2); + assert_eq!(got.accounts[1].username.as_deref(), Some("b@g.com")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn multi_urls_normalize_primary_and_persist() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "mnote-multi-url".into(), + url: Some("https://vps.example/".into()), + urls: vec![ + "https://frp.example/".into(), + "http://192.168.1.10:8080/".into(), + "https://vps.example/".into(), // dup of primary — once, primary first + ], + password: Some("x".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(created.url.as_deref(), Some("https://vps.example/")); + assert_eq!( + created.urls, + vec![ + "https://vps.example/".to_string(), + "https://frp.example/".to_string(), + "http://192.168.1.10:8080/".to_string(), + ] + ); + let l0 = project_item_l0(&created); + assert_eq!(l0["url"], "https://vps.example/"); + assert_eq!(l0["urls"].as_array().unwrap().len(), 3); + + let updated = update_credential( + &root, + &created.id, + VaultUpdateInput { + urls: Some(vec![ + "https://new-primary.example/".into(), + "https://frp.example/".into(), + ]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.url.as_deref(), Some("https://new-primary.example/")); + assert_eq!(updated.urls.len(), 2); + + let reloaded = get_credential(&root, &created.id).unwrap(); + assert_eq!(reloaded.urls.len(), 2); + assert_eq!(reloaded.url.as_deref(), Some("https://new-primary.example/")); + // frontmatter should round-trip urls list (path is under .mnote/vault/) + let md = fs::read_to_string(vault_root(&root).join(&reloaded.relative_path)).unwrap(); + assert!(md.contains("urls:")); + assert!(md.contains("https://frp.example/")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn legacy_single_fields_synthesize_slots() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Legacy".into(), + username: Some("u".into()), + password: Some("secret".into()), + apikey: Some("k".into()), + token: Some("t".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(created.accounts.len(), 1); + assert_eq!(created.secrets.len(), 2); + assert_eq!(created.accounts[0].password.as_deref(), Some("secret")); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/rust/crates/mnote-vault-core/src/token.rs b/rust/crates/mnote-vault-core/src/token.rs new file mode 100644 index 00000000..bf74afcd --- /dev/null +++ b/rust/crates/mnote-vault-core/src/token.rs @@ -0,0 +1,379 @@ +//! Agent capability tokens for local vault access (12-2). +//! +//! Format: `mnv1..` +//! Token is **not** a master key — only authorizes list/get/resolve against vaultd/CLI. + +use crate::error::{VaultError, VaultStatus}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +type HmacSha256 = Hmac; + +pub const TOKEN_VERSION: u32 = 1; +pub const DEFAULT_TOKEN_DIR: &str = ".config/mnote/vault-tokens"; +pub const DEFAULT_HMAC_KEY_REL: &str = ".config/mnote/vaultd-hmac.key"; +pub const DEFAULT_ACTOR: &str = "mnote-e2e"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VaultTokenClaims { + pub v: u32, + pub iss: String, + pub aud: String, + pub sub: String, + pub actor: String, + pub scope: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + pub iat: u64, + /// 0 = no expiry + pub exp: u64, + pub jti: String, +} + +#[derive(Debug, Clone)] +pub struct IssuedToken { + pub token: String, + pub claims: VaultTokenClaims, +} + +pub fn default_hmac_key_path() -> PathBuf { + dirs_path_home().join(DEFAULT_HMAC_KEY_REL) +} + +pub fn default_token_path() -> PathBuf { + dirs_path_home() + .join(DEFAULT_TOKEN_DIR) + .join("default.token") +} + +/// Default UDS path for mnote-vaultd (12-2 §5.1). +/// Order: `MNOTE_VAULT_SOCK` → `$XDG_RUNTIME_DIR/mnote-vaultd.sock` → `/tmp/mnote-vaultd-$UID.sock`. +pub fn default_sock_path() -> PathBuf { + if let Ok(p) = std::env::var("MNOTE_VAULT_SOCK") { + let p = p.trim(); + if !p.is_empty() { + return PathBuf::from(p); + } + } + if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") { + let runtime = runtime.trim(); + if !runtime.is_empty() { + return PathBuf::from(runtime).join("mnote-vaultd.sock"); + } + } + let uid = std::env::var("UID") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(current_uid); + PathBuf::from(format!("/tmp/mnote-vaultd-{uid}.sock")) +} + +fn current_uid() -> u32 { + #[cfg(unix)] + { + extern "C" { + fn getuid() -> u32; + } + // SAFETY: getuid has no preconditions. + unsafe { getuid() } + } + #[cfg(not(unix))] + { + 0 + } +} + +fn dirs_path_home() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Load or create a random 32-byte HMAC secret (hex file, 0600 when possible). +pub fn load_or_create_hmac_key(path: &Path) -> Result, VaultError> { + if path.exists() { + let raw = fs::read_to_string(path).map_err(|e| { + VaultError::bad_request_code( + "vault_hmac_key_read_failed", + format!("无法读取 HMAC key {}: {e}", path.display()), + ) + })?; + let hex = raw.trim(); + if hex.len() < 32 { + return Err(VaultError::bad_request_code( + "vault_hmac_key_invalid", + "HMAC key 过短", + )); + } + return hex::decode(hex).map_err(|e| { + VaultError::bad_request_code( + "vault_hmac_key_invalid", + format!("HMAC key 非 hex: {e}"), + ) + }); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + VaultError::bad_request_code( + "vault_hmac_key_write_failed", + format!("无法创建目录: {e}"), + ) + })?; + } + let mut bytes = [0u8; 32]; + getrandom_fill(&mut bytes)?; + let hex = hex::encode(bytes); + fs::write(path, format!("{hex}\n")).map_err(|e| { + VaultError::bad_request_code( + "vault_hmac_key_write_failed", + format!("无法写入 HMAC key: {e}"), + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(bytes.to_vec()) +} + +fn getrandom_fill(buf: &mut [u8]) -> Result<(), VaultError> { + use std::io::Read; + // Prefer /dev/urandom for zero extra deps if getrandom crate not used. + let mut f = fs::File::open("/dev/urandom").map_err(|e| { + VaultError::internal(format!("open /dev/urandom: {e}")) + })?; + f.read_exact(buf) + .map_err(|e| VaultError::internal(format!("read urandom: {e}")))?; + Ok(()) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +pub fn issue_token( + hmac_key: &[u8], + sub: &str, + actor: &str, + scopes: &[&str], + ttl_secs: Option, + workspace: Option, +) -> Result { + let iat = now_unix(); + let exp = match ttl_secs { + Some(0) | None => 0, + Some(ttl) => iat.saturating_add(ttl), + }; + let claims = VaultTokenClaims { + v: TOKEN_VERSION, + iss: "mnote-vaultd".into(), + aud: "local-vaultd".into(), + sub: sub.trim().to_string(), + actor: actor.trim().to_string(), + scope: scopes.iter().map(|s| (*s).to_string()).collect(), + workspace, + iat, + exp, + jti: Uuid::new_v4().to_string(), + }; + let token = encode_token(hmac_key, &claims)?; + Ok(IssuedToken { token, claims }) +} + +pub fn encode_token(hmac_key: &[u8], claims: &VaultTokenClaims) -> Result { + let payload = serde_json::to_vec(claims).map_err(|e| { + VaultError::internal(format!("token serialize: {e}")) + })?; + let payload_b64 = URL_SAFE_NO_PAD.encode(&payload); + let mut mac = HmacSha256::new_from_slice(hmac_key) + .map_err(|e| VaultError::internal(format!("hmac init: {e}")))?; + mac.update(payload_b64.as_bytes()); + let sig = mac.finalize().into_bytes(); + let sig_b64 = URL_SAFE_NO_PAD.encode(sig); + Ok(format!("mnv1.{payload_b64}.{sig_b64}")) +} + +pub fn verify_token(hmac_key: &[u8], token: &str) -> Result { + let token = token.trim(); + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 || parts[0] != "mnv1" { + return Err(VaultError::unauthorized_code( + "vault_token_invalid", + "token 格式无效(期望 mnv1..)", + )); + } + let payload_b64 = parts[1]; + let sig_b64 = parts[2]; + let mut mac = HmacSha256::new_from_slice(hmac_key) + .map_err(|e| VaultError::internal(format!("hmac init: {e}")))?; + mac.update(payload_b64.as_bytes()); + let expected = mac.finalize().into_bytes(); + let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| { + VaultError::unauthorized_code("vault_token_invalid", "token 签名解码失败") + })?; + if sig.len() != expected.len() + || sig + .iter() + .zip(expected.iter()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + != 0 + { + return Err(VaultError::unauthorized_code( + "vault_token_invalid", + "token 签名校验失败", + )); + } + let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| { + VaultError::unauthorized_code("vault_token_invalid", "token payload 解码失败") + })?; + let claims: VaultTokenClaims = serde_json::from_slice(&payload).map_err(|_| { + VaultError::unauthorized_code("vault_token_invalid", "token payload JSON 无效") + })?; + if claims.v != TOKEN_VERSION { + return Err(VaultError::unauthorized_code( + "vault_token_invalid", + format!("不支持的 token 版本 {}", claims.v), + )); + } + if claims.aud != "local-vaultd" { + return Err(VaultError::unauthorized_code( + "vault_token_invalid", + "token aud 不匹配", + )); + } + if claims.exp != 0 && now_unix() > claims.exp { + return Err(VaultError::unauthorized_code( + "vault_token_expired", + "token 已过期", + )); + } + Ok(claims) +} + +pub fn require_scope(claims: &VaultTokenClaims, need: &str) -> Result<(), VaultError> { + let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect(); + if set.contains(need) || set.contains("*") { + return Ok(()); + } + Err(VaultError::forbidden_code( + "vault_scope_denied", + format!("token 缺少 scope: {need}"), + )) +} + +pub fn require_actor(claims: &VaultTokenClaims, expected: &str) -> Result<(), VaultError> { + if claims.actor.trim() == expected.trim() { + return Ok(()); + } + Err(VaultError::forbidden_code( + "vault_actor_mismatch", + format!( + "token.actor={} 与 AI 本 actor={} 不一致", + claims.actor, expected + ), + )) +} + +pub fn read_token_from_env_or_file() -> Result { + if let Ok(t) = std::env::var("MNOTE_VAULT_TOKEN") { + let t = t.trim().to_string(); + if !t.is_empty() { + return Ok(t); + } + } + let path = std::env::var("MNOTE_VAULT_TOKEN_FILE") + .map(PathBuf::from) + .unwrap_or_else(|_| default_token_path()); + if !path.exists() { + return Err(VaultError::new( + VaultStatus::Unauthorized, + "vault_token_missing", + format!( + "未找到 token(设 MNOTE_VAULT_TOKEN 或运行 issue-token)。期望文件: {}", + path.display() + ), + )); + } + let raw = fs::read_to_string(&path).map_err(|e| { + VaultError::unauthorized_code( + "vault_token_missing", + format!("无法读取 {}: {e}", path.display()), + ) + })?; + let t = raw.trim().to_string(); + if t.is_empty() { + return Err(VaultError::unauthorized_code( + "vault_token_missing", + "token 文件为空", + )); + } + Ok(t) +} + +pub fn write_token_file(path: &Path, token: &str) -> Result<(), VaultError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + VaultError::bad_request_code( + "vault_token_write_failed", + format!("无法创建目录: {e}"), + ) + })?; + } + fs::write(path, format!("{}\n", token.trim())).map_err(|e| { + VaultError::bad_request_code( + "vault_token_write_failed", + format!("无法写入 token: {e}"), + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issue_verify_roundtrip() { + let key = b"0123456789abcdef0123456789abcdef"; + let issued = issue_token( + key, + "agent:test", + "mnote-e2e", + &["list", "get", "resolve"], + Some(3600), + None, + ) + .unwrap(); + let claims = verify_token(key, &issued.token).unwrap(); + assert_eq!(claims.actor, "mnote-e2e"); + require_scope(&claims, "resolve").unwrap(); + assert!(require_scope(&claims, "admin").is_err()); + } + + #[test] + fn bad_sig_fails() { + let key = b"0123456789abcdef0123456789abcdef"; + let issued = issue_token(key, "a", "mnote-e2e", &["list"], None, None).unwrap(); + let mut t = issued.token; + t.push('x'); + assert!(verify_token(key, &t).is_err()); + } +} diff --git a/rust/crates/mnote-vault/Cargo.toml b/rust/crates/mnote-vault/Cargo.toml new file mode 100644 index 00000000..02497f49 --- /dev/null +++ b/rust/crates/mnote-vault/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mnote-vault" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Local vault CLI: issue-token / list / get / resolve / login / session / serve (no mnote-web)" + +[[bin]] +name = "mnote-vault" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5.38", features = ["derive"] } +mnote-vault-core = { path = "../mnote-vault-core" } +serde_json = "1" diff --git a/rust/crates/mnote-vault/src/main.rs b/rust/crates/mnote-vault/src/main.rs new file mode 100644 index 00000000..f23ab7f3 --- /dev/null +++ b/rust/crates/mnote-vault/src/main.rs @@ -0,0 +1,1169 @@ +//! mnote-vault — local-first AI password-book CLI + optional UDS daemon (12-2). +//! +//! Does **not** require mnote-web. Uses mnote-vault-core + capability token. +//! +//! ```text +//! mnote-vault issue-token --agent paseo --ttl 90d +//! mnote-vault serve # UDS daemon +//! mnote-vault list # sock first, else embedded core +//! mnote-vault resolve --id --field password +//! ``` + +use clap::{Parser, Subcommand, ValueEnum}; +use mnote_vault_core::store::VaultItemStatus; +use mnote_vault_core::{ + ai_vault_actor_id, ai_vault_workspace_root, default_hmac_key_path, default_sock_path, + default_token_path, ensure_ai_vault_workspace, get_ai_vault_item, issue_token, + list_ai_vault_items, load_or_create_hmac_key, login_ai_vault_credential, + put_ai_vault_session, read_token_from_env_or_file, require_actor, require_scope, + resolve_ai_vault_secret, verify_token, write_token_file, VaultError, VaultStatus, + VaultTokenClaims, +}; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; + +#[derive(Parser, Debug)] +#[command(name = "mnote-vault")] +#[command( + about = "Local AI vault CLI / vaultd (token + file SSOT; no mnote-web)", + long_about = None +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// 检查 workspace / token / hmac / sock 状态(不打印 secret) + Doctor, + /// 签发 agent capability token 并写入默认路径(0600) + IssueToken(IssueTokenArgs), + /// 启动 UDS 常驻服务(HTTP/1.1 on Unix socket) + Serve(ServeArgs), + /// 列出 AI 密码本条目(L0,无明文密码) + List(ListArgs), + /// 取条目元数据(掩码) + Get(GetArgs), + /// 解析密钥字段(唯一取密通道) + Resolve(ResolveArgs), + /// 外站登录:复用 session 或 api_first;CF 返回 human_required + Login(LoginArgs), + /// 回写浏览器捕获的 cookie(人机验证后) + Session(SessionArgs), + /// 打印 token claims(无 secret value) + Whoami, +} + +#[derive(Parser, Debug)] +struct IssueTokenArgs { + /// Agent 主体,写入 token.sub(如 agent:paseo) + #[arg(long, default_value = "agent:local")] + agent: String, + /// AI 密码本 actor;默认 MNOTE_AI_VAULT_ACTOR 或 mnote-e2e + #[arg(long)] + actor: Option, + /// scope 逗号分隔;默认 list,get,resolve,login,session + #[arg(long, default_value = "list,get,resolve,login,session")] + scope: String, + /// TTL:如 90d / 24h / 3600 / never(0 = 不过期) + #[arg(long, default_value = "90d")] + ttl: String, + /// 输出路径;默认 ~/.config/mnote/vault-tokens/default.token + #[arg(long)] + out: Option, + /// HMAC key 路径;默认 ~/.config/mnote/vaultd-hmac.key + #[arg(long)] + hmac_key: Option, + /// 只打印 token 到 stdout,不写文件 + #[arg(long)] + stdout: bool, +} + +#[derive(Parser, Debug)] +struct ServeArgs { + /// UDS 路径;默认 $XDG_RUNTIME_DIR/mnote-vaultd.sock 或 /tmp/mnote-vaultd-$UID.sock + #[arg(long)] + sock: Option, + /// 已存在 sock 文件时先 unlink + #[arg(long, default_value_t = true)] + replace: bool, +} + +#[derive(Parser, Debug)] +struct ListArgs { + #[arg(long, value_enum, default_value = "active")] + status: StatusArg, + /// 跳过 token 校验(仅本机调试;默认必须 token) + #[arg(long, hide = true)] + no_token: bool, + /// 强制内嵌 core,不连 vaultd sock + #[arg(long)] + local: bool, + /// 强制只走 sock;连不上则失败 + #[arg(long)] + remote: bool, +} + +#[derive(Parser, Debug)] +struct GetArgs { + #[arg(long)] + id: String, + #[arg(long, hide = true)] + no_token: bool, + #[arg(long)] + local: bool, + #[arg(long)] + remote: bool, +} + +#[derive(Parser, Debug)] +struct ResolveArgs { + #[arg(long)] + id: String, + #[arg(long, default_value = "password")] + field: String, + #[arg(long)] + account_id: Option, + #[arg(long)] + secret_id: Option, + /// 仅输出明文 value(便于管道);默认 JSON + #[arg(long)] + raw: bool, + #[arg(long, hide = true)] + no_token: bool, + #[arg(long)] + local: bool, + #[arg(long)] + remote: bool, +} + +#[derive(Parser, Debug)] +struct LoginArgs { + #[arg(long)] + id: String, + /// 强制重新登录(忽略未过期 session) + #[arg(long, default_value_t = false)] + force_refresh: bool, + #[arg(long, hide = true)] + no_token: bool, + #[arg(long)] + local: bool, + #[arg(long)] + remote: bool, +} + +#[derive(Parser, Debug)] +struct SessionArgs { + #[arg(long)] + id: String, + /// Cookie 请求头全文(name=value; …) + #[arg(long)] + cookie_header: String, + #[arg(long)] + expires_at: Option, + /// 来源标记:human_bridge | browser | api + #[arg(long, default_value = "human_bridge")] + source: String, + #[arg(long, hide = true)] + no_token: bool, + #[arg(long)] + local: bool, + #[arg(long)] + remote: bool, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum StatusArg { + Active, + Deleted, +} + +impl StatusArg { + fn to_store(self) -> VaultItemStatus { + match self { + Self::Active => VaultItemStatus::Active, + Self::Deleted => VaultItemStatus::Deleted, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Deleted => "deleted", + } + } +} + +fn parse_ttl(s: &str) -> Result, String> { + let s = s.trim().to_ascii_lowercase(); + if s.is_empty() || s == "never" || s == "0" || s == "none" { + return Ok(None); + } + if let Some(days) = s.strip_suffix('d') { + let n: u64 = days + .parse() + .map_err(|_| format!("invalid ttl days: {s}"))?; + return Ok(Some(n.saturating_mul(86_400))); + } + if let Some(hours) = s.strip_suffix('h') { + let n: u64 = hours + .parse() + .map_err(|_| format!("invalid ttl hours: {s}"))?; + return Ok(Some(n.saturating_mul(3_600))); + } + if let Some(mins) = s.strip_suffix('m') { + let n: u64 = mins + .parse() + .map_err(|_| format!("invalid ttl minutes: {s}"))?; + return Ok(Some(n.saturating_mul(60))); + } + let n: u64 = s + .parse() + .map_err(|_| format!("invalid ttl seconds: {s}"))?; + Ok(if n == 0 { None } else { Some(n) }) +} + +fn print_err(err: &VaultError) -> ExitCode { + let body = json!({ + "ok": false, + "code": err.code, + "message": err.message, + "status": err.status.as_u16(), + "details": err.details, + "transport": "local-core", + }); + eprintln!("{body}"); + match err.status { + VaultStatus::Unauthorized => ExitCode::from(2), + VaultStatus::Forbidden => ExitCode::from(3), + VaultStatus::NotFound => ExitCode::from(4), + _ => ExitCode::from(1), + } +} + +fn print_json(v: &Value) { + println!( + "{}", + serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()) + ); +} + +/// Verify env/file token against local HMAC key; enforce scope + AI actor. +fn authorize(need_scope: &str, skip: bool) -> Result, VaultError> { + if skip { + return Ok(None); + } + let token = read_token_from_env_or_file()?; + let key_path = default_hmac_key_path(); + if !key_path.exists() { + return Err(VaultError::unauthorized_code( + "vault_token_invalid", + format!( + "本机 HMAC key 不存在: {}(请先 mnote-vault issue-token)", + key_path.display() + ), + )); + } + let key = load_or_create_hmac_key(&key_path)?; + let claims = verify_token(&key, &token)?; + require_scope(&claims, need_scope)?; + require_actor(&claims, &ai_vault_actor_id())?; + Ok(Some(claims)) +} + +fn cmd_doctor() -> Result { + let actor = ai_vault_actor_id(); + let root = ai_vault_workspace_root(); + let root_exists = root.exists(); + let vault_dir = root.join(".mnote/vault"); + let index_exists = vault_dir.join("vault-index.json").exists(); + let hmac_path = default_hmac_key_path(); + let token_path = std::env::var("MNOTE_VAULT_TOKEN_FILE") + .map(PathBuf::from) + .unwrap_or_else(|_| default_token_path()); + let sock_path = default_sock_path(); + let sock_alive = sock_reachable(&sock_path); + let has_env_token = std::env::var("MNOTE_VAULT_TOKEN") + .map(|t| !t.trim().is_empty()) + .unwrap_or(false); + let mut token_ok = false; + let mut token_err: Option = None; + let mut claims_json: Option = None; + if has_env_token || token_path.exists() { + match read_token_from_env_or_file().and_then(|t| { + let key = load_or_create_hmac_key(&hmac_path)?; + let c = verify_token(&key, &t)?; + require_actor(&c, &actor)?; + Ok(c) + }) { + Ok(c) => { + token_ok = true; + claims_json = Some(json!({ + "sub": c.sub, + "actor": c.actor, + "scope": c.scope, + "exp": c.exp, + "jti": c.jti, + })); + } + Err(e) => token_err = Some(e.message), + } + } + Ok(json!({ + "ok": root_exists && index_exists && (token_ok || !hmac_path.exists()), + "transport": if sock_alive { "uds" } else { "local-core" }, + "aiVaultActorId": actor, + "workspaceRoot": root.display().to_string(), + "workspaceExists": root_exists, + "vaultIndexExists": index_exists, + "hmacKeyPath": hmac_path.display().to_string(), + "hmacKeyExists": hmac_path.exists(), + "tokenPath": token_path.display().to_string(), + "tokenFileExists": token_path.exists(), + "tokenFromEnv": has_env_token, + "tokenValid": token_ok, + "tokenError": token_err, + "tokenClaims": claims_json, + "sockPath": sock_path.display().to_string(), + "sockExists": sock_path.exists(), + "sockReachable": sock_alive, + "note": "读密不依赖 mnote-web;优先 mnote-vault serve + token;sock 不可达时 CLI 内嵌 core", + })) +} + +fn cmd_issue_token(args: IssueTokenArgs) -> Result { + let actor = args.actor.unwrap_or_else(ai_vault_actor_id); + let scopes: Vec<&str> = args + .scope + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if scopes.is_empty() { + return Err(VaultError::bad_request_code( + "vault_scope_empty", + "scope 不能为空", + )); + } + let ttl = parse_ttl(&args.ttl) + .map_err(|e| VaultError::bad_request_code("vault_ttl_invalid", e))?; + let hmac_path = args.hmac_key.unwrap_or_else(default_hmac_key_path); + let key = load_or_create_hmac_key(&hmac_path)?; + let sub = if args.agent.starts_with("agent:") { + args.agent.clone() + } else { + format!("agent:{}", args.agent) + }; + let issued = issue_token(&key, &sub, &actor, &scopes, ttl, None)?; + let out_path = args.out.unwrap_or_else(default_token_path); + if args.stdout { + println!("{}", issued.token); + return Ok(json!({ + "ok": true, + "written": false, + "sub": issued.claims.sub, + "actor": issued.claims.actor, + "scope": issued.claims.scope, + "exp": issued.claims.exp, + "jti": issued.claims.jti, + })); + } + write_token_file(&out_path, &issued.token)?; + Ok(json!({ + "ok": true, + "written": true, + "path": out_path.display().to_string(), + "hmacKeyPath": hmac_path.display().to_string(), + "sub": issued.claims.sub, + "actor": issued.claims.actor, + "scope": issued.claims.scope, + "exp": issued.claims.exp, + "jti": issued.claims.jti, + "note": "将 MNOTE_VAULT_TOKEN_FILE 指到该路径,或 export MNOTE_VAULT_TOKEN;勿把 token 提交 git", + })) +} + +fn cmd_whoami() -> Result { + let token = read_token_from_env_or_file()?; + let key = load_or_create_hmac_key(&default_hmac_key_path())?; + let c = verify_token(&key, &token)?; + Ok(json!({ + "ok": true, + "sub": c.sub, + "actor": c.actor, + "scope": c.scope, + "iss": c.iss, + "aud": c.aud, + "iat": c.iat, + "exp": c.exp, + "jti": c.jti, + "workspace": c.workspace, + "aiVaultActorId": ai_vault_actor_id(), + "actorMatch": c.actor.trim() == ai_vault_actor_id(), + "sockPath": default_sock_path().display().to_string(), + "sockReachable": sock_reachable(&default_sock_path()), + })) +} + +// ─── UDS client / server ─────────────────────────────────────────────────── + +fn sock_reachable(path: &Path) -> bool { + #[cfg(unix)] + { + if !path.exists() { + return false; + } + UnixStream::connect(path).is_ok() + } + #[cfg(not(unix))] + { + let _ = path; + false + } +} + +/// HTTP/1.1 request over UDS; returns status + body. +#[cfg(unix)] +fn uds_http( + sock: &Path, + method: &str, + path_and_query: &str, + body: Option<&str>, + bearer: Option<&str>, +) -> Result<(u16, String), VaultError> { + let mut stream = UnixStream::connect(sock).map_err(|e| { + VaultError::new( + VaultStatus::Unavailable, + "vaultd_unavailable", + format!("无法连接 vaultd sock {}: {e}", sock.display()), + ) + })?; + let body_bytes = body.unwrap_or("").as_bytes(); + let mut req = format!( + "{method} {path_and_query} HTTP/1.1\r\nHost: mnote-vaultd\r\nConnection: close\r\n" + ); + if let Some(token) = bearer { + req.push_str(&format!("Authorization: Bearer {token}\r\n")); + } + if body.is_some() { + req.push_str("Content-Type: application/json\r\n"); + req.push_str(&format!("Content-Length: {}\r\n", body_bytes.len())); + } else { + req.push_str("Content-Length: 0\r\n"); + } + req.push_str("\r\n"); + stream + .write_all(req.as_bytes()) + .and_then(|_| { + if !body_bytes.is_empty() { + stream.write_all(body_bytes) + } else { + Ok(()) + } + }) + .map_err(|e| { + VaultError::new( + VaultStatus::Unavailable, + "vaultd_unavailable", + format!("写 sock 失败: {e}"), + ) + })?; + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).map_err(|e| { + VaultError::new( + VaultStatus::Unavailable, + "vaultd_unavailable", + format!("读 sock 失败: {e}"), + ) + })?; + let text = String::from_utf8_lossy(&raw); + parse_http_response(&text) +} + +#[cfg(not(unix))] +fn uds_http( + _sock: &Path, + _method: &str, + _path_and_query: &str, + _body: Option<&str>, + _bearer: Option<&str>, +) -> Result<(u16, String), VaultError> { + Err(VaultError::new( + VaultStatus::Unavailable, + "vaultd_unavailable", + "UDS 仅支持 Unix", + )) +} + +fn parse_http_response(text: &str) -> Result<(u16, String), VaultError> { + let (head, body) = text + .split_once("\r\n\r\n") + .or_else(|| text.split_once("\n\n")) + .unwrap_or((text, "")); + let status_line = head.lines().next().unwrap_or(""); + let status: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(500); + Ok((status, body.to_string())) +} + +fn map_http_error(status: u16, body: &str) -> VaultError { + if let Ok(v) = serde_json::from_str::(body) { + let code = v + .get("code") + .and_then(Value::as_str) + .unwrap_or("vaultd_error"); + let message = v + .get("message") + .and_then(Value::as_str) + .unwrap_or(body) + .to_string(); + // Leak to 'static via common codes — fall back to internal for unknown. + let code_static = match code { + "vault_token_missing" => "vault_token_missing", + "vault_token_invalid" => "vault_token_invalid", + "vault_token_expired" => "vault_token_expired", + "vault_scope_denied" => "vault_scope_denied", + "vault_actor_mismatch" => "vault_actor_mismatch", + "vault_item_not_found" => "vault_item_not_found", + "vault_resolve_field_invalid" => "vault_resolve_field_invalid", + "vault_resolve_inactive" => "vault_resolve_inactive", + "vaultd_unavailable" => "vaultd_unavailable", + "bad_request" => "bad_request", + other if other.starts_with("vault_") => { + // Keep original code in message; use generic. + return VaultError::new( + status_to_vault(status), + "vault_error", + format!("{other}: {message}"), + ); + } + _ => "vaultd_error", + }; + return VaultError::new(status_to_vault(status), code_static, message); + } + VaultError::new( + status_to_vault(status), + "vaultd_error", + format!("HTTP {status}: {body}"), + ) +} + +fn status_to_vault(status: u16) -> VaultStatus { + match status { + 400 => VaultStatus::BadRequest, + 401 => VaultStatus::Unauthorized, + 403 => VaultStatus::Forbidden, + 404 => VaultStatus::NotFound, + 409 => VaultStatus::Conflict, + 423 => VaultStatus::Locked, + 503 => VaultStatus::Unavailable, + _ => VaultStatus::Internal, + } +} + +fn client_token_header(skip: bool) -> Result, VaultError> { + if skip { + return Ok(None); + } + Ok(Some(read_token_from_env_or_file()?)) +} + +/// Prefer UDS vaultd; on failure fall back to embedded core (unless remote-only). +fn with_transport( + local_only: bool, + remote_only: bool, + need_scope: &str, + no_token: bool, + via_uds: F, + via_local: G, +) -> Result +where + F: FnOnce(Option<&str>) -> Result, + G: FnOnce() -> Result, +{ + if local_only { + let _ = authorize(need_scope, no_token)?; + return via_local(); + } + let sock = default_sock_path(); + if sock_reachable(&sock) { + let token = client_token_header(no_token)?; + match via_uds(token.as_deref()) { + Ok(v) => return Ok(v), + Err(e) if remote_only => return Err(e), + Err(e) => { + // Soft fallback unless remote-only + eprintln!( + "{}", + json!({ + "ok": false, + "warn": "uds_fallback_local", + "code": e.code, + "message": e.message, + }) + ); + } + } + } else if remote_only { + return Err(VaultError::new( + VaultStatus::Unavailable, + "vaultd_unavailable", + format!("vaultd sock 不可达: {}", sock.display()), + )); + } + let _ = authorize(need_scope, no_token)?; + via_local() +} + +fn cmd_list(args: ListArgs) -> Result { + let status_q = args.status.as_str(); + let status_store = args.status.to_store(); + with_transport( + args.local, + args.remote, + "list", + args.no_token, + |token| { + let path = format!("/v1/items?status={status_q}"); + let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?; + if st >= 400 { + return Err(map_http_error(st, &body)); + } + serde_json::from_str(&body).map_err(|e| { + VaultError::internal(format!("vaultd list JSON 无效: {e}")) + }) + }, + || { + let _ = ensure_ai_vault_workspace()?; + list_ai_vault_items(status_store) + }, + ) +} + +fn cmd_get(args: GetArgs) -> Result { + let id = args.id.clone(); + with_transport( + args.local, + args.remote, + "get", + args.no_token, + |token| { + let path = format!("/v1/items/{id}"); + let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?; + if st >= 400 { + return Err(map_http_error(st, &body)); + } + serde_json::from_str(&body).map_err(|e| { + VaultError::internal(format!("vaultd get JSON 无效: {e}")) + }) + }, + || get_ai_vault_item(&id), + ) +} + +fn cmd_resolve(args: ResolveArgs) -> Result { + let id = args.id.clone(); + let field = args.field.clone(); + let account_id = args.account_id.clone(); + let secret_id = args.secret_id.clone(); + with_transport( + args.local, + args.remote, + "resolve", + args.no_token, + |token| { + let path = format!("/v1/items/{id}/resolve"); + let body = json!({ + "field": field, + "accountId": account_id, + "secretId": secret_id, + }); + let body_s = body.to_string(); + let (st, resp) = + uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?; + if st >= 400 { + return Err(map_http_error(st, &resp)); + } + serde_json::from_str(&resp).map_err(|e| { + VaultError::internal(format!("vaultd resolve JSON 无效: {e}")) + }) + }, + || { + let actor = format!("cli:{}", ai_vault_actor_id()); + resolve_ai_vault_secret( + &id, + &field, + &actor, + None, + account_id.as_deref(), + secret_id.as_deref(), + ) + }, + ) +} + +fn cmd_login(args: LoginArgs) -> Result { + let id = args.id.clone(); + let force = args.force_refresh; + with_transport( + args.local, + args.remote, + "login", + args.no_token, + |token| { + let path = format!("/v1/items/{id}/login"); + let body = json!({ "forceRefresh": force }); + let body_s = body.to_string(); + let (st, resp) = + uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?; + if st >= 400 { + return Err(map_http_error(st, &resp)); + } + serde_json::from_str(&resp).map_err(|e| { + VaultError::internal(format!("vaultd login JSON 无效: {e}")) + }) + }, + || { + let actor = format!("cli:{}", ai_vault_actor_id()); + login_ai_vault_credential(&id, force, &actor, None) + }, + ) +} + +fn cmd_session(args: SessionArgs) -> Result { + let id = args.id.clone(); + let cookie = args.cookie_header.clone(); + let expires = args.expires_at.clone(); + let source = args.source.clone(); + with_transport( + args.local, + args.remote, + "session", + args.no_token, + |token| { + let path = format!("/v1/items/{id}/session"); + let body = json!({ + "cookieHeader": cookie, + "expiresAt": expires, + "source": source, + }); + let body_s = body.to_string(); + let (st, resp) = + uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?; + if st >= 400 { + return Err(map_http_error(st, &resp)); + } + serde_json::from_str(&resp).map_err(|e| { + VaultError::internal(format!("vaultd session JSON 无效: {e}")) + }) + }, + || { + let actor = format!("cli:{}", ai_vault_actor_id()); + put_ai_vault_session( + &id, + &cookie, + expires.as_deref(), + &source, + &actor, + None, + ) + }, + ) +} + +// ─── Server ──────────────────────────────────────────────────────────────── + +#[cfg(unix)] +fn cmd_serve(args: ServeArgs) -> Result<(), VaultError> { + let sock = args.sock.unwrap_or_else(default_sock_path); + if sock.exists() { + if args.replace { + let _ = std::fs::remove_file(&sock); + } else { + return Err(VaultError::bad_request_code( + "vaultd_sock_exists", + format!("sock 已存在: {}(用 --replace 或换路径)", sock.display()), + )); + } + } + if let Some(parent) = sock.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + VaultError::bad_request_code( + "vaultd_sock_parent", + format!("无法创建 sock 目录 {}: {e}", parent.display()), + ) + })?; + } + } + let _ = ensure_ai_vault_workspace()?; + let listener = UnixListener::bind(&sock).map_err(|e| { + VaultError::internal(format!("bind {} 失败: {e}", sock.display())) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)); + } + eprintln!( + "{}", + json!({ + "ok": true, + "event": "vaultd_listen", + "sock": sock.display().to_string(), + "aiVaultActorId": ai_vault_actor_id(), + "workspaceRoot": ai_vault_workspace_root().display().to_string(), + }) + ); + for conn in listener.incoming() { + match conn { + Ok(stream) => { + if let Err(e) = handle_client(stream) { + eprintln!( + "{}", + json!({ + "ok": false, + "event": "vaultd_conn_error", + "code": e.code, + "message": e.message, + }) + ); + } + } + Err(e) => { + eprintln!( + "{}", + json!({ + "ok": false, + "event": "vaultd_accept_error", + "message": e.to_string(), + }) + ); + } + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn cmd_serve(_args: ServeArgs) -> Result<(), VaultError> { + Err(VaultError::bad_request_code( + "vaultd_unix_only", + "mnote-vault serve 仅支持 Unix", + )) +} + +#[cfg(unix)] +fn handle_client(mut stream: UnixStream) -> Result<(), VaultError> { + let mut buf = vec![0u8; 65536]; + let n = stream + .read(&mut buf) + .map_err(|e| VaultError::internal(format!("read: {e}")))?; + if n == 0 { + return Ok(()); + } + let raw = String::from_utf8_lossy(&buf[..n]); + let (status, body) = dispatch_request(&raw); + let resp = format!( + "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + reason_phrase(status), + body.len(), + ); + let _ = stream.write_all(resp.as_bytes()); + Ok(()) +} + +fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 423 => "Locked", + 503 => "Service Unavailable", + _ => "Error", + } +} + +fn dispatch_request(raw: &str) -> (u16, String) { + let (head, body) = raw + .split_once("\r\n\r\n") + .or_else(|| raw.split_once("\n\n")) + .unwrap_or((raw, "")); + let mut lines = head.lines(); + let request_line = lines.next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let path_q = parts.next().unwrap_or("/"); + let (path, query) = match path_q.split_once('?') { + Some((p, q)) => (p, q), + None => (path_q, ""), + }; + + let mut auth: Option = None; + for line in lines { + if let Some(v) = line + .strip_prefix("Authorization:") + .or_else(|| line.strip_prefix("authorization:")) + { + let v = v.trim(); + if let Some(t) = v + .strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("bearer ")) + { + auth = Some(t.trim().to_string()); + } + } + } + + match (method, path) { + ("GET", "/v1/health") => { + let out = json!({ + "ok": true, + "unlocked": true, + "version": "0.1.0", + "actorDefault": ai_vault_actor_id(), + "workspaceRoot": ai_vault_workspace_root().display().to_string(), + "transport": "uds", + }); + (200, out.to_string()) + } + ("GET", "/v1/items") => { + if let Err(e) = authorize_bearer(auth.as_deref(), "list") { + return err_response(e); + } + let status = if query.contains("status=deleted") { + VaultItemStatus::Deleted + } else { + VaultItemStatus::Active + }; + match list_ai_vault_items(status) { + Ok(mut v) => { + if let Some(obj) = v.as_object_mut() { + obj.insert("transport".into(), json!("uds")); + } + (200, v.to_string()) + } + Err(e) => err_response(e), + } + } + ("GET", p) if p.starts_with("/v1/items/") && !p.ends_with("/resolve") => { + if let Err(e) = authorize_bearer(auth.as_deref(), "get") { + return err_response(e); + } + let id = p.trim_start_matches("/v1/items/"); + if id.is_empty() || id.contains('/') { + return err_response(VaultError::bad_request_code( + "vault_item_id_invalid", + "条目 id 无效", + )); + } + match get_ai_vault_item(id) { + Ok(mut v) => { + if let Some(obj) = v.as_object_mut() { + obj.insert("transport".into(), json!("uds")); + } + (200, v.to_string()) + } + Err(e) => err_response(e), + } + } + ("POST", p) if p.starts_with("/v1/items/") && p.ends_with("/resolve") => { + if let Err(e) = authorize_bearer(auth.as_deref(), "resolve") { + return err_response(e); + } + let mid = p + .trim_start_matches("/v1/items/") + .trim_end_matches("/resolve"); + if mid.is_empty() { + return err_response(VaultError::bad_request_code( + "vault_item_id_invalid", + "条目 id 无效", + )); + } + let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); + let field = parsed + .get("field") + .and_then(Value::as_str) + .unwrap_or("password"); + let account_id = parsed + .get("accountId") + .or_else(|| parsed.get("account_id")) + .and_then(Value::as_str); + let secret_id = parsed + .get("secretId") + .or_else(|| parsed.get("secret_id")) + .and_then(Value::as_str); + let actor = format!("vaultd:{}", ai_vault_actor_id()); + match resolve_ai_vault_secret(mid, field, &actor, None, account_id, secret_id) { + Ok(mut v) => { + if let Some(obj) = v.as_object_mut() { + obj.insert("transport".into(), json!("uds")); + } + (200, v.to_string()) + } + Err(e) => err_response(e), + } + } + ("POST", p) if p.starts_with("/v1/items/") && p.ends_with("/login") => { + if let Err(e) = authorize_bearer(auth.as_deref(), "login") { + return err_response(e); + } + let mid = p + .trim_start_matches("/v1/items/") + .trim_end_matches("/login"); + if mid.is_empty() { + return err_response(VaultError::bad_request_code( + "vault_item_id_invalid", + "条目 id 无效", + )); + } + let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); + let force = parsed + .get("forceRefresh") + .or_else(|| parsed.get("force_refresh")) + .and_then(Value::as_bool) + .unwrap_or(false); + let actor = format!("vaultd:{}", ai_vault_actor_id()); + match login_ai_vault_credential(mid, force, &actor, None) { + Ok(mut v) => { + if let Some(obj) = v.as_object_mut() { + obj.insert("transport".into(), json!("uds")); + } + (200, v.to_string()) + } + Err(e) => err_response(e), + } + } + ("POST", p) if p.starts_with("/v1/items/") && p.ends_with("/session") => { + if let Err(e) = authorize_bearer(auth.as_deref(), "session") { + return err_response(e); + } + let mid = p + .trim_start_matches("/v1/items/") + .trim_end_matches("/session"); + if mid.is_empty() { + return err_response(VaultError::bad_request_code( + "vault_item_id_invalid", + "条目 id 无效", + )); + } + let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); + let cookie = parsed + .get("cookieHeader") + .or_else(|| parsed.get("cookie_header")) + .and_then(Value::as_str) + .unwrap_or(""); + let expires = parsed + .get("expiresAt") + .or_else(|| parsed.get("expires_at")) + .and_then(Value::as_str); + let source = parsed + .get("source") + .and_then(Value::as_str) + .unwrap_or("human_bridge"); + let actor = format!("vaultd:{}", ai_vault_actor_id()); + match put_ai_vault_session(mid, cookie, expires, source, &actor, None) { + Ok(mut v) => { + if let Some(obj) = v.as_object_mut() { + obj.insert("transport".into(), json!("uds")); + } + (200, v.to_string()) + } + Err(e) => err_response(e), + } + } + (_, _) => err_response(VaultError::new( + VaultStatus::NotFound, + "vaultd_route_not_found", + format!("未知路由 {method} {path}"), + )), + } +} + +fn authorize_bearer(token: Option<&str>, need_scope: &str) -> Result { + let token = token + .map(str::trim) + .filter(|t| !t.is_empty()) + .ok_or_else(|| { + VaultError::unauthorized_code("vault_token_missing", "缺少 Authorization: Bearer") + })?; + let key = load_or_create_hmac_key(&default_hmac_key_path())?; + let claims = verify_token(&key, token)?; + require_scope(&claims, need_scope)?; + require_actor(&claims, &ai_vault_actor_id())?; + Ok(claims) +} + +fn err_response(err: VaultError) -> (u16, String) { + let body = json!({ + "ok": false, + "code": err.code, + "message": err.message, + "details": err.details, + "transport": "uds", + }); + (err.status.as_u16(), body.to_string()) +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let result = match cli.command { + Commands::Doctor => cmd_doctor().map(|v| { + print_json(&v); + ExitCode::SUCCESS + }), + Commands::IssueToken(args) => cmd_issue_token(args).map(|v| { + print_json(&v); + ExitCode::SUCCESS + }), + Commands::Whoami => cmd_whoami().map(|v| { + print_json(&v); + ExitCode::SUCCESS + }), + Commands::Serve(args) => cmd_serve(args).map(|_| ExitCode::SUCCESS), + Commands::List(args) => cmd_list(args).map(|v| { + print_json(&v); + ExitCode::SUCCESS + }), + Commands::Get(args) => cmd_get(args).map(|v| { + print_json(&v); + ExitCode::SUCCESS + }), + Commands::Resolve(args) => { + let raw = args.raw; + cmd_resolve(args).map(|v| { + if raw { + let val = v.get("value").and_then(Value::as_str).unwrap_or(""); + println!("{val}"); + } else { + print_json(&v); + } + ExitCode::SUCCESS + }) + } + Commands::Login(args) => cmd_login(args).map(|v| { + print_json(&v); + ExitCode::SUCCESS + }), + Commands::Session(args) => cmd_session(args).map(|v| { + print_json(&v); + ExitCode::SUCCESS + }), + }; + match result { + Ok(code) => code, + Err(e) => print_err(&e), + } +} diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index beb915be..0afab5ae 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -16,6 +16,7 @@ hyper = "1" hyper-util = { version = "0.1", features = ["tokio"] } leptos = { version = "0.8.14", default-features = false, features = ["ssr"] } mnote-editor-core = { path = "../mnote-editor-core" } +mnote-vault-core = { path = "../mnote-vault-core" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "stream"] } rusqlite = { version = "0.34", features = ["bundled"] } serde = { version = "1", features = ["derive"] } diff --git a/rust/crates/mnote-web/browser/filetree-dnd-runtime.js b/rust/crates/mnote-web/browser/filetree-dnd-runtime.js index d762352a..0a159a54 100644 --- a/rust/crates/mnote-web/browser/filetree-dnd-runtime.js +++ b/rust/crates/mnote-web/browser/filetree-dnd-runtime.js @@ -11,6 +11,7 @@ var FILETREE_DRAG_MIME = 'application/x-mnote-file-tree'; var draggingFileTreeRowIds = []; var activeFileTreeDropRow = null; +var activeFileTreeDropPosition = null; // ─── 纯 helper ───────────────────────────────────────── @@ -22,10 +23,34 @@ function filetreeDragPayload(rowIds) { }); } +/** + * Wolai-style drop zones on a row: + * - top 25% → before (sibling reorder line) + * - bottom 25% → after + * - middle → inside folders/dirs; leaf rows treat middle as after + */ +function fileTreeDropPosition(event, row) { + if (!(row instanceof HTMLElement) || !event) return 'inside'; + var rect = row.getBoundingClientRect(); + var ratio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; + if (ratio < 0.25) return 'before'; + if (ratio > 0.75) return 'after'; + var kind = String(row.getAttribute('data-row-kind') || '').trim(); + if (kind === 'folder' || kind === 'directory' || kind === 'doc' || kind === 'document') { + // documents can host children in page/file tree hybrid; folders always accept inside + if (kind === 'folder' || kind === 'directory') return 'inside'; + // expandable document rows also accept nest-into + if (row.getAttribute('aria-expanded') != null && row.querySelector('.tree-toggle')) return 'inside'; + } + // non-container leaf: middle zone still sorts after the row + return 'after'; +} + function filetreeDropDetail(targetRow, fileTree, deps) { deps = deps || {}; var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; }; var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath || function() { return ''; }; + var dropPosition = deps.dropPosition || null; return { workspaceId: resolveWorkspaceId(targetRow || fileTree), targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null, @@ -36,6 +61,7 @@ function filetreeDropDetail(targetRow, fileTree, deps) { assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null, targetRelativePath: targetRow ? fileTreeRowLocalUploadTargetRelativePath(targetRow) : '', uploadIntent: 'filetree.folder.drop', + dropPosition: dropPosition || (targetRow ? 'inside' : 'inside'), }; } @@ -59,8 +85,21 @@ function parseFileTreeDragPayload(raw) { function clearFileTreeDropFeedback() { if (activeFileTreeDropRow instanceof HTMLElement) { activeFileTreeDropRow.setAttribute('data-drop-target', 'false'); + activeFileTreeDropRow.removeAttribute('data-drop-position'); + activeFileTreeDropRow.setAttribute('data-drop-feedback', 'false'); } activeFileTreeDropRow = null; + activeFileTreeDropPosition = null; +} + +function setFileTreeDropFeedback(row, position) { + clearFileTreeDropFeedback(); + if (!(row instanceof HTMLElement)) return; + activeFileTreeDropRow = row; + activeFileTreeDropPosition = position || 'inside'; + activeFileTreeDropRow.setAttribute('data-drop-target', 'true'); + activeFileTreeDropRow.setAttribute('data-drop-feedback', 'true'); + activeFileTreeDropRow.setAttribute('data-drop-position', activeFileTreeDropPosition); } function resetFileTreeDragState() { @@ -95,10 +134,27 @@ function handleFileTreeDragOver(event, deps) { if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return false; event.preventDefault(); - clearFileTreeDropFeedback(); - activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree; - if (activeFileTreeDropRow instanceof HTMLElement) { - activeFileTreeDropRow.setAttribute('data-drop-target', 'true'); + var row = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]'); + if (row instanceof HTMLElement) { + var position = hasFiles ? 'inside' : fileTreeDropPosition(event, row); + // external file drops only nest into containers + if (hasFiles) { + var kind = String(row.getAttribute('data-row-kind') || '').trim(); + if (kind !== 'folder' && kind !== 'directory' && kind !== 'doc' && kind !== 'document') { + // drop onto leaf file → treat as after (parent folder context resolved at drop) + position = 'after'; + } else if (kind === 'folder' || kind === 'directory') { + position = 'inside'; + } + } + setFileTreeDropFeedback(row, position); + } else { + clearFileTreeDropFeedback(); + activeFileTreeDropRow = fileTree; + if (activeFileTreeDropRow instanceof HTMLElement) { + activeFileTreeDropRow.setAttribute('data-drop-target', 'true'); + } + activeFileTreeDropPosition = 'inside'; } if (event.dataTransfer) { var copyModifier = event.altKey || event.ctrlKey || event.metaKey; @@ -134,9 +190,16 @@ function handleFileTreeDrop(event, deps) { if (!files.length && !rowIds.length) return; event.preventDefault(); + var dropPosition = activeFileTreeDropPosition + || (targetRow ? fileTreeDropPosition(event, targetRow) : 'inside'); + if (files.length) { + var fileKind = targetRow ? String(targetRow.getAttribute('data-row-kind') || '').trim() : ''; + if (fileKind === 'folder' || fileKind === 'directory') dropPosition = 'inside'; + } var detail = filetreeDropDetail(targetRow, fileTree, { resolveWorkspaceId: resolveWorkspaceId, fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath, + dropPosition: dropPosition, }); clearFileTreeDropFeedback(); if (files.length) { @@ -149,6 +212,7 @@ function handleFileTreeDrop(event, deps) { dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: copyModifier, + dropPosition: dropPosition, })); }); } @@ -159,14 +223,18 @@ function handleFileTreeDrop(event, deps) { window.__mnoteFileTreeDndRuntime = { FILETREE_DRAG_MIME: FILETREE_DRAG_MIME, - draggingFileTreeRowIds: draggingFileTreeRowIds, - activeFileTreeDropRow: activeFileTreeDropRow, + get draggingFileTreeRowIds() { return draggingFileTreeRowIds; }, + set draggingFileTreeRowIds(value) { draggingFileTreeRowIds = value; }, + get activeFileTreeDropRow() { return activeFileTreeDropRow; }, + get activeFileTreeDropPosition() { return activeFileTreeDropPosition; }, filetreeDragPayload: filetreeDragPayload, filetreeDropDetail: filetreeDropDetail, + fileTreeDropPosition: fileTreeDropPosition, filetreeHasFiles: filetreeHasFiles, filetreeHasInternalDrag: filetreeHasInternalDrag, parseFileTreeDragPayload: parseFileTreeDragPayload, clearFileTreeDropFeedback: clearFileTreeDropFeedback, + setFileTreeDropFeedback: setFileTreeDropFeedback, resetFileTreeDragState: resetFileTreeDragState, startFileTreeDrag: startFileTreeDrag, handleFileTreeDragOver: handleFileTreeDragOver, diff --git a/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js b/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js index ba08301e..a6105953 100644 --- a/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js +++ b/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js @@ -10,15 +10,22 @@ function getSidebarFileTreeClipboard() { return sidebarFileTreeClipboard; } -function setSidebarFileTreeClipboard(action, rowIds) { +function setSidebarFileTreeClipboard(action, rowIds, options) { sidebarFileTreeClipboard = { action: action, rowIds: rowIds }; document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', action); + // Keep product runtimeState / host clipboard in sync (menu paste reads runtimeState). + if (options && typeof options.onClipboardChange === 'function') { + options.onClipboardChange(sidebarFileTreeClipboard); + } return sidebarFileTreeClipboard; } -function clearSidebarFileTreeClipboard() { +function clearSidebarFileTreeClipboard(options) { sidebarFileTreeClipboard = null; document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action'); + if (options && typeof options.onClipboardChange === 'function') { + options.onClipboardChange(null); + } } // ─── Keyboard handler ─────────────────────────────────── @@ -31,6 +38,7 @@ function handleFileTreeKeyDown(event, deps) { var buildSidebarFileTreeContext = deps.buildSidebarFileTreeContext || function() { return {}; }; var evaluateSidebarFileTreeWhen = deps.evaluateSidebarFileTreeWhen || function() { return false; }; var deleteSelectedSidebarFileTreeRows = deps.deleteSelectedSidebarFileTreeRows || function() { return Promise.resolve(); }; + var onClipboardChange = deps.onClipboardChange || null; var keyTarget = event.target; var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root'); @@ -59,7 +67,9 @@ function handleFileTreeKeyDown(event, deps) { }).filter(Boolean); if (selectedRowIds.length > 0) { event.preventDefault(); - setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds); + setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds, { + onClipboardChange: onClipboardChange, + }); } return true; } @@ -92,7 +102,7 @@ function handleFileTreeKeyDown(event, deps) { // ─── 导出 ─────────────────────────────────────────────── window.__mnoteFileTreeKeyboardRuntime = { - sidebarFileTreeClipboard: sidebarFileTreeClipboard, + get sidebarFileTreeClipboard() { return sidebarFileTreeClipboard; }, getSidebarFileTreeClipboard: getSidebarFileTreeClipboard, setSidebarFileTreeClipboard: setSidebarFileTreeClipboard, clearSidebarFileTreeClipboard: clearSidebarFileTreeClipboard, diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js index 967b8688..659c9c5d 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -183,6 +183,49 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { runtimeState.activeTreeContextMenu = null; } + /** + * Product clipboard used by menu paste + keyboard paste. + * Keep keyboard-module clipboard and runtimeState in lockstep so + * 「粘贴到此处」enables after either Ctrl+C/X or menu cut/copy. + */ + function currentSidebarFileTreeClipboard() { + if (runtimeState.sidebarFileTreeClipboard + && Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) + && runtimeState.sidebarFileTreeClipboard.rowIds.length) { + return runtimeState.sidebarFileTreeClipboard; + } + var kb = window.__mnoteFileTreeKeyboardRuntime; + var fromKb = kb && typeof kb.getSidebarFileTreeClipboard === 'function' + ? kb.getSidebarFileTreeClipboard() + : (kb && kb.sidebarFileTreeClipboard) || null; + if (fromKb && Array.isArray(fromKb.rowIds) && fromKb.rowIds.length) { + // Heal dual-clipboard drift (keyboard module wrote first). + runtimeState.sidebarFileTreeClipboard = fromKb; + return fromKb; + } + return null; + } + + function setSidebarFileTreeClipboard(action, rowIds) { + var next = action && Array.isArray(rowIds) && rowIds.length + ? { action: action, rowIds: rowIds.slice() } + : null; + runtimeState.sidebarFileTreeClipboard = next; + var kb = window.__mnoteFileTreeKeyboardRuntime; + if (kb && typeof kb.setSidebarFileTreeClipboard === 'function') { + if (next) { + kb.setSidebarFileTreeClipboard(next.action, next.rowIds); + } else if (typeof kb.clearSidebarFileTreeClipboard === 'function') { + kb.clearSidebarFileTreeClipboard(); + } + } else if (next) { + document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', next.action); + } else { + document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action'); + } + return next; + } + function copyTreeContextValue(value, actionName) { var text = String(value || ''); var done = function() { @@ -618,6 +661,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { }); return; } + if (action === 'cut' || action === 'copy') { + var clipboardRows = selectedSidebarFileTreeRows(); + if (!clipboardRows.length && trigger && trigger.closest) { + var single = trigger.closest('.tree-row[data-shell-mode="filetree"]'); + if (single instanceof HTMLElement) clipboardRows = [single]; + } + var clipboardRowIds = clipboardRows.map(function(row) { + return String(row.getAttribute('data-row-id') || '').trim(); + }).filter(Boolean); + if (!clipboardRowIds.length) { + recordFileTreeActionStatus('skipped', Object.assign({}, detail, { reason: 'empty-selection' })); + return; + } + setSidebarFileTreeClipboard(action, clipboardRowIds); + recordFileTreeAction(action, Object.assign({}, detail, { + sourceRowIds: clipboardRowIds, + clipboardAction: action, + })); + recordFileTreeActionStatus('applied', Object.assign({}, detail, { + sourceRowIds: clipboardRowIds, + clipboardAction: action, + })); + return; + } if (action === 'paste-into') { var pasteRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger; recordFileTreeAction('paste-into', detail); @@ -1228,7 +1295,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { { action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' }, { action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' }, { action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' }, - { action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !runtimeState.sidebarFileTreeClipboard, title: runtimeState.sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' }, + { separator: true }, + { action: 'cut', icon: 'content_cut', label: '剪切', shortcut: 'Ctrl+X', when: '!workspace.readonly' }, + { action: 'copy', icon: 'content_copy', label: '复制', shortcut: 'Ctrl+C' }, + { action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', shortcut: 'Ctrl+V', disabled: !currentSidebarFileTreeClipboard(), title: currentSidebarFileTreeClipboard() ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' }, { action: 'refresh', icon: 'refresh', label: 'Refresh' }, { action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' }, { action: 'reveal', icon: 'my_location', label: 'Reveal' }, @@ -1929,9 +1999,16 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var runtimeFn = fileTreeRuntimeFunction('fileTreeChildCount'); if (runtimeFn) return runtimeFn(documentId, fileTreeRuntimeDeps()); if (!documentId) return 0; - var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]'); + var row = document.querySelector( + '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"],' + + '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(documentId) + '"],' + + '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(documentId) + '"],' + + '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-node-id="' + cssEscape(documentId) + '"]' + ); var node = row ? row.closest('.tree-node') : null; - var children = node ? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="document"], :scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="doc"]') : []; + var children = node + ? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"]') + : []; return children.length; } @@ -1944,6 +2021,45 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { }, targetRow) || null; } + function fileTreeParentIdFromAttr(parentAttr) { + var raw = String(parentAttr || '').trim(); + if (!raw) return null; + if (raw.indexOf('doc:') === 0) return raw.slice(4); + return raw; + } + + /** + * Wolai-style move target: + * - inside → nest under target (folder/dir/doc) + * - before/after → same parent as target, sortOrder = sibling index (+1 for after) + */ + function resolveFileTreeMoveTarget(targetRow, position) { + if (!(targetRow instanceof HTMLElement)) { + return { parentId: null, sortOrder: 0 }; + } + var pos = position || 'inside'; + if (pos === 'inside') { + var nestParentId = fileTreeMoveTargetParentId(targetRow); + return { + parentId: nestParentId, + sortOrder: nestParentId ? fileTreeChildCount(nestParentId) : 0, + }; + } + var parentAttr = targetRow.getAttribute('data-parent-id') || ''; + var parentId = fileTreeParentIdFromAttr(parentAttr); + var siblings = Array.from( + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]') + ).filter(function(row) { + return (row.getAttribute('data-parent-id') || '') === parentAttr; + }); + var index = siblings.indexOf(targetRow); + if (index < 0) index = 0; + return { + parentId: parentId, + sortOrder: Math.max(0, index + (pos === 'after' ? 1 : 0)), + }; + } + function fileTreeMoveSourceId(row) { if (!(row instanceof HTMLElement)) return ''; return fileTreeRowDocumentId(row) @@ -1956,7 +2072,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var rows = fileTreeRowsByRowIds(rowIds || []); if (rows.length === 0) return false; var copy = Boolean(options && options.copy); - var targetParentId = fileTreeMoveTargetParentId(targetRow); + var dropPosition = (options && options.dropPosition) || 'inside'; + var resolved = resolveFileTreeMoveTarget(targetRow, dropPosition); + var targetParentId = resolved.parentId; + var baseSortOrder = typeof resolved.sortOrder === 'number' ? resolved.sortOrder : 0; var workspaceId = resolveWorkspaceId(targetRow || document.body); var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy); if (!writable) return false; @@ -1983,7 +2102,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { workspaceId: workspaceId, documentId: sourceId, parentId: targetParentId, - sortOrder: targetParentId ? fileTreeChildCount(targetParentId) + i : i + sortOrder: baseSortOrder + i }); } catch (error) { failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败')); @@ -2014,21 +2133,24 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { } async function pasteSidebarFileTreeClipboard(trigger) { - if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false; + var clipboard = currentSidebarFileTreeClipboard(); + if (!clipboard) return false; var targetRow = trigger instanceof HTMLElement ? trigger : null; if (!targetRow && sidebarFileTreeSelection.focusedRowId) { targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]'); } var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId(); - var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy'; + var action = clipboard.action === 'cut' ? 'move' : 'copy'; recordFileTreeAction('paste', { rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '', documentId: targetDocumentId, - sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds, - clipboardAction: runtimeState.sidebarFileTreeClipboard.action + sourceRowIds: clipboard.rowIds, + clipboardAction: clipboard.action }); - var ok = await moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds, targetRow, { copy: action === 'copy' }); - if (ok && action === 'move') runtimeState.sidebarFileTreeClipboard = null; + var ok = await moveSidebarFileTreeRows(clipboard.rowIds, targetRow, { copy: action === 'copy' }); + if (ok && action === 'move') { + setSidebarFileTreeClipboard(null, []); + } return ok; } @@ -2206,6 +2328,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { postSidebarFileTreeJson, fileTreeRowsByRowIds, fileTreeChildCount, + resolveFileTreeMoveTarget, moveSidebarFileTreeRows, pasteSidebarFileTreeClipboard, deleteSelectedSidebarFileTreeRows, diff --git a/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js index 9a998539..d110e821 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js @@ -186,12 +186,29 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => { function clearPageDropFeedback() { if (activePageDropRow instanceof HTMLElement) { activePageDropRow.setAttribute('data-drop-feedback', 'false'); + activePageDropRow.setAttribute('data-drop-target', 'false'); + activePageDropRow.removeAttribute('data-drop-position'); } activePageDropRow = null; } - function setActivePageDropRow(row) { - activePageDropRow = row instanceof HTMLElement ? row : null; + function setActivePageDropRow(row, position) { + if (!(row instanceof HTMLElement)) { + clearPageDropFeedback(); + return; + } + if (activePageDropRow && activePageDropRow !== row) { + clearPageDropFeedback(); + } + activePageDropRow = row; + activePageDropRow.setAttribute('data-drop-feedback', 'true'); + activePageDropRow.setAttribute('data-drop-target', 'true'); + // Always refresh position so before↔after on the same row updates the edge line. + if (position) { + activePageDropRow.setAttribute('data-drop-position', position); + } else { + activePageDropRow.removeAttribute('data-drop-position'); + } } function clearPageDragState() { diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 7185d021..9b01d4e3 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -522,6 +522,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim '
' + '' + '' + + '' + '' + '
' + '' + @@ -3698,12 +3703,17 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim var targetRow = detail.targetRowId ? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]') : null; + var dropPosition = detail.dropPosition || 'inside'; recordFileTreeAction('internal-drop', { rowId: detail.targetRowId || '', sourceRowIds: rowIds, - copy: Boolean(detail.copy) + copy: Boolean(detail.copy), + dropPosition: dropPosition + }); + void moveSidebarFileTreeRows(rowIds, targetRow, { + copy: Boolean(detail.copy), + dropPosition: dropPosition, }); - void moveSidebarFileTreeRows(rowIds, targetRow, { copy: Boolean(detail.copy) }); }); document.addEventListener('contextmenu', function(event) { @@ -3746,6 +3756,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen, deleteSelectedSidebarFileTreeRows: deleteSelectedSidebarFileTreeRows, pasteSidebarFileTreeClipboard: pasteSidebarFileTreeClipboard, + onClipboardChange: function(nextClipboard) { + sidebarFileTreeClipboard = nextClipboard; + }, }); if (handled) return; } else { @@ -3953,10 +3966,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim var sourceNodeId = readPageDragNodeId(event); if (pageRow && canDropPage(sourceNodeId, pageRow)) { event.preventDefault(); - clearPageDropFeedback(); - pageRow.setAttribute('data-drop-feedback', 'true'); - pageRow.setAttribute('data-drop-position', pageDropPosition(event, pageRow)); - setActivePageDropRow(pageRow); + var pagePosition = pageDropPosition(event, pageRow); + setActivePageDropRow(pageRow, pagePosition); if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; return; } diff --git a/rust/crates/mnote-web/browser/vault-workbench-runtime.js b/rust/crates/mnote-web/browser/vault-workbench-runtime.js index 14e5c916..a06d8da7 100644 --- a/rust/crates/mnote-web/browser/vault-workbench-runtime.js +++ b/rust/crates/mnote-web/browser/vault-workbench-runtime.js @@ -94,6 +94,36 @@ .replace(/"/g, '"'); } + /** + * Reusable helper for rendering a single secret row (password/apikey/token). + * Used heavily by account secrets, nested secrets, and the second-secret bug fix. + * Reduces duplication in render blocks. + */ + function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts = {}) { + var key = opts.secretId || ''; + var has = secretState && secretState.state === 'masked'; + var canReveal = has || (secretState && secretState.state === 'revealed'); + if (!canReveal && !showEmpty) return ''; + + var shown = state.revealed[key] + ? state._revealedValues && state._revealedValues[key] + ? escapeHtml(state._revealedValues[key]) + : maskLabel(secretState) + : maskLabel(secretState); + + var revealBtn = state.revealed[key] ? '隐藏' : '显示'; + var copyBtn = ''; + + var actions = state.revealed[key] + ? '' + copyBtn + : '' + copyBtn; + + return '
' + + '
' + shown + '
' + + '
' + actions + '
' + + '
'; + } + function bindWorkbench(root) { if (!(root instanceof HTMLElement) || root.getAttribute('data-vault-bound') === '1') { return; @@ -114,12 +144,97 @@ collapsedFolders: {}, cipherKeys: [], cipherRevealed: {}, + /** @type {HTMLInputElement|HTMLTextAreaElement|null} 编辑表单中最近一次可插入密文的输入框 */ + lastCipherInsertTarget: null, + /** @type {{ start: number, end: number }|null} 失焦前光标,避免点下拉后丢失 */ + lastCipherInsertRange: null, isAiVault: false, aiVaultActorId: 'mnote-e2e', vaultRole: 'user', showEmptyFields: false, + /** + * Edit/create form UI chrome that must survive reRenderFormKeepingSlots + * and view-mode renderDetail (password/appendix reveal re-renders). + * Without this, appendix-secret panels always re-mount collapsed. + * @type {{ openAccountIds: Record, expandedSecretPanels: Record }} + */ + _formUi: { openAccountIds: {}, expandedSecretPanels: {} }, }; + function ensureFormUi() { + if (!state._formUi) { + state._formUi = { openAccountIds: {}, expandedSecretPanels: {} }; + } + if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {}; + if (!state._formUi.expandedSecretPanels) { + state._formUi.expandedSecretPanels = {}; + } + return state._formUi; + } + + /** Account key for UI chrome (open group / appendix panel). */ + function accountUiKey(accId, idx) { + if (accId != null && String(accId) !== '') return String(accId); + if (idx != null) return 'idx:' + String(idx); + return 'default'; + } + + function isSecretsPanelExpanded(accKey) { + var ui = ensureFormUi(); + return !!ui.expandedSecretPanels[accKey]; + } + + function setSecretsPanelExpanded(accKey, expanded) { + if (!accKey) return; + ensureFormUi().expandedSecretPanels[accKey] = !!expanded; + } + + /** + * Build appendix-secrets panel markup; honors expanded state so reveal/hide + * re-renders do not collapse an already-open panel. + */ + function renderSecretsPanelHtml(opts) { + opts = opts || {}; + var accKey = opts.accKey || 'default'; + var title = opts.title || '附录密钥'; + var count = opts.count || 0; + var bodyHtml = opts.bodyHtml || ''; + var extraAttrs = opts.extraAttrs || ''; + var headExtra = opts.headExtra || ''; + var expanded = isSecretsPanelExpanded(accKey); + return ( + '' + ); + } + var listEl = qs('[data-vault-list]', root); var detailEl = qs('[data-vault-detail]', root); var statusEl = qs('[data-vault-status]', root); @@ -127,22 +242,233 @@ var tabActive = qs('[data-vault-tab="active"]', root); var tabDeleted = qs('[data-vault-tab="deleted"]', root); - try { - var savedCollapse = sessionStorage.getItem('mnote.vault.collapsedFolders'); - if (savedCollapse) state.collapsedFolders = JSON.parse(savedCollapse) || {}; - } catch (_e) { - state.collapsedFolders = {}; + // 折叠态:localStorage(同 origin 多窗 / 未来桌面多窗共享)+ BroadcastChannel 即时同步。 + // 与页面树 view-state 同思路的「本机 UI 状态」层;暂不写 control-plane。 + var VAULT_COLLAPSE_LEGACY_SESSION_KEY = 'mnote.vault.collapsedFolders'; + var VAULT_COLLAPSE_SCHEMA = 1; + var collapseUpdatedAtMs = 0; + var collapseBroadcast = null; + var collapseStorageKey = vaultCollapseStorageKey(state.rootUri); + + function vaultCollapseStorageKey(rootUri) { + var raw = String(rootUri || '').trim() || 'default'; + var hash = 0; + for (var i = 0; i < raw.length; i++) { + hash = (hash * 31 + raw.charCodeAt(i)) | 0; + } + return ( + 'mnote.vault.collapsedFolders.v1:' + + (hash >>> 0).toString(16) + + ':' + + encodeURIComponent(raw.slice(0, 120)) + ); } - function persistCollapse() { + function normalizeCollapseMap(raw) { + var out = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + Object.keys(raw).forEach(function (k) { + out[k] = !!raw[k]; + }); + return out; + } + + function parseCollapsePayload(raw) { + if (!raw) return null; try { - sessionStorage.setItem( - 'mnote.vault.collapsedFolders', - JSON.stringify(state.collapsedFolders) - ); + var parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!parsed || typeof parsed !== 'object') return null; + // v1 envelope: { schemaVersion, updatedAtMs, collapsed } + if (parsed.collapsed && typeof parsed.collapsed === 'object') { + return { + schemaVersion: Number(parsed.schemaVersion) || VAULT_COLLAPSE_SCHEMA, + updatedAtMs: Number(parsed.updatedAtMs) || 0, + collapsed: normalizeCollapseMap(parsed.collapsed), + }; + } + // legacy flat map { path: boolean } + return { + schemaVersion: VAULT_COLLAPSE_SCHEMA, + updatedAtMs: 0, + collapsed: normalizeCollapseMap(parsed), + }; + } catch (_e) { + return null; + } + } + + function serializeCollapsePayload() { + return JSON.stringify({ + schemaVersion: VAULT_COLLAPSE_SCHEMA, + updatedAtMs: collapseUpdatedAtMs || Date.now(), + collapsed: state.collapsedFolders || {}, + }); + } + + function loadCollapseState() { + var fromLocal = null; + try { + if (window.localStorage) { + fromLocal = parseCollapsePayload(window.localStorage.getItem(collapseStorageKey)); + } + } catch (_e) { + fromLocal = null; + } + // 一次性迁移:同 tab 旧 sessionStorage → localStorage + var fromSession = null; + try { + if (window.sessionStorage) { + fromSession = parseCollapsePayload( + window.sessionStorage.getItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY) + ); + } + } catch (_e) { + fromSession = null; + } + var chosen = fromLocal; + if (fromSession && (!chosen || (fromSession.updatedAtMs || 0) >= (chosen.updatedAtMs || 0))) { + // 无 local 或 session 更新时间不落后时:优先有数据的一份;二者都无时间戳时若 local 空则用 session + if (!chosen || !Object.keys(chosen.collapsed || {}).length) { + chosen = fromSession; + } + } + if (chosen) { + state.collapsedFolders = chosen.collapsed || {}; + collapseUpdatedAtMs = chosen.updatedAtMs || Date.now(); + } else { + state.collapsedFolders = {}; + collapseUpdatedAtMs = 0; + } + // 若只从 session 迁过来,立刻落到 localStorage(不广播,避免冷启动风暴) + if (fromSession && Object.keys(fromSession.collapsed || {}).length) { + try { + if (window.sessionStorage) { + window.sessionStorage.removeItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY); + } + } catch (_e2) { + /* ignore */ + } + if (!fromLocal || !Object.keys(fromLocal.collapsed || {}).length) { + collapseUpdatedAtMs = collapseUpdatedAtMs || Date.now(); + try { + if (window.localStorage) { + window.localStorage.setItem(collapseStorageKey, serializeCollapsePayload()); + } + } catch (_e3) { + /* ignore */ + } + } + } + } + + function applyRemoteCollapsePayload(payload, source) { + if (!payload || !payload.collapsed) return false; + var remoteTs = Number(payload.updatedAtMs) || 0; + // LWW:远程更旧则忽略(含本窗 storage 回声时用 updatedAt 防抖) + if (remoteTs && collapseUpdatedAtMs && remoteTs < collapseUpdatedAtMs) { + return false; + } + var next = normalizeCollapseMap(payload.collapsed); + var prev = state.collapsedFolders || {}; + var same = + Object.keys(next).length === Object.keys(prev).length && + Object.keys(next).every(function (k) { + return prev[k] === next[k]; + }); + if (same && remoteTs <= collapseUpdatedAtMs) return false; + state.collapsedFolders = next; + collapseUpdatedAtMs = remoteTs || Date.now(); + if (source !== 'localStorage-echo') { + // storage 事件来自其他窗已写好 localStorage,无需再写 + } + return true; + } + + function persistCollapse(options) { + options = options || {}; + collapseUpdatedAtMs = Date.now(); + var payload = { + schemaVersion: VAULT_COLLAPSE_SCHEMA, + updatedAtMs: collapseUpdatedAtMs, + collapsed: state.collapsedFolders || {}, + }; + var raw = JSON.stringify(payload); + try { + if (window.localStorage) { + window.localStorage.setItem(collapseStorageKey, raw); + } } catch (_e) { /* ignore */ } + // 同 origin 其他窗:BroadcastChannel 即时;无 BC 时靠 storage 事件 + if (options.broadcast !== false && collapseBroadcast) { + try { + collapseBroadcast.postMessage({ + type: 'vault.collapse.changed', + storageKey: collapseStorageKey, + payload: payload, + }); + } catch (_e2) { + /* ignore */ + } + } + } + + function onCollapseSyncMessage(payload, source) { + if (applyRemoteCollapsePayload(payload, source)) { + // 列表已渲染时跟着重画;boot 前 renderList 可能尚不可用 + if (typeof renderList === 'function') { + try { + renderList(); + } catch (_e) { + /* ignore */ + } + } + } + } + + function installCollapseSync() { + try { + if (typeof BroadcastChannel !== 'undefined') { + collapseBroadcast = new BroadcastChannel('mnote.vault.collapse.v1'); + collapseBroadcast.onmessage = function (ev) { + var data = ev && ev.data; + if (!data || data.type !== 'vault.collapse.changed') return; + if (data.storageKey && data.storageKey !== collapseStorageKey) return; + onCollapseSyncMessage(data.payload, 'broadcast'); + }; + } + } catch (_e) { + collapseBroadcast = null; + } + // 跨窗 localStorage 变更(BroadcastChannel 不可用或 Electron 分区差异时的托底) + window.addEventListener('storage', function (ev) { + if (!ev || ev.key !== collapseStorageKey || ev.storageArea !== window.localStorage) { + return; + } + var remote = parseCollapsePayload(ev.newValue); + if (remote) onCollapseSyncMessage(remote, 'storage'); + }); + } + + loadCollapseState(); + installCollapseSync(); + + /** + * Collapse state: only keys present in collapsedFolders are explicit. + * 默认全部折叠(文件夹 + 同 URL 多账号组);用户点开后写入 localStorage 并跨窗同步。 + * 旧版「未记录 = 展开」会让大分组(如「倍特」14 条)一进来铺满左栏。 + */ + function isCollapsedKey(key) { + if (Object.prototype.hasOwnProperty.call(state.collapsedFolders, key)) { + return !!state.collapsedFolders[key]; + } + return true; + } + + function toggleCollapseKey(key) { + state.collapsedFolders[key] = !isCollapsedKey(key); + persistCollapse({ broadcast: true }); } /** Normalize folderPath for save; groups appear in the list only after an entry uses them. */ @@ -221,6 +547,7 @@ var hay = [ item.title, item.url, + (item.urls || []).join(' '), item.username, item.email, item.passwordHint, @@ -261,9 +588,12 @@ function renderItemButton(item) { var active = item.id === state.selectedId ? ' is-active' : ''; - var meta = [item.username || item.email || ''] - .filter(Boolean) - .join(' · '); + var accN = item.accountCount || 0; + var secN = item.secretCount || 0; + var metaParts = [item.username || item.email || '']; + if (accN > 1) metaParts.push(accN + ' 账号'); + if (secN > 0) metaParts.push(secN + ' 密钥'); + var meta = metaParts.filter(Boolean).join(' · '); // Single-row compact: title · meta | status glyphs only (no badge rows). var icons = ''; if (itemIsSharedToAi(item) && !state.isAiVault) { @@ -329,7 +659,7 @@ return; } var siteKey = 'url:' + (folderPath || '') + '|' + url; - var collapsed = !!state.collapsedFolders[siteKey]; + var collapsed = isCollapsedKey(siteKey); var label = shortenUrlLabel(url); html += '
请先打开本地文件夹工作区,再使用密码箱。
'; + syncHeaderCipherInsertVisibility(); return; } if (state.mode === 'cipher') { renderCipherBook(); + syncHeaderCipherInsertVisibility(); return; } if (state.mode === 'create') { renderForm(emptyForm(state._createDefaults || {}), true); + syncHeaderCipherInsertVisibility(); return; } if (!state.item && !state.selectedId) { detailEl.innerHTML = '
从左侧选择条目,或点击「新建」。
'; + syncHeaderCipherInsertVisibility(); return; } if (!state.item) { detailEl.innerHTML = '
加载中…
'; + syncHeaderCipherInsertVisibility(); return; } if (state.mode === 'edit') { renderForm(state.item, false); + syncHeaderCipherInsertVisibility(); return; } renderView(state.item); + syncHeaderCipherInsertVisibility(); } - function secretRow(label, field, secretState, showEmpty) { + function revealKey(field, accountId, secretId) { + if (secretId) return 'secret:' + secretId; + if (accountId && field === 'password') return 'account:' + accountId + ':password'; + return field; + } + + function secretRow(label, field, secretState, showEmpty, opts) { + opts = opts || {}; + var accountId = opts.accountId || ''; + var secretId = opts.secretId || ''; + var key = revealKey(field, accountId, secretId); var has = secretState && secretState.state === 'masked'; var canReveal = has || (secretState && secretState.state === 'revealed'); if (!canReveal && !showEmpty) return ''; - var shown = state.revealed[field] - ? state._revealedValues && state._revealedValues[field] - ? escapeHtml(state._revealedValues[field]) + var shown = state.revealed[key] + ? state._revealedValues && state._revealedValues[key] + ? escapeHtml(state._revealedValues[key]) : '…' : maskLabel(secretState); + var extra = + (accountId ? ' data-vault-account-id="' + escapeHtml(accountId) + '"' : '') + + (secretId ? ' data-vault-secret-id="' + escapeHtml(secretId) + '"' : ''); return ( '
' + '' + '
' + shown + '
' + '
' + (canReveal ? '' + '' + escapeHtml(field) + + '"' + + extra + + '>复制' : '未设置') + '
' ); @@ -633,15 +1149,135 @@ } var showEmpty = !!state.showEmptyFields; + var accounts = normalizeAccountsFromItem(item, false); + var accountsHtml = accounts + .map(function (acc, idx) { + var title = + acc.label || + acc.username || + acc.email || + '账号 ' + (idx + 1); + var body = + fieldRow('标签名', acc.label, showEmpty) + + fieldRow('用户名', acc.username, showEmpty) + + fieldRow('邮箱', acc.email, showEmpty) + + secretRow('密码', 'password', acc.password, showEmpty, { + accountId: acc.id || '', + }) + + fieldRow('密码提示', acc.passwordHint, showEmpty); + var secList = acc.secrets || []; + var nestedSecretsHtml = secList + .map(function (sec, sidx) { + var kindLabel = + sec.kind === 'token' + ? 'Token' + : sec.kind === 'other' + ? '其他' + : 'API Key'; + var stitle = sec.label || kindLabel + ' ' + (sidx + 1); + return ( + '
' + + '
' + + escapeHtml(kindLabel) + + (sec.label ? ' · ' + escapeHtml(sec.label) : '') + + '
' + + secretRow( + stitle, + sec.kind === 'token' ? 'token' : 'apikey', + sec.value, + showEmpty, + { secretId: sec.id || '' } + ) + + '
' + ); + }) + .join(''); + if (nestedSecretsHtml) { + // 附录密钥默认折叠;展开态记在 state,避免显示/隐藏密码时整页重绘后被收起。 + body += renderSecretsPanelHtml({ + accKey: accountUiKey(acc.id, idx), + title: '附录密钥', + count: secList.length, + bodyHtml: nestedSecretsHtml, + }); + } else if (showEmpty) { + body += + ''; + } + if (!body.trim()) { + body = '
(空账号组)
'; + } + return ( + '
' + + '' + + '账号 · ' + + escapeHtml(title) + + '' + + (secList.length + ? '密钥 ' + secList.length + '' + : '') + + '' + + '
' + + body + + '
' + ); + }) + .join(''); + if (!accountsHtml && showEmpty) { + accountsHtml = + '
暂无账号组
'; + } + var urls = normalizeUrlsFromItem(item); + var urlsHtml = ''; + if (urls.length) { + urlsHtml = urls + .map(function (u, idx) { + var label = idx === 0 ? '主网址' : '备用 ' + idx; + var link = + '' + + escapeHtml(u) + + ''; + return ( + '
' + + '' + + '
' + + link + + '
' + ); + }) + .join(''); + } else if (showEmpty) { + urlsHtml = fieldRow('网址', '', true); + } var fieldsHtml = fieldRow('分组', item.folderPath, showEmpty) + - fieldRow('网址', item.url, showEmpty) + - fieldRow('用户名', item.username, showEmpty) + - fieldRow('邮箱', item.email, showEmpty) + - secretRow('密码', 'password', item.password, showEmpty) + - fieldRow('提示', item.passwordHint, showEmpty) + - secretRow('API Key', 'apikey', item.apikey, showEmpty) + - secretRow('Token', 'token', item.token, showEmpty) + + (urls.length > 1 + ? '
' + + '
网址(等价入口)' + + '' + + urls.length + + ' 个
' + + urlsHtml + + '
' + : urls.length === 1 + ? fieldRow('网址', urls[0], showEmpty) + : urlsHtml) + + '
' + + '
登录账号' + + (accounts.length > 1 + ? '' + accounts.length + ' 组' + : '') + + '
' + + accountsHtml + + '
' + (visibleTags.length || showEmpty ? '
' + (tags || '') + @@ -671,8 +1307,7 @@ (isDeleted ? '' + '' - : '' + - shareBtn + + : shareBtn + '' + '') + '
' + @@ -799,6 +1434,265 @@ function renderForm(item, isCreate) { var tagsStr = (item.tags || []).join(', '); + // 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。 + var accounts = normalizeAccountsFromItem(item, true); + var urls = normalizeUrlsFromItem(item); + if (!urls.length) urls = ['']; + state._formUrls = urls.slice(); + state._formAccounts = accounts.map(function (a) { + return { + id: a.id || newClientId('acc'), + label: a.label || '', + username: a.username || '', + email: a.email || '', + passwordHint: a.passwordHint || '', + passwordState: a.password || { state: 'absent' }, + passwordClear: false, + passwordValue: '', + secrets: (a.secrets || []).map(function (s) { + return { + id: s.id || newClientId('sec'), + kind: s.kind || 'apikey', + label: s.label || '', + valueState: s.value || { state: 'absent' }, + valueClear: false, + valueText: '', + accountId: a.id || '', + }; + }), + }; + }); + if (!state._formAccounts.length) { + state._formAccounts.push({ + id: newClientId('acc'), + label: '', + username: '', + email: '', + passwordHint: '', + passwordState: { state: 'absent' }, + passwordClear: false, + passwordValue: '', + secrets: [], + }); + } + // Legacy flat list no longer used in UI; kept empty for re-render injects. + state._formSecrets = []; + + function urlsEditorHtml() { + var rows = (state._formUrls || ['']).map(function (u, idx) { + return ( + '
' + + '' + + '' + + (idx > 0 + ? '' + : '') + + '
' + ); + }); + return ( + '
' + + '
' + + '网址(等价入口 / fallback)' + + '' + + '
' + + '

同一服务多个可达地址时,第一项为主入口,其余为备用(VPS / FRP / 本地等)。

' + + rows.join('') + + '
' + ); + } + + function accountGroupHtml(acc, idx) { + // Preserve which account groups were open across re-renders (not only idx===0). + var uiOpen = state._formUi && state._formUi.openAccountIds; + var open = + (uiOpen && uiOpen[acc.id] === true) || + (uiOpen && uiOpen[acc.id] !== false && idx === 0) || + (!uiOpen && idx === 0) + ? ' open' + : ''; + var title = acc.label || acc.username || acc.email || '账号 ' + (idx + 1); + var hasPwd = acc.passwordState && acc.passwordState.state === 'masked'; + var secrets = acc.secrets || []; + var secretsBody = + secrets.length > 0 + ? secrets + .map(function (sec, sidx) { + return secretRowHtml(sec, acc.id, sidx); + }) + .join('') + : '
暂无密钥,可点 + 密钥 添加
'; + // 附录密钥编辑区默认折叠;展开态跨 re-render 保留(+密钥 / 显示密文等)。 + var secretsPanel = renderSecretsPanelHtml({ + accKey: accountUiKey(acc.id, idx), + title: '附录密钥(API Key / Token)', + count: secrets.length, + bodyHtml: secretsBody, + extraAttrs: + ' data-vault-account-secrets="' + escapeHtml(acc.id) + '"', + headExtra: + '', + }); + return ( + '
' + + '' + + '账号 · ' + + escapeHtml(title) + + '' + + (secrets.length + ? '密钥 ' + secrets.length + '' + : '') + + '' + + (state._formAccounts.length > 1 + ? '' + : '') + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + (!isCreate && hasPwd + ? '' + + '' + : '') + + '
' + + '
' + + '
' + + secretsPanel + + '
' + ); + } + + function secretRowHtml(sec, accountId, idx) { + var kindLabel = + sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key'; + var title = sec.label || kindLabel + ' ' + (idx + 1); + var hasVal = sec.valueState && sec.valueState.state === 'masked'; + return ( + '
' + + '
' + + '' + + escapeHtml(kindLabel) + + ' · ' + + escapeHtml(title) + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + (hasVal + ? '' + + '' + : '') + + '
' + + '
' + ); + } + + detailEl.innerHTML = '
' + '
' + @@ -809,16 +1703,18 @@ '' + '' + '
' + + '

可先点用户名 / 邮箱 / 密码框,再点顶栏「插入密文」选择已有 [Key];也可手写 [Key]

' + '
' + formField('title', '标题 *', item.title || '', true) + formFolderPathField(item.folderPath || '') + - formField('url', '网址', item.url || '', false) + - formField('username', '用户名', item.username || '', false) + - formField('email', '邮箱', item.email || '', false) + - formSecretField('password', '密码(可用 [Key] 密文片段)', item.password, isCreate) + - formField('passwordHint', '密码提示', item.passwordHint || '', false) + - formSecretField('apikey', 'API Key', item.apikey, isCreate) + - formSecretField('token', 'Token', item.token, isCreate) + + urlsEditorHtml() + + '
' + + '
' + + '登录账号(用户名 / 邮箱 / 密码 / 提示)' + + '' + + '
' + + state._formAccounts.map(accountGroupHtml).join('') + + '
' + formField('tags', '标签(逗号分隔)', tagsStr, false) + '
' + '