mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串 - 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知 - WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据 - DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验 - 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞 - 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
This commit is contained in:
14
.github/workflows/dev-build.yml
vendored
14
.github/workflows/dev-build.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
141
build-release.sh
141
build-release.sh
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -1 +1 @@
|
||||
8cc5d6401a6ce7dd0f500c66ce8bb4a9
|
||||
26a843d5fd071d0c7e9d8022e98eb4e3
|
||||
@@ -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>
|
||||
|
||||
21
frontend/src/utils/appVersionDisplay.test.ts
Normal file
21
frontend/src/utils/appVersionDisplay.test.ts
Normal 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('未知');
|
||||
});
|
||||
});
|
||||
14
frontend/src/utils/appVersionDisplay.ts
Normal file
14
frontend/src/utils/appVersionDisplay.ts
Normal 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 || '未知';
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
7
frontend/src/vite-env.d.ts
vendored
7
frontend/src/vite-env.d.ts
vendored
@@ -1,2 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -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) != ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
19
internal/ai/service/daily_secret_store_adapter.go
Normal file
19
internal/ai/service/daily_secret_store_adapter.go
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
223
internal/app/daily_secret_migration.go
Normal file
223
internal/app/daily_secret_migration.go
Normal 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{}
|
||||
}
|
||||
107
internal/app/daily_secret_persistence.go
Normal file
107
internal/app/daily_secret_persistence.go
Normal 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)
|
||||
}
|
||||
210
internal/app/darwin_daily_secret_migration_test.go
Normal file
210
internal/app/darwin_daily_secret_migration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
181
internal/app/legacy_webkit_storage.go
Normal file
181
internal/app/legacy_webkit_storage.go
Normal 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))
|
||||
}
|
||||
183
internal/app/legacy_webkit_storage_test.go
Normal file
183
internal/app/legacy_webkit_storage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
235
internal/dailysecret/store.go
Normal file
235
internal/dailysecret/store.go
Normal 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 = ©Bundle
|
||||
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)
|
||||
}
|
||||
104
internal/dailysecret/store_test.go
Normal file
104
internal/dailysecret/store_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user