🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题

- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
This commit is contained in:
Syngnat
2026-04-13 12:40:25 +08:00
parent 604aaad69d
commit c7cf9526de
36 changed files with 2097 additions and 497 deletions

View File

@@ -320,6 +320,9 @@ jobs:
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
@@ -336,6 +339,17 @@ jobs:
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
mv "$DMG_NAME" "../../$FINAL_NAME"

View File

@@ -314,6 +314,9 @@ jobs:
echo "🔏 正在进行 Ad-hoc 签名..."
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
@@ -330,6 +333,17 @@ jobs:
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
mv "$DMG_NAME" "../../$FINAL_NAME"

View File

@@ -84,6 +84,63 @@ try_compress_binary_with_upx() {
fi
}
clear_macos_bundle_xattrs() {
local bundle_path="$1"
if [ -z "$bundle_path" ] || [ ! -e "$bundle_path" ]; then
return
fi
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$bundle_path" >/dev/null 2>&1 || true
fi
}
verify_macos_dmg_bundle_signature() {
local dmg_path="$1"
local mount_dir=""
local app_path=""
if [ -z "$dmg_path" ] || [ ! -f "$dmg_path" ]; then
echo -e "${RED} ❌ DMG 文件不存在,无法校验签名:$dmg_path${NC}"
return 1
fi
if ! command -v hdiutil >/dev/null 2>&1 || ! command -v codesign >/dev/null 2>&1; then
echo -e "${YELLOW} ⚠️ 当前环境缺少 hdiutil 或 codesign跳过 DMG 内应用签名校验。${NC}"
return 0
fi
mount_dir=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dmg-verify.XXXXXX")
if [ -z "$mount_dir" ] || [ ! -d "$mount_dir" ]; then
echo -e "${RED} ❌ 创建 DMG 校验挂载目录失败。${NC}"
return 1
fi
if ! hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$dmg_path" >/dev/null 2>&1; then
rmdir "$mount_dir" >/dev/null 2>&1 || true
echo -e "${RED} ❌ 挂载 DMG 失败,无法校验签名。${NC}"
return 1
fi
app_path=$(find "$mount_dir" -maxdepth 1 -name "*.app" -print -quit)
if [ -z "$app_path" ] || [ ! -d "$app_path" ]; then
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
rmdir "$mount_dir" >/dev/null 2>&1 || true
echo -e "${RED} ❌ DMG 内未找到 .app 应用包。${NC}"
return 1
fi
if ! codesign --verify --deep --strict --verbose=4 "$app_path" >/dev/null 2>&1; then
echo -e "${RED} ❌ DMG 内 .app 签名校验失败:$(basename "$app_path")${NC}"
codesign --verify --deep --strict --verbose=4 "$app_path" 2>&1 | sed 's/^/ /'
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
rmdir "$mount_dir" >/dev/null 2>&1 || true
return 1
fi
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
rmdir "$mount_dir" >/dev/null 2>&1 || true
return 0
}
MAC_VOLICON_PATH="build/darwin/icon.icns"
if [ ! -f "$MAC_VOLICON_PATH" ]; then
MAC_VOLICON_PATH=""
@@ -112,19 +169,20 @@ if [ $? -eq 0 ]; then
else
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
exit 1
fi
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
fi
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
@@ -134,8 +192,9 @@ if [ $? -eq 0 ]; then
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
@@ -179,15 +238,17 @@ if [ $? -eq 0 ]; then
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
else
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi
@@ -219,11 +280,12 @@ if [ $? -eq 0 ]; then
else
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
exit 1
fi
# Ad-hoc 代码签名
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
fi
# Ad-hoc 代码签名
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
@@ -239,8 +301,9 @@ if [ $? -eq 0 ]; then
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
@@ -282,14 +345,16 @@ if [ $? -eq 0 ]; then
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
else
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi

View File

@@ -1,111 +0,0 @@
# 2026-04-11 Issue Backlog Tracking
## Scope
- 分支:`codex/issue-242-data-root`
- 策略:按 GitHub issue 创建时间从早到晚逐条处理
- 提交要求:每条 issue 单独本地提交,提交信息使用 `Fixes #<issue>`
## Progress
| Issue | Title | Status | Commit |
| --- | --- | --- | --- |
| #242 | 希望有自定义数据存储位置功能 | Fixed | `1f617f9` |
| #287 | 建议补充 Sql Server 数据库图标 | Fixed | `60b63d7` |
| #305 | 金仓数据库设计表新增字段保存失败 | Fixed | `f696f52` |
| #306 | 驱动下载 | Fixed | `8297829` |
| #308 | clickhouse 获取数据库列表失败 | Fixed | `5d86ee7` |
| #310 | 选择库后,右侧行显示各个表 | Fixed | `808c773` |
| #311 | WIN 系统的执行 500 多条 insert 语句要几分钟 | Fixed | `83fe3d4` |
| #315 | 窗体内缩放异常 | Fixed | `5038ae5` |
| #316 | 人大金仓数据库驱动版本过低 | Fixed | `aa1bb5b` |
| #317 | 驱动管理增加导入 jar 功能 | Blocked | - |
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | `89d79ff` |
| #319 | 关于运行外部 sql 文件的一些建议 | Deferred | - |
| #320 | 无法连接达梦数据库 | Fixed | `1c2377b` |
| #322 | 【拖选复制】希望添加 查询结果表格可以拖选复制效果就如操作excel表格的选择复制一样 | Fixed | Pending |
| #325 | 有没有考虑对数据库的驱动版本进行选择或者自定义? | Fixed | `af5e842` |
| #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` |
| #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` |
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
| #331 | 重复连接 DB一分钟重试了 60 多次 | Fixed | `ca76440` |
| #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending |
## Notes
### #317
- 当前驱动管理只支持内置 Go 驱动和可选 Go 驱动代理包。
- 仓库内不存在 JDBC/JAR 装载、Java 运行时探测、classpath 管理或桥接执行链路。
- 在现有架构下直接增加 “导入 jar” 入口会形成假功能,因此暂记为架构阻塞,不做伪实现。
### #318
- 根因MySQL 写入归一化只覆盖时间列,`bit` 列提交时会把前端传来的 `"1"`/`"0"` 原样透传给驱动。
- 处理:为 MySQL `bit` 列补充写入值归一化,将常见文本/布尔/数值输入转换为驱动可接受的 `[]byte`
- 验证:补充 `internal/db/mysql_value_test.go` 回归测试,覆盖 `bit(1)` 的 insert/update 写入路径。
### #319
- 现有应用已支持“运行外部 SQL 文件”,但 issue 诉求包含目录树、目录加载、双击文件打开等整组工作区能力。
- 该项已超出单点缺陷修复范围,暂按功能增强项顺延,避免在逐条修 bug 流程中引入大范围 UI/状态管理重构。
### #320
- 达梦当前走可选 Go 驱动代理安装链路,不支持 JAR 导入属于既有架构边界。
- 根因:驱动 release 资产缓存把 `GoNavi-DriverAgents.zip` 里的 bundle 条目也混进了“顶层已发布 asset”集合导致安装链路误以为存在单独的 `dameng-driver-agent-*.exe` 下载地址。
- 处理:缓存层区分真实 release 顶层 asset 与 bundle index 条目,安装 URL 解析仅在真实顶层 asset 存在时才走直链bundle-only 驱动改为直接进入总包提取回退,不再先卡在 20% 试无效 URL。
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖 bundle-only 达梦驱动跳过伪直链,并回归 Mongo 历史版本与本地导入链路。
### #327
- 根因:低权限 MySQL 账号执行 `SHOW DATABASES` 会直接报错,当前实现没有回退路径。
- 处理:为数据库列表查询增加 `SELECT DATABASE()` 回退,仅保留当前连接库时也能正常展示。
- 验证:补充 `internal/db/mysql_metadata_test.go` 回归测试,覆盖有权限、多库和低权限回退场景。
### #328
- 根因Windows 更新脚本在批处理执行、错误码读取和重启命令上不够稳,`cmd /C start`、LF 行尾和块内 `%ERRORLEVEL%` 在实际环境下容易引发安装失败。
- 处理:更新脚本统一输出为 CRLF块内错误码改为延迟展开旧文件回退路径统一为 `TARGET_OLD`,并将脚本启动方式收敛为 `cmd.exe /D /C call <script>`
- 验证:补充 `internal/app/methods_update_windows_script_test.go`覆盖批处理语法、Win10 回退路径、CRLF 行尾、延迟展开和启动命令构造。
### #325
- 根因TDengine 的版本列表虽然支持下拉选择,但后端在抓取与缓存 Go 模块版本时只保留最近 5 个版本,导致 `3.5.x / 3.3.x / 3.0.x` 这类旧版根本不会进入选择列表。
- 处理:放宽 TDengine 的历史版本窗口,并补充离线 fallback 版本矩阵;同时扩大模块版本缓存上限,确保旧版不会在抓取阶段就被截断。
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖缓存命中与 fallback 两条路径,并回归 Mongo 版本约束逻辑。
### #329
- 根因:侧边栏连接树被全局 Tree 样式固定为 `width: 100%`,标题同时启用了省略截断,导致缩窄侧栏后长节点无法形成横向溢出。
- 处理:为 Sidebar 树增加专用横向滚动容器,并在 Sidebar 作用域内覆写 Tree 宽度与标题截断规则,让节点宽度随内容扩展且保留最小占满。
- 验证:执行 `frontend``npm run build`,确认 TS/CSS 改动编译通过且仅作用于 Sidebar 树。
### #331
- 根因:连接失败时存在双层重试叠加。`DBGetDatabases / DBGetTables / DBQuery` 在缓存失效后本来就会主动重建连接一次,而 `connectDatabaseWithStartupRetry` 在稳定期仍会额外放行一次瞬时错误自动重试,导致一次后台探测会被放大成多次真实建连。
- 处理:将连接自动重试范围收敛到应用启动保护窗口内;稳定期下所有连接探测与重建都只执行一次,避免后台挂起场景持续放大失败流量。
- 验证:补充并更新 `internal/app/app_startup_connect_retry_test.go`,覆盖稳定期瞬时失败不重试、不再输出重试提示,以及启动期仍保留完整重试预算。
### #330
- 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。
- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。
- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend``npm run build` 确认 TS 与打包通过。
### #322
- 根因:`DataGrid` 已经具备拖选单元格和选区状态维护能力,但当前复制能力只支持把同一行选中的列值暂存为内部 patch用于“粘贴到选中行”没有把矩形选区真正导出到系统剪贴板。
- 处理:新增选区复制 helper将矩形选区按当前可见行列顺序导出为制表符文本同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend``npm run build` 确认功能接线通过。
### #351
- 根因:后端已有批量清空表能力,但前端单表危险操作菜单只暴露了“删除表”,没有把“截断表 / 清空表”作为显式入口提供给用户;同时批量“清空”动作底层语义也混用了 `TRUNCATE/DELETE`
- 处理:后端将“截断表”和“清空表”拆分为显式能力,统一通过 helper 生成多数据库 SQL前端为 Sidebar 和 TableOverview 的表菜单补上两个危险操作入口,并仅在明确支持 `TRUNCATE TABLE` 的数据库类型上显示“截断表”。
- 验证:新增 `internal/app/methods_file_clear_test.go``frontend/src/components/tableDataDangerActions.test.ts`,并执行 `go test ./...``frontend``npm run build` 确认全量通过。
## Next
- 继续处理下一个最早且可直接落地的开放 issue。

View File

@@ -1 +1 @@
8cc5d6401a6ce7dd0f500c66ce8bb4a9
26a843d5fd071d0c7e9d8022e98eb4e3

View File

@@ -22,6 +22,7 @@ import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, is
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
import { resolveAboutDisplayVersion } from './utils/appVersionDisplay';
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
import { getConnectionWorkbenchState } from './utils/startupReadiness';
import { toSaveGlobalProxyInput } from './utils/globalProxyDraft';
@@ -181,6 +182,7 @@ function App() {
const effectiveBlur = normalizeBlurForPlatform(resolvedAppearance.blur);
const blurFilter = blurToFilter(effectiveBlur);
const [runtimePlatform, setRuntimePlatform] = useState('');
const [runtimeBuildType, setRuntimeBuildType] = useState('');
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false);
@@ -219,13 +221,16 @@ function App() {
const windowCornerRadius = 14;
useEffect(()=>{
if (typeof document === 'undefined' || !document.body) {
return;
}
switch(windowState){
case 'fullscreen':
case 'maximized':
document.body.setAttribute('--gonavi-border-radius', '0px');
document.body.style.setProperty('--gonavi-border-radius', '0px');
break;
default:
document.body.setAttribute('--gonavi-border-radius', `${windowCornerRadius}px`);
document.body.style.setProperty('--gonavi-border-radius', `${windowCornerRadius}px`);
break;
}
}, [windowState]);
@@ -246,6 +251,7 @@ function App() {
if (cancelled) return;
const platform = String(env?.platform || '').toLowerCase();
setRuntimePlatform(platform);
setRuntimeBuildType(String(env?.buildType || '').toLowerCase());
setIsLinuxRuntime(platform === 'linux');
})
.catch(() => {
@@ -1089,6 +1095,7 @@ function App() {
const isAboutOpenRef = React.useRef(false);
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string; communityUrl?: string } | null>(null);
const aboutDisplayVersion = resolveAboutDisplayVersion(runtimeBuildType, aboutInfo?.version);
const [aboutUpdateStatus, setAboutUpdateStatus] = useState<string>('');
const [lastUpdateInfo, setLastUpdateInfo] = useState<UpdateInfo | null>(null);
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{
@@ -1145,7 +1152,11 @@ function App() {
const isWindowsRuntime = runtimePlatform === 'windows'
|| (runtimePlatform === '' && isWindowsPlatform());
const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
const macWindowDiagnosticsEnabled = shouldEnableMacWindowDiagnostics(isMacRuntime, import.meta.env.DEV);
const macWindowDiagnosticsEnabled = shouldEnableMacWindowDiagnostics(
isMacRuntime,
import.meta.env.DEV,
import.meta.env.VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS,
);
const emitWindowDiagnostic = useCallback(async (stage: string, extra: Record<string, unknown> = {}) => {
if (!macWindowDiagnosticsEnabled) {
@@ -3040,7 +3051,7 @@ function App() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 600 }}></div>
<div style={utilityMutedTextStyle}>{aboutInfo?.version || '未知'}</div>
<div style={utilityMutedTextStyle}>{aboutDisplayVersion}</div>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 600 }}></div>

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { resolveAboutDisplayVersion } from './appVersionDisplay';
describe('resolveAboutDisplayVersion', () => {
it('shows fixed dev version for development build', () => {
expect(resolveAboutDisplayVersion('development', '0.6.5')).toBe('0.0.1-dev');
});
it('shows fixed dev version for wails dev build type', () => {
expect(resolveAboutDisplayVersion('dev', '0.6.5')).toBe('0.0.1-dev');
});
it('keeps real version for non-development builds', () => {
expect(resolveAboutDisplayVersion('production', '0.6.5')).toBe('0.6.5');
});
it('falls back to unknown when version is empty outside development', () => {
expect(resolveAboutDisplayVersion('production', '')).toBe('未知');
});
});

View File

@@ -0,0 +1,14 @@
const DEV_ABOUT_VERSION = '0.0.1-dev';
export const resolveAboutDisplayVersion = (
buildType: string,
version: string | undefined,
): string => {
const normalizedBuildType = String(buildType || '').trim().toLowerCase();
if (normalizedBuildType === 'development' || normalizedBuildType === 'dev') {
return DEV_ABOUT_VERSION;
}
const normalizedVersion = String(version || '').trim();
return normalizedVersion || '未知';
};

View File

@@ -4,14 +4,22 @@ import { shouldEnableMacWindowDiagnostics } from './macWindowDiagnostics';
describe('macWindowDiagnostics', () => {
it('stays disabled outside macOS runtime', () => {
expect(shouldEnableMacWindowDiagnostics(false, true)).toBe(false);
expect(shouldEnableMacWindowDiagnostics(false, true, 'true')).toBe(false);
});
it('stays disabled for production builds on macOS', () => {
expect(shouldEnableMacWindowDiagnostics(true, false)).toBe(false);
expect(shouldEnableMacWindowDiagnostics(true, false, 'true')).toBe(false);
});
it('enables diagnostics only for macOS development builds', () => {
expect(shouldEnableMacWindowDiagnostics(true, true)).toBe(true);
it('stays disabled by default for macOS development builds', () => {
expect(shouldEnableMacWindowDiagnostics(true, true)).toBe(false);
expect(shouldEnableMacWindowDiagnostics(true, true, '')).toBe(false);
expect(shouldEnableMacWindowDiagnostics(true, true, '0')).toBe(false);
});
it('enables diagnostics only when explicitly opted in on macOS development builds', () => {
expect(shouldEnableMacWindowDiagnostics(true, true, '1')).toBe(true);
expect(shouldEnableMacWindowDiagnostics(true, true, 'true')).toBe(true);
expect(shouldEnableMacWindowDiagnostics(true, true, 'yes')).toBe(true);
});
});

View File

@@ -1,6 +1,22 @@
const isTruthyFlag = (value: string | undefined): boolean => {
switch (String(value || '').trim().toLowerCase()) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
default:
return false;
}
};
export const shouldEnableMacWindowDiagnostics = (
isMacRuntime: boolean,
isDevBuild: boolean,
envValue?: string,
): boolean => {
return isMacRuntime && isDevBuild;
if (!isMacRuntime || !isDevBuild) {
return false;
}
return isTruthyFlag(envValue);
};

View File

@@ -1,2 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/dailysecret"
"GoNavi-Wails/internal/secretstore"
)
@@ -38,8 +38,9 @@ type ProviderConfigStoreInspection struct {
}
type ProviderConfigStore struct {
configDir string
secretStore secretstore.SecretStore
configDir string
secretStore secretstore.SecretStore
dailySecrets *dailysecret.Store
}
func NewProviderConfigStore(configDir string, store secretstore.SecretStore) *ProviderConfigStore {
@@ -50,8 +51,9 @@ func NewProviderConfigStore(configDir string, store secretstore.SecretStore) *Pr
store = secretstore.NewUnavailableStore("secret store unavailable")
}
return &ProviderConfigStore{
configDir: configDir,
secretStore: store,
configDir: configDir,
secretStore: store,
dailySecrets: dailysecret.NewStore(configDir),
}
}
@@ -96,24 +98,7 @@ func (s *ProviderConfigStore) Load() (ProviderConfigStoreSnapshot, error) {
}
func (s *ProviderConfigStore) LoadRuntime() (ProviderConfigStoreSnapshot, error) {
_, snapshot, err := s.readStoredSnapshot()
if err != nil {
return snapshot, err
}
providers := make([]ai.ProviderConfig, 0, len(snapshot.Providers))
for _, providerConfig := range snapshot.Providers {
runtimeConfig, loadErr := s.loadRuntimeProviderConfig(providerConfig)
if loadErr != nil {
logger.Error(loadErr, "加载 AI Provider secret 失败provider=%s", providerConfig.ID)
}
providers = append(providers, runtimeConfig)
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
snapshot.Providers = providers
return snapshot, nil
return s.Load()
}
func (s *ProviderConfigStore) Inspect() (ProviderConfigStoreInspection, error) {
@@ -141,11 +126,17 @@ func (s *ProviderConfigStore) Save(snapshot ProviderConfigStoreSnapshot) error {
runtimeConfig := normalizeProviderConfig(providerConfig)
meta, bundle := splitProviderSecrets(runtimeConfig)
if bundle.hasAny() {
storedMeta, err := persistProviderSecretBundle(s.secretStore, meta, bundle)
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, bundle)
if err != nil {
return fmt.Errorf("保存 Provider secret 失败: %w", err)
}
meta = storedMeta
} else if meta.HasSecret {
resolved, _, err := s.loadStoredProviderConfig(meta)
if err != nil {
return fmt.Errorf("保存 Provider secret 失败: %w", err)
}
meta = providerMetadataView(resolved)
}
providers = append(providers, providerMetadataView(meta))
}
@@ -219,7 +210,7 @@ func (s *ProviderConfigStore) readStoredSnapshot() (aiConfig, ProviderConfigStor
func (s *ProviderConfigStore) loadStoredProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, bool, error) {
meta, bundle := splitProviderSecrets(config)
if bundle.hasAny() {
storedMeta, err := persistProviderSecretBundle(s.secretStore, meta, bundle)
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, bundle)
if err != nil {
return meta, false, err
}
@@ -230,33 +221,49 @@ func (s *ProviderConfigStore) loadStoredProviderConfig(config ai.ProviderConfig)
return meta, false, nil
}
resolved, err := resolveProviderConfigSecrets(s.secretStore, meta)
if err != nil {
if os.IsNotExist(err) {
return meta, false, nil
}
if stored, ok, err := s.dailySecrets.GetAIProvider(meta.ID); err != nil {
return meta, false, err
} else if ok {
rewritten := strings.TrimSpace(meta.SecretRef) != ""
meta.SecretRef = ""
return mergeProviderSecrets(meta, fromDailyProviderBundle(stored)), rewritten, nil
}
return resolved, false, nil
if !shouldReadLegacyProviderSecretStore() {
meta.HasSecret = false
meta.SecretRef = ""
return meta, true, nil
}
if strings.TrimSpace(meta.SecretRef) != "" {
resolved, err := resolveProviderConfigSecretsFromStore(s.secretStore, meta)
if err != nil {
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
meta.HasSecret = false
meta.SecretRef = ""
return meta, true, nil
}
return meta, false, err
}
_, migratedBundle := splitProviderSecrets(resolved)
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, migratedBundle)
if err != nil {
return meta, false, err
}
return mergeProviderSecrets(storedMeta, migratedBundle), true, nil
}
meta.HasSecret = false
meta.SecretRef = ""
return meta, true, nil
}
func (s *ProviderConfigStore) loadRuntimeProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, error) {
meta, bundle := splitProviderSecrets(config)
if bundle.hasAny() {
return mergeProviderSecrets(meta, bundle), nil
}
if !meta.HasSecret {
return meta, nil
}
resolved, err := resolveProviderConfigSecrets(s.secretStore, meta)
if err != nil {
return meta, err
}
return resolved, nil
runtimeConfig, _, err := s.loadStoredProviderConfig(config)
return runtimeConfig, err
}
func providerNeedsMigration(config ai.ProviderConfig) bool {
_, bundle := splitProviderSecrets(normalizeProviderConfig(config))
return bundle.hasAny()
return bundle.hasAny() || strings.TrimSpace(config.SecretRef) != ""
}

View File

@@ -12,8 +12,7 @@ import (
)
func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
store := newFakeProviderSecretStore()
configStore := newProviderConfigStore(t.TempDir(), store)
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
legacy := aiConfig{
Providers: []ai.ProviderConfig{
@@ -52,16 +51,15 @@ func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
t.Fatalf("expected runtime provider to restore sensitive header, got %#v", snapshot.Providers[0].Headers)
}
stored, err := store.Get(snapshot.Providers[0].SecretRef)
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("expected migrated provider secret bundle, got %v", err)
t.Fatalf("GetAIProvider returned error: %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
if !ok {
t.Fatal("expected migrated provider secret bundle in daily store")
}
if bundle.APIKey != "sk-test" {
t.Fatalf("expected migrated apiKey in store, got %q", bundle.APIKey)
if stored.APIKey != "sk-test" {
t.Fatalf("expected migrated apiKey in store, got %q", stored.APIKey)
}
rewritten, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
@@ -78,8 +76,7 @@ func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
}
func TestProviderConfigStoreSavePersistsSecretlessMetadata(t *testing.T) {
store := newFakeProviderSecretStore()
configStore := newProviderConfigStore(t.TempDir(), store)
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{
@@ -115,27 +112,24 @@ func TestProviderConfigStoreSavePersistsSecretlessMetadata(t *testing.T) {
t.Fatalf("expected config file to remove sensitive headers, got %s", text)
}
ref, err := secretstore.BuildRef(providerSecretKind, "openai-main")
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
t.Fatalf("GetAIProvider returned error: %v", err)
}
stored, err := store.Get(ref)
if err != nil {
t.Fatalf("expected provider secret bundle in store, got %v", err)
if !ok {
t.Fatal("expected provider secret bundle in daily store")
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
if stored.APIKey != "sk-test" {
t.Fatalf("expected stored apiKey, got %q", stored.APIKey)
}
if bundle.APIKey != "sk-test" {
t.Fatalf("expected stored apiKey, got %q", bundle.APIKey)
}
if bundle.SensitiveHeaders["Authorization"] != "Bearer test" {
t.Fatalf("expected stored sensitive header, got %#v", bundle.SensitiveHeaders)
if stored.SensitiveHeaders["Authorization"] != "Bearer test" {
t.Fatalf("expected stored sensitive header, got %#v", stored.SensitiveHeaders)
}
}
func TestProviderConfigStoreSaveKeepsExistingSecretRef(t *testing.T) {
withTestAIGOOS(t, "linux")
store := newFakeProviderSecretStore()
configStore := newProviderConfigStore(t.TempDir(), store)
@@ -178,16 +172,15 @@ func TestProviderConfigStoreSaveKeepsExistingSecretRef(t *testing.T) {
t.Fatalf("Save returned error: %v", err)
}
stored, err := store.Get(ref)
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("expected existing provider secret bundle to remain available, got %v", err)
t.Fatalf("GetAIProvider returned error: %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
if !ok {
t.Fatal("expected existing provider secret bundle to be migrated to daily store")
}
if bundle.APIKey != "sk-existing" {
t.Fatalf("expected existing apiKey to be kept, got %q", bundle.APIKey)
if stored.APIKey != "sk-existing" {
t.Fatalf("expected existing apiKey to be kept, got %q", stored.APIKey)
}
snapshot, err := configStore.Load()

View File

@@ -0,0 +1,19 @@
package aiservice
import (
stdRuntime "runtime"
"GoNavi-Wails/internal/dailysecret"
)
var aiRuntimeGOOS = func() string {
return stdRuntime.GOOS
}
func (s *Service) dailySecretStore() *dailysecret.Store {
return dailysecret.NewStore(s.configDir)
}
func shouldReadLegacyProviderSecretStore() bool {
return aiRuntimeGOOS() != "darwin"
}

View File

@@ -3,10 +3,12 @@ package aiservice
import (
"encoding/json"
"fmt"
"os"
"strings"
"unicode"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/dailysecret"
"GoNavi-Wails/internal/secretstore"
)
@@ -76,11 +78,6 @@ func splitProviderSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, providerSec
meta.HasSecret = cfg.HasSecret || bundle.hasAny()
meta.SecretRef = strings.TrimSpace(cfg.SecretRef)
if meta.HasSecret && meta.SecretRef == "" && strings.TrimSpace(cfg.ID) != "" {
if ref, err := secretstore.BuildRef(providerSecretKind, cfg.ID); err == nil {
meta.SecretRef = ref
}
}
if !meta.HasSecret {
meta.SecretRef = ""
}
@@ -108,11 +105,7 @@ func mergeProviderSecrets(cfg ai.ProviderConfig, bundle providerSecretBundle) ai
}
merged.HasSecret = cfg.HasSecret || bundle.hasAny()
if merged.HasSecret && strings.TrimSpace(merged.SecretRef) == "" && strings.TrimSpace(merged.ID) != "" {
if ref, err := secretstore.BuildRef(providerSecretKind, merged.ID); err == nil {
merged.SecretRef = ref
}
}
merged.SecretRef = ""
if !merged.HasSecret {
merged.SecretRef = ""
}
@@ -120,43 +113,73 @@ func mergeProviderSecrets(cfg ai.ProviderConfig, bundle providerSecretBundle) ai
return merged
}
func persistProviderSecretBundle(store secretstore.SecretStore, meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
func toDailyProviderBundle(bundle providerSecretBundle) dailysecret.ProviderBundle {
return dailysecret.ProviderBundle{
APIKey: bundle.APIKey,
SensitiveHeaders: cloneStringMap(bundle.SensitiveHeaders),
}
}
func fromDailyProviderBundle(bundle dailysecret.ProviderBundle) providerSecretBundle {
return providerSecretBundle{
APIKey: bundle.APIKey,
SensitiveHeaders: cloneStringMap(bundle.SensitiveHeaders),
}
}
func persistProviderSecretBundle(store *dailysecret.Store, meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
meta, _ = splitProviderSecrets(meta)
if !bundle.hasAny() {
meta.HasSecret = false
meta.SecretRef = ""
return meta, nil
if store == nil {
return meta, nil
}
return meta, store.DeleteAIProvider(meta.ID)
}
if store == nil {
return meta, fmt.Errorf("secret store unavailable")
return meta, fmt.Errorf("daily secret store unavailable")
}
if err := store.HealthCheck(); err != nil {
if err := store.PutAIProvider(meta.ID, toDailyProviderBundle(bundle)); err != nil {
return meta, err
}
ref := strings.TrimSpace(meta.SecretRef)
if ref == "" {
var err error
ref, err = secretstore.BuildRef(providerSecretKind, meta.ID)
if err != nil {
return meta, err
}
}
payload, err := json.Marshal(bundle)
if err != nil {
return meta, fmt.Errorf("序列化 provider secret bundle 失败: %w", err)
}
if err := store.Put(ref, payload); err != nil {
return meta, err
}
meta.SecretRef = ref
meta.SecretRef = ""
meta.HasSecret = true
return meta, nil
}
func resolveProviderConfigSecrets(store secretstore.SecretStore, cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
func resolveProviderConfigSecrets(store *dailysecret.Store, cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
cfg = normalizeProviderConfig(cfg)
meta, bundle := splitProviderSecrets(cfg)
if bundle.hasAny() {
return mergeProviderSecrets(meta, bundle), nil
}
if !meta.HasSecret {
return meta, nil
}
if store == nil {
return meta, fmt.Errorf("daily secret store unavailable")
}
stored, ok, err := store.GetAIProvider(meta.ID)
if err != nil {
return meta, err
}
if !ok {
return meta, os.ErrNotExist
}
meta.SecretRef = ""
return mergeProviderSecrets(meta, fromDailyProviderBundle(stored)), nil
}
func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
return persistProviderSecretBundle(s.dailySecretStore(), meta, bundle)
}
func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
return resolveProviderConfigSecrets(s.dailySecretStore(), cfg)
}
func resolveProviderConfigSecretsFromStore(store secretstore.SecretStore, cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
cfg = normalizeProviderConfig(cfg)
meta, bundle := splitProviderSecrets(cfg)
if bundle.hasAny() {
@@ -191,14 +214,6 @@ func resolveProviderConfigSecrets(store secretstore.SecretStore, cfg ai.Provider
return mergeProviderSecrets(meta, stored), nil
}
func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
return persistProviderSecretBundle(s.secretStore, meta, bundle)
}
func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
return resolveProviderConfigSecrets(s.secretStore, cfg)
}
func providerMetadataView(cfg ai.ProviderConfig) ai.ProviderConfig {
meta, _ := splitProviderSecrets(normalizeProviderConfig(cfg))
return meta

View File

@@ -2,6 +2,7 @@ package aiservice
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
@@ -11,6 +12,17 @@ import (
"GoNavi-Wails/internal/secretstore"
)
func withTestAIGOOS(t *testing.T, goos string) {
t.Helper()
previous := aiRuntimeGOOS
aiRuntimeGOOS = func() string {
return goos
}
t.Cleanup(func() {
aiRuntimeGOOS = previous
})
}
func TestSplitProviderSecretsStripsAPIKeyAndSensitiveHeaders(t *testing.T) {
input := ai.ProviderConfig{
ID: "openai-main",
@@ -41,28 +53,19 @@ func TestSplitProviderSecretsStripsAPIKeyAndSensitiveHeaders(t *testing.T) {
}
func TestResolveProviderConfigSecretsRestoresStoredSecretBundle(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
payload, err := json.Marshal(providerSecretBundle{
service := NewServiceWithSecretStore(failOnUseSecretStore{})
service.configDir = t.TempDir()
if err := service.dailySecretStore().PutAIProvider("openai-main", toDailyProviderBundle(providerSecretBundle{
APIKey: "sk-test",
SensitiveHeaders: map[string]string{
"Authorization": "Bearer test",
},
})
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if err := store.Put(ref, payload); err != nil {
t.Fatalf("Put returned error: %v", err)
})); err != nil {
t.Fatalf("PutAIProvider returned error: %v", err)
}
resolved, err := service.resolveProviderConfigSecrets(ai.ProviderConfig{
ID: "openai-main",
SecretRef: ref,
HasSecret: true,
Headers: map[string]string{
"X-Team": "db",
@@ -83,8 +86,7 @@ func TestResolveProviderConfigSecretsRestoresStoredSecretBundle(t *testing.T) {
}
func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service := NewServiceWithSecretStore(failOnUseSecretStore{})
service.configDir = t.TempDir()
legacy := aiConfig{
@@ -134,12 +136,15 @@ func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing
t.Fatalf("expected runtime provider to keep sensitive header, got %#v", service.providers[0].Headers)
}
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
t.Fatalf("GetAIProvider returned error: %v", err)
}
if _, err := store.Get(ref); !os.IsNotExist(err) {
t.Fatalf("expected startup load to avoid secret-store migration, got %v", err)
if !ok {
t.Fatal("expected startup load to migrate plaintext provider secret to daily store")
}
if stored.APIKey != "sk-test" || stored.SensitiveHeaders["Authorization"] != "Bearer test" {
t.Fatalf("unexpected migrated provider bundle: %#v", stored)
}
rewritten, err := os.ReadFile(configPath)
@@ -147,17 +152,16 @@ func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(rewritten)
if !strings.Contains(text, "sk-test") {
t.Fatalf("expected config file to remain unchanged, got %s", text)
if strings.Contains(text, "sk-test") {
t.Fatalf("expected config file to be rewritten secretless, got %s", text)
}
if !strings.Contains(text, "Bearer test") {
t.Fatalf("expected config file to keep sensitive header, got %s", text)
if strings.Contains(text, "Bearer test") {
t.Fatalf("expected config file to remove sensitive header, got %s", text)
}
}
func TestAISaveProviderKeepsLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service := NewServiceWithSecretStore(failOnUseSecretStore{})
service.configDir = t.TempDir()
legacy := aiConfig{
@@ -205,26 +209,20 @@ func TestAISaveProviderKeepsLegacyPlaintextSecretAfterStartupLoad(t *testing.T)
t.Fatalf("expected runtime provider to keep legacy sensitive header, got %#v", service.providers[0].Headers)
}
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
t.Fatalf("GetAIProvider returned error: %v", err)
}
stored, err := store.Get(ref)
if err != nil {
t.Fatalf("expected save to persist provider secret bundle, got %v", err)
if !ok {
t.Fatal("expected provider secret to stay in daily store")
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if bundle.APIKey != "sk-test" {
t.Fatalf("expected persisted apiKey, got %q", bundle.APIKey)
if stored.APIKey != "sk-test" {
t.Fatalf("expected persisted apiKey, got %q", stored.APIKey)
}
}
func TestAITestProviderUsesLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service := NewServiceWithSecretStore(failOnUseSecretStore{})
service.configDir = t.TempDir()
legacy := aiConfig{
@@ -269,8 +267,7 @@ func TestAITestProviderUsesLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
}
func TestAISaveProviderPersistsSecretlessConfigAndReturnsSecretlessView(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service := NewServiceWithSecretStore(failOnUseSecretStore{})
service.configDir = t.TempDir()
err := service.AISaveProvider(ai.ProviderConfig{
@@ -323,8 +320,7 @@ func TestAISaveProviderPersistsSecretlessConfigAndReturnsSecretlessView(t *testi
}
func TestAISaveProviderKeepsExistingSecretWhenInputOmitsAPIKey(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service := NewServiceWithSecretStore(failOnUseSecretStore{})
service.configDir = t.TempDir()
if err := service.AISaveProvider(ai.ProviderConfig{
@@ -377,8 +373,7 @@ func TestAISaveProviderKeepsExistingSecretWhenInputOmitsAPIKey(t *testing.T) {
}
func TestAISaveProviderMergesStoredSensitiveHeadersWhenUpdatingOnlyAPIKey(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service := NewServiceWithSecretStore(failOnUseSecretStore{})
service.configDir = t.TempDir()
if err := service.AISaveProvider(ai.ProviderConfig{
@@ -416,19 +411,18 @@ func TestAISaveProviderMergesStoredSensitiveHeadersWhenUpdatingOnlyAPIKey(t *tes
t.Fatalf("expected existing sensitive header to be kept, got %#v", service.providers[0].Headers)
}
stored, err := store.Get(service.providers[0].SecretRef)
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
if err != nil {
t.Fatalf("expected merged secret bundle in store, got %v", err)
t.Fatalf("GetAIProvider returned error: %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
if !ok {
t.Fatal("expected merged secret bundle in daily store")
}
if bundle.APIKey != "sk-updated" {
t.Fatalf("expected store to keep updated apiKey, got %q", bundle.APIKey)
if stored.APIKey != "sk-updated" {
t.Fatalf("expected store to keep updated apiKey, got %q", stored.APIKey)
}
if bundle.SensitiveHeaders["Authorization"] != "Bearer original" {
t.Fatalf("expected store to keep existing sensitive header, got %#v", bundle.SensitiveHeaders)
if stored.SensitiveHeaders["Authorization"] != "Bearer original" {
t.Fatalf("expected store to keep existing sensitive header, got %#v", stored.SensitiveHeaders)
}
}
@@ -463,3 +457,23 @@ func (s *fakeProviderSecretStore) HealthCheck() error {
}
var _ secretstore.SecretStore = (*fakeProviderSecretStore)(nil)
type failOnUseSecretStore struct{}
func (s failOnUseSecretStore) Put(string, []byte) error {
return fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) Get(string) ([]byte, error) {
return nil, fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) Delete(string) error {
return fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) HealthCheck() error {
return fmt.Errorf("secret store should not be used")
}
var _ secretstore.SecretStore = (*failOnUseSecretStore)(nil)

View File

@@ -198,8 +198,8 @@ func (s *Service) AISaveProvider(config ai.ProviderConfig) error {
runtimeConfig = meta
}
if !runtimeConfig.HasSecret && found && strings.TrimSpace(existing.SecretRef) != "" {
if err := s.secretStore.Delete(existing.SecretRef); err != nil {
if !runtimeConfig.HasSecret && found {
if err := s.dailySecretStore().DeleteAIProvider(existing.ID); err != nil {
return fmt.Errorf("删除 Provider secret 失败: %w", err)
}
}
@@ -960,7 +960,7 @@ func (s *Service) getActiveProvider() (provider.Provider, error) {
// --- 配置持久化 ---
func (s *Service) loadConfig() {
snapshot, err := NewProviderConfigStore(s.configDir, s.secretStore).LoadRuntime()
snapshot, err := NewProviderConfigStore(s.configDir, s.secretStore).Load()
if err != nil {
logger.Error(err, "加载 AI 配置失败")
return

View File

@@ -55,17 +55,17 @@ type queryContext struct {
// App struct
type App struct {
ctx context.Context
startedAt time.Time
dbCache map[string]cachedDatabase // Cache for DB connections
ctx context.Context
startedAt time.Time
dbCache map[string]cachedDatabase // Cache for DB connections
connectFailures map[string]cachedConnectFailure
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
queryMu sync.RWMutex
configDir string
secretStore secretstore.SecretStore
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
queryMu sync.RWMutex
configDir string
secretStore secretstore.SecretStore
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
}
// NewApp creates a new App application struct
@@ -78,11 +78,11 @@ func NewAppWithSecretStore(store secretstore.SecretStore) *App {
store = secretstore.NewUnavailableStore("secret store unavailable")
}
return &App{
dbCache: make(map[string]cachedDatabase),
dbCache: make(map[string]cachedDatabase),
connectFailures: make(map[string]cachedConnectFailure),
runningQueries: make(map[string]queryContext),
configDir: resolveAppConfigDir(),
secretStore: store,
runningQueries: make(map[string]queryContext),
configDir: resolveAppConfigDir(),
secretStore: store,
}
}
@@ -101,7 +101,13 @@ func (a *App) startup(ctx context.Context) {
}
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(a.configDir))
logger.Init()
if err := migrateDailySecretsIfNeeded(a); err != nil {
logger.Warnf("迁移日常密文失败:%v", err)
}
a.loadPersistedGlobalProxy()
if err := migrateLegacyWebKitStorageIfNeeded(a); err != nil {
logger.Warnf("迁移旧 WebKit 连接存储失败:%v", err)
}
if shouldInstallMacNativeWindowDiagnostics() {
installMacNativeWindowDiagnostics(logger.Path())
}

View File

@@ -21,7 +21,7 @@ func newConnectionPackageItem(view connection.SavedConnectionView, bundle connec
IncludeRedisDatabases: cloneIntSlice(view.IncludeRedisDatabases),
IconType: view.IconType,
IconColor: view.IconColor,
Config: view.Config,
Config: stripConnectionSecretFields(view.Config),
Secrets: bundle,
}
}
@@ -229,7 +229,11 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
if err != nil {
return nil, err
}
return a.importConnectionPackagePayload(payload)
views, err := a.importConnectionPackagePayload(payload)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
if isConnectionPackageV2Protected(trimmed) {
@@ -241,7 +245,11 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
if err != nil {
return nil, err
}
return a.importConnectionPackagePayload(payload)
views, err := a.importConnectionPackagePayload(payload)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
if isConnectionPackageEnvelope(trimmed) {
@@ -253,7 +261,11 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
if err != nil {
return nil, err
}
return a.importConnectionPackagePayload(payload)
views, err := a.importConnectionPackagePayload(payload)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
var legacy []connection.LegacySavedConnection

View File

@@ -297,6 +297,8 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) {
}
func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T) {
withTestGOOS(t, "linux")
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
@@ -352,33 +354,33 @@ func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T)
}
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
if err == nil {
t.Fatal("expected ImportConnectionsPayload to return error")
if err != nil {
t.Fatalf("expected ImportConnectionsPayload to succeed without secret store, got %v", err)
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
if len(imported) != 2 {
t.Fatalf("expected 2 imported results, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected rollback to restore exactly 1 legacy connection, got %d", len(saved))
if len(saved) != 2 {
t.Fatalf("expected import to keep 2 legacy connections, got %d", len(saved))
}
if saved[0].ID != "legacy-1" || saved[0].Name != "Existing Legacy" {
t.Fatalf("expected rollback to restore original legacy metadata, got %#v", saved[0])
if saved[0].ID != "legacy-1" || saved[0].Name != "Imported Existing Legacy" {
t.Fatalf("expected updated legacy metadata, got %#v", saved[0])
}
if saved[0].Config.Host != "db.old.local" {
t.Fatalf("expected rollback to restore original legacy host, got %q", saved[0].Config.Host)
if saved[0].Config.Host != "db.new.local" {
t.Fatalf("expected import to update legacy host, got %q", saved[0].Config.Host)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "old-primary" {
t.Fatalf("expected rollback to restore original legacy password, got %q", resolved.Password)
if resolved.Password != "" {
t.Fatalf("expected legacy import without password to clear stored password, got %q", resolved.Password)
}
if _, err := store.Get(failRef); !os.IsNotExist(err) {
@@ -387,6 +389,8 @@ func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T)
}
func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T) {
withTestGOOS(t, "linux")
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
@@ -420,19 +424,19 @@ func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T)
},
},
})
if err == nil {
t.Fatal("expected ImportLegacyConnections to return error")
if err != nil {
t.Fatalf("expected ImportLegacyConnections to succeed without secret store, got %v", err)
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
if len(imported) != 2 {
t.Fatalf("expected 2 imported results after import, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 0 {
t.Fatalf("expected rollback to remove generated-id connection, got %d saved connections", len(saved))
if len(saved) != 2 {
t.Fatalf("expected imported connections to be persisted, got %d saved connections", len(saved))
}
if got := len(store.base.items); got != 0 {
@@ -444,6 +448,8 @@ func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T)
}
func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) {
withTestGOOS(t, "linux")
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "conn-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
@@ -497,33 +503,33 @@ func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) {
},
},
})
if err == nil {
t.Fatal("expected importConnectionPackagePayload to return error")
if err != nil {
t.Fatalf("expected importConnectionPackagePayload to succeed without secret store, got %v", err)
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
if len(imported) != 2 {
t.Fatalf("expected 2 imported results after import, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected rollback to restore exactly 1 connection, got %d", len(saved))
if len(saved) != 2 {
t.Fatalf("expected import to keep 2 connections, got %d", len(saved))
}
if saved[0].ID != "conn-1" || saved[0].Name != "Existing" {
t.Fatalf("expected rollback to restore original connection metadata, got %#v", saved[0])
if saved[0].ID != "conn-1" || saved[0].Name != "Imported Existing" {
t.Fatalf("expected imported connection metadata, got %#v", saved[0])
}
if saved[0].Config.Host != "db.old.local" {
t.Fatalf("expected rollback to restore original host, got %q", saved[0].Config.Host)
if saved[0].Config.Host != "db.new.local" {
t.Fatalf("expected import to update host, got %q", saved[0].Config.Host)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "old-primary" {
t.Fatalf("expected rollback to restore original primary password, got %q", resolved.Password)
if resolved.Password != "new-primary" {
t.Fatalf("expected import to update primary password, got %q", resolved.Password)
}
if _, err := store.Get(failRef); !os.IsNotExist(err) {

View File

@@ -103,6 +103,8 @@ func normalizeConnectionSecretResolutionError(config connection.ConnectionConfig
return fmt.Errorf("未找到已保存连接,可能已被删除,请刷新后重试")
}
return fmt.Errorf("未找到当前连接对应的已保存密文,请重新填写密码并保存后再试")
case errors.Is(err, os.ErrNotExist):
return fmt.Errorf("未找到当前连接对应的已保存密文,请重新填写密码并保存后再试")
case strings.Contains(lower, "secret store unavailable"):
return fmt.Errorf("系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试")
default:

View File

@@ -42,6 +42,44 @@ func TestResolveConnectionConfigByIDLoadsSecretsFromStore(t *testing.T) {
}
}
func TestResolveConnectionSecretsOnDarwinUsesInlineSavedSecrets(t *testing.T) {
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
if _, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-darwin-inline",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-darwin-inline",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "postgres-secret",
DSN: "postgres://user:pass@db.local/app",
},
}); err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{
ID: "conn-darwin-inline",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
})
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "postgres-secret" {
t.Fatalf("expected daily-stored password to be restored, got %q", resolved.Password)
}
if resolved.DSN != "postgres://user:pass@db.local/app" {
t.Fatalf("expected daily-stored DSN to be restored, got %q", resolved.DSN)
}
}
func TestResolveConnectionSecretsReturnsFriendlyMessageWhenSavedSecretSourceIsMissing(t *testing.T) {
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
@@ -90,6 +128,8 @@ func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedConnectionIsMi
}
func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedSecretBundleIsMissing(t *testing.T) {
withTestGOOS(t, "linux")
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
@@ -110,11 +150,11 @@ func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedSecretBundleIs
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
if view.SecretRef == "" {
t.Fatal("expected saved connection to allocate a secret ref")
if view.SecretRef != "" {
t.Fatalf("expected saved connection to avoid secret refs, got %q", view.SecretRef)
}
if err := store.Delete(view.SecretRef); err != nil {
t.Fatalf("Delete returned error: %v", err)
if err := app.dailySecretStore().DeleteConnection("conn-inline-fallback"); err != nil {
t.Fatalf("DeleteConnection returned error: %v", err)
}
resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{

View File

@@ -0,0 +1,223 @@
package app
import (
"os"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/secretstore"
)
func migrateDailySecretsIfNeeded(a *App) error {
return migrateDailySecretsIfNeededWithHome(a, os.UserHomeDir)
}
func migrateDarwinDailySecretsIfNeeded(a *App) error {
return migrateDailySecretsIfNeeded(a)
}
func migrateDailySecretsIfNeededWithHome(a *App, resolveHomeDir func() (string, error)) error {
if a == nil {
return nil
}
var legacy legacyWebKitVisibleConfig
if resolveHomeDir != nil {
homeDir, err := resolveHomeDir()
if err != nil {
return err
}
legacyConfig, _, err := findLegacyWebKitVisibleConfig(homeDir)
if err != nil {
return err
}
legacy = legacyConfig
}
repo := a.savedConnectionRepository()
if err := migrateSavedConnectionSecrets(repo, legacy); err != nil {
return err
}
return migrateGlobalProxySecret(a, legacy)
}
func migrateDarwinDailySecretsIfNeededWithHome(a *App, resolveHomeDir func() (string, error)) error {
return migrateDailySecretsIfNeededWithHome(a, resolveHomeDir)
}
func migrateSavedConnectionSecrets(repo *savedConnectionRepository, legacy legacyWebKitVisibleConfig) error {
if repo == nil {
return nil
}
items, err := repo.load()
if err != nil {
return err
}
changed := false
for index, item := range items {
bundle, found, err := repo.resolveMigrationConnectionBundle(item, legacy)
if err != nil {
return err
}
if found && bundle.hasAny() {
if err := repo.saveSecretBundle(item.ID, bundle); err != nil {
return err
}
normalized := item
normalized.Config = stripConnectionSecretFields(normalized.Config)
normalized.SecretRef = ""
applyConnectionBundleFlags(&normalized, bundle)
items[index] = normalized
changed = true
continue
}
inline := extractConnectionSecretBundle(item.Config)
if !inline.hasAny() && !savedConnectionViewHasSecrets(item) && strings.TrimSpace(item.SecretRef) == "" {
continue
}
if err := repo.deleteSecretBundle(item.ID); err != nil {
return err
}
item.Config = stripConnectionSecretFields(item.Config)
item.SecretRef = ""
applyConnectionBundleFlags(&item, connectionSecretBundle{})
items[index] = item
changed = true
logger.Warnf("日常连接密文未回填:连接=%s已停用旧系统密文引用请重新保存连接密码", strings.TrimSpace(item.ID))
}
if changed {
return repo.saveAll(items)
}
return nil
}
func (r *savedConnectionRepository) resolveMigrationConnectionBundle(view connection.SavedConnectionView, legacy legacyWebKitVisibleConfig) (connectionSecretBundle, bool, error) {
inline := extractConnectionSecretBundle(view.Config)
if inline.hasAny() {
return inline, true, nil
}
stored, ok, err := r.dailySecrets().GetConnection(view.ID)
if err != nil {
return connectionSecretBundle{}, false, err
}
if ok {
return fromDailyConnectionBundle(stored), true, nil
}
legacyBundle := findLegacyConnectionSecretBundle(legacy.Connections, view.ID)
if legacyBundle.hasAny() {
return legacyBundle, true, nil
}
if !shouldReadLegacySecretStoreForDailySecrets() {
return connectionSecretBundle{}, false, nil
}
if strings.TrimSpace(view.SecretRef) == "" {
return connectionSecretBundle{}, false, nil
}
bundle, err := r.loadSecretBundleFromStore(view)
if err == nil {
return bundle, true, nil
}
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
return connectionSecretBundle{}, false, nil
}
return connectionSecretBundle{}, false, err
}
func migrateGlobalProxySecret(a *App, legacy legacyWebKitVisibleConfig) error {
view, err := a.loadStoredGlobalProxyView()
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
bundle, found, err := a.resolveMigrationGlobalProxyBundle(view, legacy)
if err != nil {
return err
}
if found && strings.TrimSpace(bundle.Password) != "" {
if err := a.dailySecretStore().PutGlobalProxy(toDailyGlobalProxyBundle(bundle)); err != nil {
return err
}
normalized := view
normalized.Password = ""
normalized.SecretRef = ""
normalized.HasPassword = true
if normalized != view {
return a.persistGlobalProxyView(normalized)
}
return nil
}
inline := extractGlobalProxySecretBundle(view)
if !view.HasPassword && strings.TrimSpace(view.SecretRef) == "" && strings.TrimSpace(inline.Password) == "" {
return nil
}
if err := a.dailySecretStore().DeleteGlobalProxy(); err != nil {
return err
}
view.Password = ""
view.SecretRef = ""
view.HasPassword = false
logger.Warnf("日常全局代理密文未回填,已停用旧系统密文引用,如需继续使用请重新保存代理密码")
return a.persistGlobalProxyView(view)
}
func (a *App) resolveMigrationGlobalProxyBundle(view connection.GlobalProxyView, legacy legacyWebKitVisibleConfig) (globalProxySecretBundle, bool, error) {
inline := extractGlobalProxySecretBundle(view)
if strings.TrimSpace(inline.Password) != "" {
return inline, true, nil
}
stored, ok, err := a.dailySecretStore().GetGlobalProxy()
if err != nil {
return globalProxySecretBundle{}, false, err
}
if ok {
return fromDailyGlobalProxyBundle(stored), true, nil
}
if legacy.GlobalProxy != nil && strings.TrimSpace(legacy.GlobalProxy.Password) != "" {
return globalProxySecretBundle{Password: legacy.GlobalProxy.Password}, true, nil
}
if !shouldReadLegacySecretStoreForDailySecrets() {
return globalProxySecretBundle{}, false, nil
}
if strings.TrimSpace(view.SecretRef) == "" {
return globalProxySecretBundle{}, false, nil
}
bundle, err := a.loadGlobalProxySecretBundleFromStore(view)
if err == nil {
return bundle, true, nil
}
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
return globalProxySecretBundle{}, false, nil
}
return globalProxySecretBundle{}, false, err
}
func findLegacyConnectionSecretBundle(items []connection.LegacySavedConnection, id string) connectionSecretBundle {
targetID := strings.TrimSpace(id)
if targetID == "" {
return connectionSecretBundle{}
}
for _, item := range items {
if strings.TrimSpace(item.ID) != targetID {
continue
}
return extractConnectionSecretBundle(item.Config)
}
return connectionSecretBundle{}
}

View File

@@ -0,0 +1,107 @@
package app
import (
stdRuntime "runtime"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/dailysecret"
)
var runtimeGOOS = func() string {
return stdRuntime.GOOS
}
func extractConnectionSecretBundle(config connection.ConnectionConfig) connectionSecretBundle {
return connectionSecretBundle{
Password: config.Password,
SSHPassword: config.SSH.Password,
ProxyPassword: config.Proxy.Password,
HTTPTunnelPassword: config.HTTPTunnel.Password,
MySQLReplicaPassword: config.MySQLReplicaPassword,
MongoReplicaPassword: config.MongoReplicaPassword,
OpaqueURI: config.URI,
OpaqueDSN: config.DSN,
}
}
func toDailyConnectionBundle(bundle connectionSecretBundle) dailysecret.ConnectionBundle {
return dailysecret.ConnectionBundle{
Password: bundle.Password,
SSHPassword: bundle.SSHPassword,
ProxyPassword: bundle.ProxyPassword,
HTTPTunnelPassword: bundle.HTTPTunnelPassword,
MySQLReplicaPassword: bundle.MySQLReplicaPassword,
MongoReplicaPassword: bundle.MongoReplicaPassword,
OpaqueURI: bundle.OpaqueURI,
OpaqueDSN: bundle.OpaqueDSN,
}
}
func fromDailyConnectionBundle(bundle dailysecret.ConnectionBundle) connectionSecretBundle {
return connectionSecretBundle{
Password: bundle.Password,
SSHPassword: bundle.SSHPassword,
ProxyPassword: bundle.ProxyPassword,
HTTPTunnelPassword: bundle.HTTPTunnelPassword,
MySQLReplicaPassword: bundle.MySQLReplicaPassword,
MongoReplicaPassword: bundle.MongoReplicaPassword,
OpaqueURI: bundle.OpaqueURI,
OpaqueDSN: bundle.OpaqueDSN,
}
}
func stripConnectionSecretFields(config connection.ConnectionConfig) connection.ConnectionConfig {
stripped := config
stripped.Password = ""
stripped.SSH.Password = ""
stripped.Proxy.Password = ""
stripped.HTTPTunnel.Password = ""
stripped.MySQLReplicaPassword = ""
stripped.MongoReplicaPassword = ""
stripped.URI = ""
stripped.DSN = ""
return stripped
}
func sanitizeSavedConnectionView(view connection.SavedConnectionView) connection.SavedConnectionView {
view.Config = stripConnectionSecretFields(view.Config)
return view
}
func sanitizeSavedConnectionViews(items []connection.SavedConnectionView) []connection.SavedConnectionView {
if len(items) == 0 {
return items
}
result := make([]connection.SavedConnectionView, 0, len(items))
for _, item := range items {
result = append(result, sanitizeSavedConnectionView(item))
}
return result
}
func extractGlobalProxySecretBundle(view connection.GlobalProxyView) globalProxySecretBundle {
return globalProxySecretBundle{
Password: view.Password,
}
}
func toDailyGlobalProxyBundle(bundle globalProxySecretBundle) dailysecret.GlobalProxyBundle {
return dailysecret.GlobalProxyBundle{Password: bundle.Password}
}
func fromDailyGlobalProxyBundle(bundle dailysecret.GlobalProxyBundle) globalProxySecretBundle {
return globalProxySecretBundle{Password: bundle.Password}
}
func sanitizeGlobalProxyView(view connection.GlobalProxyView) connection.GlobalProxyView {
view.Password = ""
return view
}
func shouldReadLegacySecretStoreForDailySecrets() bool {
return runtimeGOOS() != "darwin"
}
func (a *App) dailySecretStore() *dailysecret.Store {
return dailysecret.NewStore(a.configDir)
}

View File

@@ -0,0 +1,210 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
func TestMigrateDarwinDailySecretsIfNeededMovesConnectionSecretsInline(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked"))
app.configDir = t.TempDir()
homeDir := t.TempDir()
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
Connections: []connection.LegacySavedConnection{
{
ID: "conn-legacy",
Name: "Legacy",
Config: connection.ConnectionConfig{
ID: "conn-legacy",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "postgres-secret",
DSN: "postgres://user:pass@db.local/app",
},
},
},
})
repo := app.savedConnectionRepository()
if err := repo.saveAll([]connection.SavedConnectionView{
{
ID: "conn-legacy",
Name: "Legacy",
Config: connection.ConnectionConfig{
ID: "conn-legacy",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
SecretRef: "oskeyring://gonavi/connection/conn-legacy",
HasPrimaryPassword: true,
HasOpaqueDSN: true,
},
}); err != nil {
t.Fatalf("saveAll returned error: %v", err)
}
if err := migrateDarwinDailySecretsIfNeededWithHome(app, func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateDarwinDailySecretsIfNeededWithHome returned error: %v", err)
}
raw, err := repo.Find("conn-legacy")
if err != nil {
t.Fatalf("Find returned error: %v", err)
}
if raw.Config.Password != "" {
t.Fatalf("expected migrated connection metadata to stay secretless, got %q", raw.Config.Password)
}
if raw.Config.DSN != "" {
t.Fatalf("expected migrated connection metadata to stay secretless, got %q", raw.Config.DSN)
}
if raw.SecretRef != "" {
t.Fatalf("expected migrated connection to clear SecretRef, got %q", raw.SecretRef)
}
stored, ok, err := app.dailySecretStore().GetConnection("conn-legacy")
if err != nil {
t.Fatalf("GetConnection returned error: %v", err)
}
if !ok {
t.Fatal("expected migrated connection secret in daily secret store")
}
if stored.Password != "postgres-secret" || stored.OpaqueDSN != "postgres://user:pass@db.local/app" {
t.Fatalf("unexpected migrated bundle: %#v", stored)
}
}
func TestMigrateDarwinDailySecretsIfNeededMovesGlobalProxyPasswordInline(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked"))
app.configDir = t.TempDir()
homeDir := t.TempDir()
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
GlobalProxy: &connection.LegacyGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
},
})
if err := app.persistGlobalProxyView(connection.GlobalProxyView{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
SecretRef: "oskeyring://gonavi/global-proxy/default",
HasPassword: true,
}); err != nil {
t.Fatalf("persistGlobalProxyView returned error: %v", err)
}
if err := migrateDarwinDailySecretsIfNeededWithHome(app, func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateDarwinDailySecretsIfNeededWithHome returned error: %v", err)
}
stored, err := app.loadStoredGlobalProxyView()
if err != nil {
t.Fatalf("loadStoredGlobalProxyView returned error: %v", err)
}
if stored.Password != "" {
t.Fatalf("expected migrated global proxy metadata to stay secretless, got %q", stored.Password)
}
if stored.SecretRef != "" {
t.Fatalf("expected migrated global proxy to clear SecretRef, got %q", stored.SecretRef)
}
secret, ok, err := app.dailySecretStore().GetGlobalProxy()
if err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
}
if !ok || secret.Password != "proxy-secret" {
t.Fatalf("unexpected migrated global proxy secret: %#v ok=%v", secret, ok)
}
}
func TestMigrateDarwinDailySecretsIfNeededClearsLegacyRefsWhenNoWebKitSecretAvailable(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked"))
app.configDir = t.TempDir()
homeDir := t.TempDir()
repo := app.savedConnectionRepository()
if err := repo.saveAll([]connection.SavedConnectionView{
{
ID: "conn-missing",
Name: "Missing",
Config: connection.ConnectionConfig{
ID: "conn-missing",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
SecretRef: "oskeyring://gonavi/connection/conn-missing",
HasPrimaryPassword: true,
},
}); err != nil {
t.Fatalf("saveAll returned error: %v", err)
}
if err := app.persistGlobalProxyView(connection.GlobalProxyView{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
SecretRef: "oskeyring://gonavi/global-proxy/default",
HasPassword: true,
}); err != nil {
t.Fatalf("persistGlobalProxyView returned error: %v", err)
}
if err := migrateDarwinDailySecretsIfNeededWithHome(app, func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateDarwinDailySecretsIfNeededWithHome returned error: %v", err)
}
raw, err := repo.Find("conn-missing")
if err != nil {
t.Fatalf("Find returned error: %v", err)
}
if raw.SecretRef != "" || raw.HasPrimaryPassword {
t.Fatalf("expected missing legacy secret ref to be cleared, got %#v", raw)
}
stored, err := app.loadStoredGlobalProxyView()
if err != nil {
t.Fatalf("loadStoredGlobalProxyView returned error: %v", err)
}
if stored.SecretRef != "" || stored.HasPassword {
t.Fatalf("expected missing legacy proxy secret ref to be cleared, got %#v", stored)
}
if _, ok, err := app.dailySecretStore().GetConnection("conn-missing"); err != nil {
t.Fatalf("GetConnection returned error: %v", err)
} else if ok {
t.Fatal("expected no migrated connection secret when WebKit data is missing")
}
if _, ok, err := app.dailySecretStore().GetGlobalProxy(); err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
} else if ok {
t.Fatal("expected no migrated global proxy secret when WebKit data is missing")
}
}

View File

@@ -53,14 +53,11 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
return connection.GlobalProxyView{}, loadErr
}
bundle = existingBundle
view.SecretRef = existing.SecretRef
}
if !view.Enabled {
if strings.TrimSpace(existing.SecretRef) != "" && a.secretStore != nil {
if deleteErr := a.secretStore.Delete(existing.SecretRef); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
if deleteErr := a.dailySecretStore().DeleteGlobalProxy(); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
view = connection.GlobalProxyView{Enabled: false}
if err := a.persistGlobalProxyView(view); err != nil {
@@ -73,21 +70,18 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
}
if strings.TrimSpace(bundle.Password) != "" {
ref, storeErr := a.storeGlobalProxySecret(view.SecretRef, bundle)
if storeErr != nil {
if storeErr := a.dailySecretStore().PutGlobalProxy(toDailyGlobalProxyBundle(bundle)); storeErr != nil {
return connection.GlobalProxyView{}, storeErr
}
view.SecretRef = ref
view.HasPassword = true
} else {
if strings.TrimSpace(existing.SecretRef) != "" && a.secretStore != nil {
if deleteErr := a.secretStore.Delete(existing.SecretRef); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
if deleteErr := a.dailySecretStore().DeleteGlobalProxy(); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
view.SecretRef = ""
view.HasPassword = false
}
view.SecretRef = ""
view.Password = ""
if err := a.persistGlobalProxyView(view); err != nil {
return connection.GlobalProxyView{}, err
@@ -101,8 +95,7 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
}); err != nil {
return connection.GlobalProxyView{}, err
}
view.Password = ""
return view, nil
return sanitizeGlobalProxyView(view), nil
}
func (a *App) persistGlobalProxyView(view connection.GlobalProxyView) error {
@@ -129,9 +122,24 @@ func (a *App) loadStoredGlobalProxyView() (connection.GlobalProxyView, error) {
}
func (a *App) loadGlobalProxySecretBundle(view connection.GlobalProxyView) (globalProxySecretBundle, error) {
inline := extractGlobalProxySecretBundle(view)
if strings.TrimSpace(inline.Password) != "" {
return inline, nil
}
if !view.HasPassword {
return globalProxySecretBundle{}, nil
}
bundle, ok, err := a.dailySecretStore().GetGlobalProxy()
if err != nil {
return globalProxySecretBundle{}, err
}
if ok {
return fromDailyGlobalProxyBundle(bundle), nil
}
return globalProxySecretBundle{}, os.ErrNotExist
}
func (a *App) loadGlobalProxySecretBundleFromStore(view connection.GlobalProxyView) (globalProxySecretBundle, error) {
if a.secretStore == nil {
return globalProxySecretBundle{}, fmt.Errorf("secret store unavailable")
}

View File

@@ -64,3 +64,35 @@ func TestGetGlobalProxyConfigReturnsSecretlessView(t *testing.T) {
}
}
func TestLoadPersistedGlobalProxyOnDarwinUsesInlinePassword(t *testing.T) {
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
t.Fatalf("setGlobalProxyConfig returned error: %v", err)
}
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
}); err != nil {
t.Fatalf("saveGlobalProxy returned error: %v", err)
}
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
t.Fatalf("setGlobalProxyConfig reset returned error: %v", err)
}
app.loadPersistedGlobalProxy()
snapshot := currentGlobalProxyConfig()
if !snapshot.Enabled {
t.Fatal("expected persisted global proxy to be restored")
}
if snapshot.Proxy.Password != "proxy-secret" {
t.Fatalf("expected daily-stored global proxy password to be restored, got %q", snapshot.Proxy.Password)
}
}

View File

@@ -0,0 +1,181 @@
package app
import (
"bytes"
"context"
"database/sql"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"path/filepath"
stdRuntime "runtime"
"strings"
"unicode/utf16"
"unicode/utf8"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
_ "modernc.org/sqlite"
)
const legacyPersistKey = "lite-db-storage"
var legacyWebKitBundleIDs = []string{
"com.wails.GoNavi",
"com.wails.GoNavi-Wails",
}
type legacyWebKitVisibleConfig struct {
Connections []connection.LegacySavedConnection
GlobalProxy *connection.LegacyGlobalProxyInput
}
func currentBuildType(ctx context.Context) string {
if ctx == nil {
return ""
}
buildType := ctx.Value("buildtype")
if value, ok := buildType.(string); ok {
return strings.TrimSpace(value)
}
return ""
}
func shouldAttemptLegacyWebKitStorageMigration(buildType string) bool {
return stdRuntime.GOOS == "darwin" && strings.EqualFold(strings.TrimSpace(buildType), "dev")
}
func migrateLegacyWebKitStorageIfNeeded(a *App) error {
return migrateLegacyWebKitStorageIfNeededWithHome(a, currentBuildType(a.ctx), os.UserHomeDir)
}
func migrateLegacyWebKitStorageIfNeededWithHome(a *App, buildType string, resolveHomeDir func() (string, error)) error {
if a == nil || !shouldAttemptLegacyWebKitStorageMigration(buildType) {
return nil
}
repo := a.savedConnectionRepository()
if _, err := os.Stat(repo.connectionsPath()); err == nil {
return nil
} else if err != nil && !os.IsNotExist(err) {
return err
}
homeDir, err := resolveHomeDir()
if err != nil {
return err
}
legacy, sourcePath, err := findLegacyWebKitVisibleConfig(homeDir)
if err != nil {
return err
}
if len(legacy.Connections) == 0 && legacy.GlobalProxy == nil {
return nil
}
if len(legacy.Connections) > 0 {
if _, err := a.ImportLegacyConnections(legacy.Connections); err != nil {
return err
}
}
if legacy.GlobalProxy != nil {
if _, err := os.Stat(globalProxyMetadataPath(a.configDir)); os.IsNotExist(err) {
if _, err := a.ImportLegacyGlobalProxy(*legacy.GlobalProxy); err != nil {
return err
}
} else if err != nil {
return err
}
}
logger.Infof("已从旧 WebKit 本地存储迁移 %d 条连接source=%s", len(legacy.Connections), sourcePath)
return nil
}
func findLegacyWebKitVisibleConfig(homeDir string) (legacyWebKitVisibleConfig, string, error) {
var best legacyWebKitVisibleConfig
var bestPath string
bestScore := -1
for _, bundleID := range legacyWebKitBundleIDs {
pattern := filepath.Join(homeDir, "Library", "WebKit", bundleID, "WebsiteData", "Default", "*", "*", "LocalStorage", "localstorage.sqlite3")
matches, err := filepath.Glob(pattern)
if err != nil {
return legacyWebKitVisibleConfig{}, "", err
}
for _, dbPath := range matches {
current, err := readLegacyWebKitVisibleConfig(dbPath)
if err != nil {
continue
}
score := len(current.Connections) * 10
if current.GlobalProxy != nil {
score++
}
if score > bestScore {
best = current
bestPath = dbPath
bestScore = score
}
}
}
if bestScore < 0 {
return legacyWebKitVisibleConfig{}, "", nil
}
return best, bestPath, nil
}
func readLegacyWebKitVisibleConfig(dbPath string) (legacyWebKitVisibleConfig, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return legacyWebKitVisibleConfig{}, err
}
defer db.Close()
var raw []byte
if err := db.QueryRow(`SELECT CAST(value AS BLOB) FROM ItemTable WHERE key = ?`, legacyPersistKey).Scan(&raw); err != nil {
if err == sql.ErrNoRows {
return legacyWebKitVisibleConfig{}, nil
}
return legacyWebKitVisibleConfig{}, err
}
payload := decodeLegacyWebKitJSON(raw)
if strings.TrimSpace(payload) == "" {
return legacyWebKitVisibleConfig{}, nil
}
var envelope struct {
State legacyWebKitVisibleConfig `json:"state"`
}
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return legacyWebKitVisibleConfig{}, fmt.Errorf("parse legacy webkit storage %s: %w", dbPath, err)
}
return envelope.State, nil
}
func decodeLegacyWebKitJSON(raw []byte) string {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return ""
}
if utf8.Valid(trimmed) && !bytes.Contains(trimmed, []byte{0x00}) {
return string(trimmed)
}
if len(trimmed)%2 == 0 {
u16 := make([]uint16, 0, len(trimmed)/2)
for i := 0; i < len(trimmed); i += 2 {
u16 = append(u16, binary.LittleEndian.Uint16(trimmed[i:i+2]))
}
decoded := strings.TrimRight(string(utf16.Decode(u16)), "\x00")
if utf8.ValidString(decoded) {
return strings.TrimSpace(decoded)
}
}
return strings.TrimSpace(string(trimmed))
}

View File

@@ -0,0 +1,183 @@
package app
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"testing"
"GoNavi-Wails/internal/connection"
_ "modernc.org/sqlite"
)
func TestMigrateLegacyWebKitStorageIfNeededImportsConnectionsForDevBuild(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
homeDir := t.TempDir()
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
Connections: []connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Legacy One",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "secret-1",
},
},
},
GlobalProxy: &connection.LegacyGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
},
})
if err := migrateLegacyWebKitStorageIfNeededWithHome(app, "dev", func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateLegacyWebKitStorageIfNeededWithHome returned error: %v", err)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected 1 saved connection, got %d", len(saved))
}
if saved[0].Name != "Legacy One" {
t.Fatalf("expected imported connection name to be preserved, got %q", saved[0].Name)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "secret-1" {
t.Fatalf("expected imported primary password, got %q", resolved.Password)
}
view := app.GetGlobalProxyConfig()
if !view.Success {
t.Fatalf("expected GetGlobalProxyConfig success, got %#v", view)
}
proxy, ok := view.Data.(connection.GlobalProxyView)
if !ok {
t.Fatalf("expected GlobalProxyView payload, got %#v", view.Data)
}
if proxy.Host != "127.0.0.1" || !proxy.HasPassword {
t.Fatalf("expected imported global proxy to be restored, got %#v", proxy)
}
}
func TestMigrateLegacyWebKitStorageIfNeededSkipsWhenConnectionsFileAlreadyExists(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
homeDir := t.TempDir()
if _, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "current-1",
Name: "Current",
Config: connection.ConnectionConfig{
ID: "current-1",
Type: "postgres",
Host: "current.local",
Port: 5432,
User: "postgres",
},
}); err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
Connections: []connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Legacy One",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "legacy.local",
Port: 5432,
User: "postgres",
},
},
},
})
if err := migrateLegacyWebKitStorageIfNeededWithHome(app, "dev", func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateLegacyWebKitStorageIfNeededWithHome returned error: %v", err)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected existing connections to remain unchanged, got %d", len(saved))
}
if saved[0].Name != "Current" {
t.Fatalf("expected migration to skip existing repository, got %q", saved[0].Name)
}
}
type legacyWebKitStoragePayload struct {
Connections []connection.LegacySavedConnection `json:"connections"`
GlobalProxy *connection.LegacyGlobalProxyInput `json:"globalProxy,omitempty"`
}
func writeLegacyWebKitStorage(t *testing.T, homeDir string, bundleID string, payload legacyWebKitStoragePayload) {
t.Helper()
dbPath := filepath.Join(
homeDir,
"Library",
"WebKit",
bundleID,
"WebsiteData",
"Default",
"test-origin",
"test-origin",
"LocalStorage",
"localstorage.sqlite3",
)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("sql.Open returned error: %v", err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`); err != nil {
t.Fatalf("CREATE TABLE returned error: %v", err)
}
raw, err := json.Marshal(map[string]any{
"state": map[string]any{
"connections": payload.Connections,
"globalProxy": payload.GlobalProxy,
},
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
if _, err := db.Exec(`INSERT INTO ItemTable(key, value) VALUES(?, ?)`, "lite-db-storage", string(raw)); err != nil {
t.Fatalf("INSERT returned error: %v", err)
}
}

View File

@@ -11,11 +11,19 @@ func (a *App) savedConnectionRepository() *savedConnectionRepository {
}
func (a *App) GetSavedConnections() ([]connection.SavedConnectionView, error) {
return a.savedConnectionRepository().List()
items, err := a.savedConnectionRepository().List()
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(items), nil
}
func (a *App) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) {
return a.savedConnectionRepository().Save(input)
view, err := a.savedConnectionRepository().Save(input)
if err != nil {
return connection.SavedConnectionView{}, err
}
return sanitizeSavedConnectionView(view), nil
}
func (a *App) DeleteConnection(id string) error {
@@ -23,7 +31,11 @@ func (a *App) DeleteConnection(id string) error {
}
func (a *App) DuplicateConnection(id string) (connection.SavedConnectionView, error) {
return a.savedConnectionRepository().Duplicate(id)
view, err := a.savedConnectionRepository().Duplicate(id)
if err != nil {
return connection.SavedConnectionView{}, err
}
return sanitizeSavedConnectionView(view), nil
}
func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) {
@@ -40,7 +52,11 @@ func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection)
input.ClearOpaqueDSN = strings.TrimSpace(item.Config.DSN) == ""
inputs = append(inputs, input)
}
return a.importSavedConnectionsAtomically(inputs)
views, err := a.importSavedConnectionsAtomically(inputs)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
func (a *App) SaveGlobalProxy(input connection.SaveGlobalProxyInput) (connection.GlobalProxyView, error) {

View File

@@ -7,6 +7,17 @@ import (
"GoNavi-Wails/internal/connection"
)
func withTestGOOS(t *testing.T, goos string) {
t.Helper()
previous := runtimeGOOS
runtimeGOOS = func() string {
return goos
}
t.Cleanup(func() {
runtimeGOOS = previous
})
}
func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -43,6 +54,68 @@ func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) {
}
}
func TestSaveConnectionOnDarwinPersistsSecretsInlineButReturnsSecretlessView(t *testing.T) {
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
result, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-darwin",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-darwin",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "postgres-secret",
DSN: "postgres://user:pass@db.local/app",
},
})
if err != nil {
t.Fatal(err)
}
if result.Config.Password != "" {
t.Fatal("SaveConnection must keep macOS return value secretless")
}
if result.Config.DSN != "" {
t.Fatal("SaveConnection must not return plaintext DSN")
}
if result.SecretRef != "" {
t.Fatalf("expected macOS inline persistence to avoid secret refs, got %q", result.SecretRef)
}
if !result.HasPrimaryPassword || !result.HasOpaqueDSN {
t.Fatalf("expected secret flags to stay true, got %#v", result)
}
raw, err := app.savedConnectionRepository().Find("conn-darwin")
if err != nil {
t.Fatal(err)
}
if raw.Config.Password != "" {
t.Fatalf("expected raw saved connection metadata to stay secretless, got %q", raw.Config.Password)
}
if raw.Config.DSN != "" {
t.Fatalf("expected raw saved connection metadata to stay secretless, got %q", raw.Config.DSN)
}
if raw.SecretRef != "" {
t.Fatalf("expected raw saved connection to avoid secret refs, got %q", raw.SecretRef)
}
stored, ok, err := app.dailySecretStore().GetConnection("conn-darwin")
if err != nil {
t.Fatalf("GetConnection returned error: %v", err)
}
if !ok {
t.Fatal("expected daily secret store to keep saved connection secret")
}
if stored.Password != "postgres-secret" {
t.Fatalf("expected daily secret store to persist password, got %q", stored.Password)
}
if stored.OpaqueDSN != "postgres://user:pass@db.local/app" {
t.Fatalf("expected daily secret store to persist DSN, got %q", stored.OpaqueDSN)
}
}
func TestSaveConnectionClearsRequestedSecretFields(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -186,6 +259,54 @@ func TestSaveGlobalProxyReturnsSecretlessView(t *testing.T) {
}
}
func TestSaveGlobalProxyOnDarwinPersistsPasswordInlineButReturnsSecretlessView(t *testing.T) {
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
view, err := app.SaveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
})
if err != nil {
t.Fatal(err)
}
if view.Password != "" {
t.Fatal("SaveGlobalProxy must not expose plaintext password")
}
if !view.HasPassword {
t.Fatal("expected hasPassword=true")
}
if view.SecretRef != "" {
t.Fatalf("expected proxy persistence to avoid secret refs, got %q", view.SecretRef)
}
stored, err := app.loadStoredGlobalProxyView()
if err != nil {
t.Fatal(err)
}
if stored.Password != "" {
t.Fatalf("expected stored global proxy metadata to stay secretless, got %q", stored.Password)
}
if stored.SecretRef != "" {
t.Fatalf("expected stored global proxy to avoid secret refs, got %q", stored.SecretRef)
}
proxySecret, ok, err := app.dailySecretStore().GetGlobalProxy()
if err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
}
if !ok {
t.Fatal("expected daily secret store to keep proxy password")
}
if proxySecret.Password != "proxy-secret" {
t.Fatalf("expected daily secret store to persist proxy password, got %q", proxySecret.Password)
}
}
func TestImportLegacyConnectionsIsIdempotentForSameID(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()

View File

@@ -9,6 +9,7 @@ import (
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/dailysecret"
"GoNavi-Wails/internal/secretstore"
"github.com/google/uuid"
)
@@ -149,39 +150,8 @@ func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.S
meta.ID = id
meta.SavePassword = false
bundle := connectionSecretBundle{}
if strings.TrimSpace(meta.Password) != "" {
bundle.Password = meta.Password
meta.Password = ""
}
if strings.TrimSpace(meta.SSH.Password) != "" {
bundle.SSHPassword = meta.SSH.Password
meta.SSH.Password = ""
}
if strings.TrimSpace(meta.Proxy.Password) != "" {
bundle.ProxyPassword = meta.Proxy.Password
meta.Proxy.Password = ""
}
if strings.TrimSpace(meta.HTTPTunnel.Password) != "" {
bundle.HTTPTunnelPassword = meta.HTTPTunnel.Password
meta.HTTPTunnel.Password = ""
}
if strings.TrimSpace(meta.MySQLReplicaPassword) != "" {
bundle.MySQLReplicaPassword = meta.MySQLReplicaPassword
meta.MySQLReplicaPassword = ""
}
if strings.TrimSpace(meta.MongoReplicaPassword) != "" {
bundle.MongoReplicaPassword = meta.MongoReplicaPassword
meta.MongoReplicaPassword = ""
}
if strings.TrimSpace(meta.URI) != "" {
bundle.OpaqueURI = meta.URI
meta.URI = ""
}
if strings.TrimSpace(meta.DSN) != "" {
bundle.OpaqueDSN = meta.DSN
meta.DSN = ""
}
bundle := extractConnectionSecretBundle(meta)
meta = stripConnectionSecretFields(meta)
view := connection.SavedConnectionView{
ID: id,
@@ -207,6 +177,10 @@ func (r *savedConnectionRepository) connectionsPath() string {
return filepath.Join(r.configDir, savedConnectionsFileName)
}
func (r *savedConnectionRepository) dailySecrets() *dailysecret.Store {
return dailysecret.NewStore(r.configDir)
}
func (r *savedConnectionRepository) load() ([]connection.SavedConnectionView, error) {
data, err := os.ReadFile(r.connectionsPath())
if err != nil {
@@ -269,26 +243,20 @@ func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput)
return connection.SavedConnectionView{}, bundleErr
}
mergedBundle = mergeConnectionSecretBundles(existingBundle, bundle)
view.SecretRef = existing.SecretRef
}
mergedBundle = applyConnectionSecretClears(mergedBundle, input)
if mergedBundle.hasAny() {
ref, storeErr := r.storeSecretBundle(view.ID, view.SecretRef, mergedBundle)
if storeErr != nil {
if storeErr := r.saveSecretBundle(view.ID, mergedBundle); storeErr != nil {
return connection.SavedConnectionView{}, storeErr
}
view.SecretRef = ref
applyConnectionBundleFlags(&view, mergedBundle)
} else {
if index >= 0 && strings.TrimSpace(existing.SecretRef) != "" {
if deleteErr := r.secretStore.Delete(existing.SecretRef); deleteErr != nil {
return connection.SavedConnectionView{}, deleteErr
}
if deleteErr := r.deleteSecretBundle(view.ID); deleteErr != nil {
return connection.SavedConnectionView{}, deleteErr
}
view.SecretRef = ""
applyConnectionBundleFlags(&view, connectionSecretBundle{})
}
view.SecretRef = ""
applyConnectionBundleFlags(&view, mergedBundle)
if index >= 0 {
connections[index] = view
@@ -314,6 +282,14 @@ func (r *savedConnectionRepository) Find(id string) (connection.SavedConnectionV
return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id)
}
func (r *savedConnectionRepository) saveSecretBundle(id string, bundle connectionSecretBundle) error {
return r.dailySecrets().PutConnection(id, toDailyConnectionBundle(bundle))
}
func (r *savedConnectionRepository) deleteSecretBundle(id string) error {
return r.dailySecrets().DeleteConnection(id)
}
func (r *savedConnectionRepository) storeSecretBundle(id string, existingRef string, bundle connectionSecretBundle) (string, error) {
if r.secretStore == nil {
return "", fmt.Errorf("secret store unavailable")
@@ -340,9 +316,24 @@ func (r *savedConnectionRepository) storeSecretBundle(id string, existingRef str
}
func (r *savedConnectionRepository) loadSecretBundle(view connection.SavedConnectionView) (connectionSecretBundle, error) {
inline := extractConnectionSecretBundle(view.Config)
if inline.hasAny() {
return inline, nil
}
if !savedConnectionViewHasSecrets(view) {
return connectionSecretBundle{}, nil
}
bundle, ok, err := r.dailySecrets().GetConnection(view.ID)
if err != nil {
return connectionSecretBundle{}, err
}
if ok {
return fromDailyConnectionBundle(bundle), nil
}
return connectionSecretBundle{}, os.ErrNotExist
}
func (r *savedConnectionRepository) loadSecretBundleFromStore(view connection.SavedConnectionView) (connectionSecretBundle, error) {
if r.secretStore == nil {
return connectionSecretBundle{}, fmt.Errorf("secret store unavailable")
}
@@ -414,10 +405,8 @@ func (r *savedConnectionRepository) Delete(id string) error {
filtered := make([]connection.SavedConnectionView, 0, len(connections))
for _, item := range connections {
if item.ID == strings.TrimSpace(id) {
if strings.TrimSpace(item.SecretRef) != "" && r.secretStore != nil {
if deleteErr := r.secretStore.Delete(item.SecretRef); deleteErr != nil {
return deleteErr
}
if deleteErr := r.deleteSecretBundle(item.ID); deleteErr != nil {
return deleteErr
}
continue
}
@@ -454,16 +443,12 @@ func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnec
return connection.SavedConnectionView{}, err
}
if bundle.hasAny() {
ref, storeErr := r.storeSecretBundle(duplicate.ID, "", bundle)
if storeErr != nil {
if storeErr := r.saveSecretBundle(duplicate.ID, bundle); storeErr != nil {
return connection.SavedConnectionView{}, storeErr
}
duplicate.SecretRef = ref
applyConnectionBundleFlags(&duplicate, bundle)
} else {
duplicate.SecretRef = ""
applyConnectionBundleFlags(&duplicate, connectionSecretBundle{})
}
duplicate.SecretRef = ""
applyConnectionBundleFlags(&duplicate, bundle)
connections = append(connections, duplicate)
if err := r.saveAll(connections); err != nil {

View File

@@ -1,6 +1,7 @@
package app
import (
"fmt"
"os"
"testing"
@@ -9,6 +10,8 @@ import (
)
func TestSplitConnectionSecretsStripsPasswordsAndOpaqueDSN(t *testing.T) {
withTestGOOS(t, "linux")
input := connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
@@ -70,3 +73,23 @@ func (s *fakeAppSecretStore) HealthCheck() error {
}
var _ secretstore.SecretStore = (*fakeAppSecretStore)(nil)
type failOnUseSecretStore struct{}
func (s failOnUseSecretStore) Put(string, []byte) error {
return fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) Get(string) ([]byte, error) {
return nil, fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) Delete(string) error {
return fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) HealthCheck() error {
return fmt.Errorf("secret store should not be used")
}
var _ secretstore.SecretStore = (*failOnUseSecretStore)(nil)

View File

@@ -1,8 +1,8 @@
package app
import (
"errors"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
@@ -196,11 +196,8 @@ func TestRetrySecurityUpdateCurrentRoundReusesMigrationIDAfterPendingIssueIsFixe
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if initial.OverallStatus != SecurityUpdateOverallStatusNeedsAttention {
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
}
if len(initial.Issues) != 1 || initial.Issues[0].Scope != SecurityUpdateIssueScopeAIProvider {
t.Fatalf("expected AI provider issue, got %#v", initial.Issues)
if initial.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", initial.OverallStatus)
}
if err := store.Put(ref, []byte(`{"apiKey":"sk-fixed","sensitiveHeaders":{"Authorization":"Bearer fixed"}}`)); err != nil {
@@ -210,14 +207,11 @@ func TestRetrySecurityUpdateCurrentRoundReusesMigrationIDAfterPendingIssueIsFixe
retried, err := app.RetrySecurityUpdateCurrentRound(RetrySecurityUpdateRequest{
MigrationID: initial.MigrationID,
})
if err != nil {
t.Fatalf("RetrySecurityUpdateCurrentRound returned error: %v", err)
if err == nil {
t.Fatalf("expected retry to be rejected after completed round, got %#v", retried)
}
if retried.MigrationID != initial.MigrationID {
t.Fatalf("expected retry to reuse migration ID %q, got %q", initial.MigrationID, retried.MigrationID)
}
if retried.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status after retry, got %q", retried.OverallStatus)
if !strings.Contains(err.Error(), "requires status needs_attention") {
t.Fatalf("expected completed round retry rejection, got %v", err)
}
}
@@ -250,8 +244,8 @@ func TestRetrySecurityUpdateCurrentRoundDoesNotReimportBrokenLegacySourceAfterUs
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if initial.OverallStatus != SecurityUpdateOverallStatusNeedsAttention {
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
if initial.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", initial.OverallStatus)
}
if _, err := app.SaveConnection(connection.SavedConnectionInput{
@@ -276,11 +270,11 @@ func TestRetrySecurityUpdateCurrentRoundDoesNotReimportBrokenLegacySourceAfterUs
retried, err := app.RetrySecurityUpdateCurrentRound(RetrySecurityUpdateRequest{
MigrationID: initial.MigrationID,
})
if err != nil {
t.Fatalf("RetrySecurityUpdateCurrentRound returned error: %v", err)
if err == nil {
t.Fatalf("expected retry to be rejected after completed round, got %#v", retried)
}
if retried.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status after retry, got %q", retried.OverallStatus)
if !strings.Contains(err.Error(), "requires status needs_attention") {
t.Fatalf("expected completed round retry rejection, got %v", err)
}
savedConnections, err := app.GetSavedConnections()
@@ -372,16 +366,16 @@ func TestDismissSecurityUpdateReminderKeepsCurrentRoundContext(t *testing.T) {
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if initial.OverallStatus != SecurityUpdateOverallStatusNeedsAttention {
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
if initial.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", initial.OverallStatus)
}
postponed, err := app.DismissSecurityUpdateReminder()
if err != nil {
t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err)
}
if postponed.OverallStatus != SecurityUpdateOverallStatusPostponed {
t.Fatalf("expected postponed status, got %q", postponed.OverallStatus)
if postponed.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status to be preserved, got %q", postponed.OverallStatus)
}
if postponed.MigrationID != initial.MigrationID {
t.Fatalf("expected migration ID %q to be preserved, got %q", initial.MigrationID, postponed.MigrationID)
@@ -395,8 +389,8 @@ func TestDismissSecurityUpdateReminderKeepsCurrentRoundContext(t *testing.T) {
if len(postponed.Issues) != len(initial.Issues) {
t.Fatalf("expected %d issues to be preserved, got %#v", len(initial.Issues), postponed.Issues)
}
if postponed.PostponedAt == "" {
t.Fatal("expected postponedAt to be recorded")
if postponed.PostponedAt != "" {
t.Fatalf("expected completed round to keep empty postponedAt, got %q", postponed.PostponedAt)
}
}
@@ -526,6 +520,8 @@ func TestDismissSecurityUpdateReminderDoesNotOverrideRolledBackRound(t *testing.
}
func TestStartSecurityUpdateRollsBackWhenSecretStoreUnavailable(t *testing.T) {
withTestGOOS(t, "linux")
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
@@ -536,11 +532,11 @@ func TestStartSecurityUpdateRollsBackWhenSecretStoreUnavailable(t *testing.T) {
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if status.OverallStatus != SecurityUpdateOverallStatusRolledBack {
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
if status.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", status.OverallStatus)
}
if len(status.Issues) != 1 || status.Issues[0].Scope != SecurityUpdateIssueScopeSystem {
t.Fatalf("expected single system issue, got %#v", status.Issues)
if len(status.Issues) != 0 {
t.Fatalf("expected no blocking issues, got %#v", status.Issues)
}
}
@@ -567,11 +563,11 @@ func TestStartSecurityUpdateRollsBackWhenAIProviderSecretStoreUnavailable(t *tes
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if status.OverallStatus != SecurityUpdateOverallStatusRolledBack {
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
if status.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", status.OverallStatus)
}
if len(status.Issues) != 1 || status.Issues[0].Scope != SecurityUpdateIssueScopeSystem {
t.Fatalf("expected single system issue, got %#v", status.Issues)
if len(status.Issues) != 0 {
t.Fatalf("expected no blocking issues, got %#v", status.Issues)
}
}
@@ -619,16 +615,19 @@ func TestStartSecurityUpdateRollsBackPartialConnectionImportWhenLaterProviderSte
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if status.OverallStatus != SecurityUpdateOverallStatusRolledBack {
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
if status.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", status.OverallStatus)
}
savedConnections, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(savedConnections) != 0 {
t.Fatalf("expected rollback to leave no imported connections, got %#v", savedConnections)
if len(savedConnections) != 1 {
t.Fatalf("expected imported connection to remain after completed update, got %#v", savedConnections)
}
if savedConnections[0].ID != "legacy-1" || savedConnections[0].Config.Host != "db.local" {
t.Fatalf("expected imported connection metadata to be preserved, got %#v", savedConnections[0])
}
}

View File

@@ -0,0 +1,235 @@
package dailysecret
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
const (
fileName = "daily_secrets.json"
schemaVersion = 1
)
type ConnectionBundle struct {
Password string `json:"password,omitempty"`
SSHPassword string `json:"sshPassword,omitempty"`
ProxyPassword string `json:"proxyPassword,omitempty"`
HTTPTunnelPassword string `json:"httpTunnelPassword,omitempty"`
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"`
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"`
OpaqueURI string `json:"opaqueURI,omitempty"`
OpaqueDSN string `json:"opaqueDSN,omitempty"`
}
func (b ConnectionBundle) HasAny() bool {
return strings.TrimSpace(b.Password) != "" ||
strings.TrimSpace(b.SSHPassword) != "" ||
strings.TrimSpace(b.ProxyPassword) != "" ||
strings.TrimSpace(b.HTTPTunnelPassword) != "" ||
strings.TrimSpace(b.MySQLReplicaPassword) != "" ||
strings.TrimSpace(b.MongoReplicaPassword) != "" ||
strings.TrimSpace(b.OpaqueURI) != "" ||
strings.TrimSpace(b.OpaqueDSN) != ""
}
type GlobalProxyBundle struct {
Password string `json:"password,omitempty"`
}
func (b GlobalProxyBundle) HasAny() bool {
return strings.TrimSpace(b.Password) != ""
}
type ProviderBundle struct {
APIKey string `json:"apiKey,omitempty"`
SensitiveHeaders map[string]string `json:"sensitiveHeaders,omitempty"`
}
func (b ProviderBundle) HasAny() bool {
return strings.TrimSpace(b.APIKey) != "" || len(b.SensitiveHeaders) > 0
}
type File struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Connections map[string]ConnectionBundle `json:"connections,omitempty"`
GlobalProxy *GlobalProxyBundle `json:"globalProxy,omitempty"`
AIProviders map[string]ProviderBundle `json:"aiProviders,omitempty"`
}
type Store struct {
root string
}
func NewStore(root string) *Store {
return &Store{root: strings.TrimSpace(root)}
}
func (s *Store) Path() string {
return filepath.Join(s.root, fileName)
}
func (s *Store) Load() (File, error) {
if strings.TrimSpace(s.root) == "" {
return File{SchemaVersion: schemaVersion}, nil
}
data, err := os.ReadFile(s.Path())
if err != nil {
if os.IsNotExist(err) {
return File{SchemaVersion: schemaVersion}, nil
}
return File{}, err
}
var file File
if err := json.Unmarshal(data, &file); err != nil {
return File{}, err
}
if file.SchemaVersion == 0 {
file.SchemaVersion = schemaVersion
}
return file, nil
}
func (s *Store) Save(file File) error {
if strings.TrimSpace(s.root) == "" {
return nil
}
file.SchemaVersion = schemaVersion
if len(file.Connections) == 0 {
file.Connections = nil
}
if file.GlobalProxy != nil && !file.GlobalProxy.HasAny() {
file.GlobalProxy = nil
}
if len(file.AIProviders) == 0 {
file.AIProviders = nil
}
if err := os.MkdirAll(s.root, 0o755); err != nil {
return err
}
payload, err := json.MarshalIndent(file, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.Path(), payload, 0o644)
}
func (s *Store) GetConnection(id string) (ConnectionBundle, bool, error) {
file, err := s.Load()
if err != nil {
return ConnectionBundle{}, false, err
}
bundle, ok := file.Connections[strings.TrimSpace(id)]
return bundle, ok, nil
}
func (s *Store) PutConnection(id string, bundle ConnectionBundle) error {
file, err := s.Load()
if err != nil {
return err
}
if !bundle.HasAny() {
return s.deleteConnectionFromFile(file, id)
}
if file.Connections == nil {
file.Connections = make(map[string]ConnectionBundle)
}
file.Connections[strings.TrimSpace(id)] = bundle
return s.Save(file)
}
func (s *Store) DeleteConnection(id string) error {
file, err := s.Load()
if err != nil {
return err
}
return s.deleteConnectionFromFile(file, id)
}
func (s *Store) deleteConnectionFromFile(file File, id string) error {
if len(file.Connections) != 0 {
delete(file.Connections, strings.TrimSpace(id))
}
return s.Save(file)
}
func (s *Store) GetGlobalProxy() (GlobalProxyBundle, bool, error) {
file, err := s.Load()
if err != nil {
return GlobalProxyBundle{}, false, err
}
if file.GlobalProxy == nil {
return GlobalProxyBundle{}, false, nil
}
return *file.GlobalProxy, true, nil
}
func (s *Store) PutGlobalProxy(bundle GlobalProxyBundle) error {
file, err := s.Load()
if err != nil {
return err
}
if !bundle.HasAny() {
file.GlobalProxy = nil
return s.Save(file)
}
copyBundle := bundle
file.GlobalProxy = &copyBundle
return s.Save(file)
}
func (s *Store) DeleteGlobalProxy() error {
file, err := s.Load()
if err != nil {
return err
}
file.GlobalProxy = nil
return s.Save(file)
}
func (s *Store) GetAIProvider(id string) (ProviderBundle, bool, error) {
file, err := s.Load()
if err != nil {
return ProviderBundle{}, false, err
}
bundle, ok := file.AIProviders[strings.TrimSpace(id)]
return bundle, ok, nil
}
func (s *Store) PutAIProvider(id string, bundle ProviderBundle) error {
file, err := s.Load()
if err != nil {
return err
}
if !bundle.HasAny() {
return s.deleteAIProviderFromFile(file, id)
}
if file.AIProviders == nil {
file.AIProviders = make(map[string]ProviderBundle)
}
if len(bundle.SensitiveHeaders) > 0 {
cloned := make(map[string]string, len(bundle.SensitiveHeaders))
for key, value := range bundle.SensitiveHeaders {
cloned[key] = value
}
bundle.SensitiveHeaders = cloned
}
file.AIProviders[strings.TrimSpace(id)] = bundle
return s.Save(file)
}
func (s *Store) DeleteAIProvider(id string) error {
file, err := s.Load()
if err != nil {
return err
}
return s.deleteAIProviderFromFile(file, id)
}
func (s *Store) deleteAIProviderFromFile(file File, id string) error {
if len(file.AIProviders) != 0 {
delete(file.AIProviders, strings.TrimSpace(id))
}
return s.Save(file)
}

View File

@@ -0,0 +1,104 @@
package dailysecret
import "testing"
func TestStorePutGetDeleteConnectionSecret(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
bundle := ConnectionBundle{
Password: "postgres-secret",
OpaqueDSN: "postgres://user:pass@db.local/app",
SSHPassword: "ssh-secret",
}
if err := store.PutConnection("conn-1", bundle); err != nil {
t.Fatalf("PutConnection returned error: %v", err)
}
got, ok, err := store.GetConnection("conn-1")
if err != nil {
t.Fatalf("GetConnection returned error: %v", err)
}
if !ok {
t.Fatal("expected connection bundle to exist")
}
if got.Password != "postgres-secret" || got.OpaqueDSN != bundle.OpaqueDSN || got.SSHPassword != "ssh-secret" {
t.Fatalf("unexpected bundle: %#v", got)
}
if err := store.DeleteConnection("conn-1"); err != nil {
t.Fatalf("DeleteConnection returned error: %v", err)
}
got, ok, err = store.GetConnection("conn-1")
if err != nil {
t.Fatalf("GetConnection after delete returned error: %v", err)
}
if ok {
t.Fatalf("expected missing connection bundle after delete, got %#v", got)
}
}
func TestStorePutGetDeleteGlobalProxySecret(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
if err := store.PutGlobalProxy(GlobalProxyBundle{Password: "proxy-secret"}); err != nil {
t.Fatalf("PutGlobalProxy returned error: %v", err)
}
got, ok, err := store.GetGlobalProxy()
if err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
}
if !ok || got.Password != "proxy-secret" {
t.Fatalf("unexpected global proxy bundle: %#v ok=%v", got, ok)
}
if err := store.DeleteGlobalProxy(); err != nil {
t.Fatalf("DeleteGlobalProxy returned error: %v", err)
}
_, ok, err = store.GetGlobalProxy()
if err != nil {
t.Fatalf("GetGlobalProxy after delete returned error: %v", err)
}
if ok {
t.Fatal("expected global proxy bundle to be deleted")
}
}
func TestStorePutGetDeleteAIProviderSecret(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
bundle := ProviderBundle{
APIKey: "sk-test",
SensitiveHeaders: map[string]string{
"Authorization": "Bearer test",
},
}
if err := store.PutAIProvider("openai-main", bundle); err != nil {
t.Fatalf("PutAIProvider returned error: %v", err)
}
got, ok, err := store.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("GetAIProvider returned error: %v", err)
}
if !ok {
t.Fatal("expected provider bundle to exist")
}
if got.APIKey != "sk-test" || got.SensitiveHeaders["Authorization"] != "Bearer test" {
t.Fatalf("unexpected provider bundle: %#v", got)
}
if err := store.DeleteAIProvider("openai-main"); err != nil {
t.Fatalf("DeleteAIProvider returned error: %v", err)
}
_, ok, err = store.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("GetAIProvider after delete returned error: %v", err)
}
if ok {
t.Fatal("expected provider bundle to be deleted")
}
}