diff --git a/backend/res_init/directories_test.go b/backend/res_init/directories_test.go new file mode 100644 index 0000000..c292d63 --- /dev/null +++ b/backend/res_init/directories_test.go @@ -0,0 +1,91 @@ +package res_init + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +func TestDirInitUsesRuntimeRoot(t *testing.T) { + root, working := t.TempDir(), t.TempDir() + t.Chdir(working) + previous := config.ROOT_PATH + config.ROOT_PATH = root + t.Cleanup(func() { config.ROOT_PATH = previous }) + + for i := 0; i < 2; i++ { + if err := dirInit(); err != nil { + t.Fatal(err) + } + for _, name := range []string{"config", "config/dkim", "config/ssl"} { + info, err := os.Stat(filepath.Join(root, name)) + if err != nil || !info.IsDir() { + t.Fatalf("运行目录未创建: %s", name) + } + } + } + if _, err := os.Stat(filepath.Join(working, "config")); !os.IsNotExist(err) { + t.Fatal("初始化不应在工作目录创建另一份配置") + } +} + +func TestDirInitPreservesExistingFiles(t *testing.T) { + root := t.TempDir() + previous := config.ROOT_PATH + config.ROOT_PATH = root + t.Cleanup(func() { config.ROOT_PATH = previous }) + if err := dirInit(); err != nil { + t.Fatal(err) + } + files := []string{"config/config.json", "config/smail.db", "config/credential.key", "config/dkim/dkim.priv", "config/ssl/private.key"} + data := []byte("existing-runtime-fixture") + for _, name := range files { + if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil { + t.Fatal(err) + } + } + if err := dirInit(); err != nil { + t.Fatal(err) + } + for _, name := range files { + actual, err := os.ReadFile(filepath.Join(root, name)) + if err != nil || !bytes.Equal(actual, data) { + t.Fatalf("初始化改变了已有运行文件: %s", name) + } + } +} + +func TestDirInitRejectsFileAsDirectory(t *testing.T) { + root := t.TempDir() + previous := config.ROOT_PATH + config.ROOT_PATH = root + t.Cleanup(func() { config.ROOT_PATH = previous }) + if err := os.Mkdir(filepath.Join(root, "config"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "config", "dkim"), []byte("blocked"), 0600); err != nil { + t.Fatal(err) + } + if err := dirInit(); err == nil { + t.Fatal("普通文件不能冒充运行目录") + } +} + +func TestDirInitRejectsSymlink(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + previous := config.ROOT_PATH + config.ROOT_PATH = root + t.Cleanup(func() { config.ROOT_PATH = previous }) + if err := os.Symlink(outside, filepath.Join(root, "config")); err != nil { + t.Skipf("当前环境无法创建符号链接: %v", err) + } + if err := dirInit(); err == nil { + t.Fatal("运行配置目录不能通过符号链接逃逸") + } + if _, err := os.Stat(filepath.Join(outside, "dkim")); !os.IsNotExist(err) { + t.Fatal("拒绝符号链接前不应写入外部目录") + } +} diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 4044180..eb736b3 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -32,7 +32,6 @@ import ( "git.pcux.cn/xuans/Smail/session" "git.pcux.cn/xuans/Smail/signal" "git.pcux.cn/xuans/Smail/utils/credential" - "git.pcux.cn/xuans/Smail/utils/file" log "github.com/sirupsen/logrus" // 导入 HTTP 客户端,用于等待服务启动。 "net/http" @@ -453,24 +452,18 @@ func stopAdminResetControl() { } func dirInit() error { - if !file.PathExist("./config") { - err := os.MkdirAll("./config", 0700) - if err != nil { - return fmt.Errorf("create config directory: %w", err) + // 初始化与配置读写使用同一个运行根目录,不依赖启动时的工作目录。 + root := filepath.Join(config.ROOT_PATH, "config") + for _, directory := range []string{root, filepath.Join(root, "dkim"), filepath.Join(root, "ssl")} { + if err := os.MkdirAll(directory, 0700); err != nil { + return fmt.Errorf("创建运行配置目录失败: %w", err) } - } - - if !file.PathExist("./config/dkim") { - err := os.MkdirAll("./config/dkim", 0700) + info, err := os.Lstat(directory) if err != nil { - return fmt.Errorf("create DKIM directory: %w", err) + return fmt.Errorf("检查运行配置目录失败: %w", err) } - } - - if !file.PathExist("./config/ssl") { - err := os.MkdirAll("./config/ssl", 0700) - if err != nil { - return fmt.Errorf("create SSL directory: %w", err) + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("运行配置目录不能是符号链接或普通文件") } } return nil diff --git a/deploy/start-dev.ps1 b/deploy/start-dev.ps1 index bb7f0a7..d6078cd 100644 --- a/deploy/start-dev.ps1 +++ b/deploy/start-dev.ps1 @@ -11,10 +11,18 @@ try { $binary = Join-Path $root 'out\backend\smail-dev.exe' if (-not (Test-Path -LiteralPath $binary)) { throw '请先运行 deploy/build.ps1 编译后端' } if (Get-NetTCPConnection -State Listen -LocalPort 443 -ErrorAction SilentlyContinue) { throw '443 端口已占用,请先确认并停止旧后端' } - # 二进制与配置分离后必须显式指定根目录,不能在 out 下初始化新数据库。 - $env:SMAIL_ROOT = Join-Path $root 'backend' + # 拒绝沿用旧运行目录,避免迁移遗漏时静默创建另一份账号库。 + $legacyConfig = Join-Path $root 'backend\config' + foreach ($name in @('config.json', 'config.dev.json', 'credential.key', 'smail.db', 'smail_temp.db')) { + if (Test-Path -LiteralPath (Join-Path $legacyConfig $name)) { + throw 'backend/config 中仍有运行文件,请先停止后端并完成根目录 config 迁移' + } + } + # SMAIL_ROOT 是 config 的父目录,不能指向 config 本身或编译输出目录。 + $env:SMAIL_ROOT = $root $arguments = @('--dev-disable-admin-reset') - $workingDirectory = $env:SMAIL_ROOT + # 插件工作目录保持不变;配置、数据库和密钥统一通过 SMAIL_ROOT 定位。 + $workingDirectory = Join-Path $root 'backend' } else { if (Get-NetTCPConnection -State Listen -LocalPort 15173 -ErrorAction SilentlyContinue) { throw '15173 端口已占用,请先确认并停止旧前端' } $binary = (Get-Command node -ErrorAction Stop).Source diff --git a/docs/开发启动权限说明.md b/docs/开发启动权限说明.md index 1cb18a8..d3e87b1 100644 --- a/docs/开发启动权限说明.md +++ b/docs/开发启动权限说明.md @@ -24,17 +24,20 @@ powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Servic 2026-09-05 配置管理修复后,当前 `config/config.json` 已迁移为分区根配置。必须先执行上面的编译命令,再运行新二进制;不要直接运行迁移前保留的旧程序。备份和本次验收边界见《配置管理接入修复验收》。后续修复期间已成功普通权限停止旧后端并启动新后端,下方早期停止受阻记录仅反映当时状态。 -启动脚本以普通权限在后台运行服务,并输出进程编号;停止时先核对进程路径,再使用 `Stop-Process -Id <进程编号>`。日志位于 `test-log/runtime/`,二进制位于 `out/backend/`。脚本显式设置 `SMAIL_ROOT` 指向 `backend/`,避免在 `out/` 下生成另一份配置或数据库。其他端口占用、文件权限或系统限制仍需分别处理,此参数不保证绕过所有操作系统权限问题。 +启动脚本以普通权限在后台运行服务,并输出进程编号;停止时先核对进程路径,再使用 `Stop-Process -Id <进程编号>`。日志位于 `test-log/runtime/`,二进制位于 `out/backend/`。脚本显式设置 `SMAIL_ROOT` 指向项目根目录 `D:\Desktop\Smail`,运行文件统一位于根目录 `config/`,避免在 `backend/` 或 `out/` 下生成另一份配置或数据库。若旧 `backend/config/` 中仍有配置、密钥或数据库,脚本拒绝启动,要求先完成迁移。其他端口占用、文件权限或系统限制仍需分别处理,此参数不保证绕过所有操作系统权限问题。 ## 原有开发模式 ```powershell Set-Location D:\Desktop\Smail\backend +$env:SMAIL_ROOT = 'D:\Desktop\Smail' go run . dev ``` 末尾 `dev` 仍读取 `config/config.dev.json` 并启用项目原有开发行为,现在也会停用管理员重置通道。该文件可能使用另一份数据库,不应为解决权限问题直接切换过去。开发模式下不再使用 `SMAIL_ADMIN_CONTROL_PATH` 创建重置通道。 +`SMAIL_ROOT` 必须是 `config/` 的父目录,不能设置成 `D:\Desktop\Smail\config`,否则会变成 `config/config/`。直接运行 `out/backend/` 下的程序或管理工具时也应显式设置该变量;默认可执行文件目录规则为独立正式部署保留,不自动猜测源码位置。 + ## 正式部署 - 移除 `--dev-disable-admin-reset`,不要以末尾 `dev` 启动。 diff --git a/docs/文件目录整理说明.md b/docs/文件目录整理说明.md index df15b36..5edd220 100644 --- a/docs/文件目录整理说明.md +++ b/docs/文件目录整理说明.md @@ -9,7 +9,8 @@ ```text Smail/ ├── frontend/ # 前端源码与依赖 -├── backend/ # 后端源码、必要的同包测试与运行配置 +├── backend/ # 后端源码与必要的同包测试 +├── config/ # 本机运行配置、数据库、证书、密钥与配置备份 ├── plugins/ # 插件源码与文档 ├── deploy/ # 构建、开发启动及正式部署工具 ├── test/ # 独立测试源码、脚本和统一测试入口 @@ -39,7 +40,7 @@ Smail/ 1. Go 同包测试保留在 `backend/`:依赖包私有符号,直接搬离会破坏测试,统一入口负责集中执行。 2. `backend/listen/http_server/dist/` 和 Telegram 插件的 `hook/dist/` 是 Go 嵌入中间目录:Go 不允许跨模块向上嵌入,不能直接指向 `out/`。前端正式输出在 `out/`,构建后自动同步必需的嵌入文件。 3. 运行 Logo、文档图片、源代码静态资源、依赖目录、模型及训练工具不属于测试垃圾。 -4. `backend/config/` 是现有服务的数据根目录,配置、SQLite、密钥及迁移备份原位保留。根目录 `ssl/` 和用途尚未确认的空数据库也保留,不凭文件名删除。 +4. 后续按用户要求将运行文件统一迁入根目录 `config/`;`backend/config/` 只保留配置管理源码、同包测试和说明文件。迁移不改写分区引用、不清空数据库、不重新生成密钥。根目录 `ssl/` 是人工证书输入,用途尚未确认的空数据库也保留,不凭文件名删除。具体范围见《运行文件目录迁移说明》。 ## 构建与启动 @@ -50,7 +51,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Servic powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Service frontend ``` -开发后端为 `out/backend/smail-dev.exe`。脚本固定 `SMAIL_ROOT=backend/`,不在 `out/` 下创建新账号库。编译前应停止正在运行的同名二进制,停止前核对进程号和路径。正式部署仍需使用发行入口 `cmd/smail`,保留本机管理员恢复通道的权限保护。 +开发后端为 `out/backend/smail-dev.exe`。脚本固定 `SMAIL_ROOT` 为项目根目录,读取根目录 `config/`,不在 `out/` 下创建新账号库。插件工作目录保持原有 `backend/` 不变,配置初始化不再依赖工作目录。编译前应停止正在运行的同名二进制,停止前核对进程号和路径。正式部署仍需使用发行入口 `cmd/smail`,保留本机管理员恢复通道的权限保护。 两个新增 PowerShell 脚本使用 UTF-8 BOM,使 Windows PowerShell 5 正确解析中文;其他源文件沿用其原编码。没有用脚本替换中文文字。 diff --git a/docs/运行文件目录迁移说明.md b/docs/运行文件目录迁移说明.md new file mode 100644 index 0000000..d788192 --- /dev/null +++ b/docs/运行文件目录迁移说明.md @@ -0,0 +1,89 @@ +# 运行文件目录迁移说明 + +## 需求确认 + +运行配置统一放在 `D:\Desktop\Smail\config`;`backend/config` 是 Go 配置管理源码包,不整体重命名或删除。运行日志继续放在 `test-log/runtime`,编译输出继续放在 `out`。 + +## 文件归属 + +```text +Smail/ +├── config/ # 忽略 Git 的本机运行文件 +│ ├── config.json # 当前启动使用的分区根配置 +│ ├── smtp.json # SMTP 分区 +│ ├── email.json # 邮箱策略分区 +│ ├── ssl.json # 证书引用分区 +│ ├── plugins.json # 插件配置分区 +│ ├── config.dev.json # 历史 dev 模式配置,不是当前启动配置 +│ ├── config_mysql.json # 保留的另一份配置,不自动启用 +│ ├── config.json.legacy # 历史配置原始备份 +│ ├── smail_temp.db # 当前账号及邮件数据库 +│ ├── smail.db # 另一份已有 SQLite 数据库,保留 +│ ├── credential.key # 凭据加密主密钥 +│ ├── external-email-*.key # 外部邮箱加密和指纹密钥 +│ ├── dkim/ # DKIM 密钥及备份 +│ ├── ssl/ # TLS 证书、私钥及 ACME 资料 +│ ├── migration-backups/ # 配置格式迁移备份 +│ ├── backups-v2/ # 分区配置备份 +│ └── transaction/ # 配置事务工作目录 +└── backend/config/ # Go 源码、同包测试和原有说明文档 +``` + +## 启动约定 + +- `deploy/start-dev.ps1` 设置 `SMAIL_ROOT=D:\Desktop\Smail`。程序使用 `SMAIL_ROOT/config` 读取和生成运行文件,不能把环境变量设置成 `config` 自身。 +- 当前启动继续使用 `--dev-disable-admin-reset`,读取 `config/config.json`,不切换到 `config.dev.json` 或另一份数据库。 +- 为保持已有插件安装路径,子进程工作目录仍是 `backend/`。初始化目录已改为基于 `SMAIL_ROOT`,不再向工作目录写入 `./config`。 +- 直接运行编译程序或 `go run` 时也需要显式设置 `SMAIL_ROOT`。独立正式部署保留原有环境变量优先、可执行文件目录回退规则。 +- 如果 `backend/config` 仍存在旧配置、主密钥或 SQLite 文件,开发启动脚本停止并提示先迁移,防止误初始化第二份账号库。 + +## 迁移和保护边界 + +1. 修改前提交中文检查点并推送,真实运行文件不进入 Git。 +2. 停止并核实当前项目后端进程,前端保持运行。 +3. 迁移前记录源码及运行文件清单和 SHA-256;运行文件另存一份本地回退副本到 `test-log/archive`。 +4. 只迁移明确的运行文件,目标存在同名文件时拒绝覆盖;保留 Go 源码及说明文档,不删除旧空目录。 +5. JSON、数据库、密钥及证书保持原始字节;分区配置的摘要和相对路径无需更改。 +6. 迁移后逐一校验哈希,再启动后端并核对运行根目录、数据库及探活结果。 +7. 根目录 `ssl/` 的人工输入证书、`backend/smail.db` 的原有空文件、历史测试配置不属于当前活动配置,不删除,也不冒充当前数据源。 + +本次是保留数据的目录迁移,不进行清库初始化。自动测试使用 `test-log` 下独立临时数据库;业务数据库仅做只读完整性和账号摘要校验。进程启动后的正常任务可能更新业务数据库,迁移字节校验应在重启前完成。 + +## 任务清单 + +- [✓] 核实运行文件、活动数据库与源码边界。 +- [✓] 修改前检查点已提交并推送。 +- [✓] 开发启动指向项目根目录,并拒绝旧目录残留运行文件;脚本语法、编码及路径约束测试通过。 +- [✓] 初始化目录与工作目录解耦;异工作目录、重复初始化、文件保留和目录冲突回归通过。 +- [✓] 停服备份并迁移 24 个运行文件,重启前逐一哈希一致;55 个源码及说明文件保持不变。 +- [✓] 三个后端程序重新编译,前端 19 项自动测试、Go 全量测试及静态检查通过。 +- [✓] 从新目录恢复后端,两份 SQLite 完整性及迁移前表记录数一致,账号记录在重启前后均未改变;前后端探活正常。 +- [!] 符号链接拒绝测试在当前 Windows 普通账户下因缺少创建符号链接权限而跳过,未提升权限,也未据此宣称该分支实测通过。 + +## 本次验收记录 + +2026-09-05 本地迁移结果: + +- 修改前检查点:`3c455186b70289238167a19432431ace74ee79e4`,已核对与远端 `smail/main` 一致;后续本轮代码改动保留在工作区。 +- 停止的旧后端进程编号为 `23136`;新进程编号为 `45920`,可执行文件位于 `out/backend/smail-dev.exe`。进程编号仅代表验收当时状态。 +- 新启动日志显示 `Env Root Path: D:\Desktop\Smail/`,当前配置仍是 `config/config.json`,数据库仍由 `localRef=smail_temp.db` 指定,分区代次保持 `1`。 +- 两个 SQLite 文件各有 20 张表;保留库 `smail.db` 有 0 个账号,活动库 `smail_temp.db` 有 1 个账号。校验不输出账号、密码哈希或密钥内容。 +- 重启后 23 个非活动数据库运行文件及 55 个源码说明文件哈希仍一致;活动数据库仅允许正常运行写入,账号完整记录与备份比较一致。 +- `https://127.0.0.1/api/ping` 和 `http://127.0.0.1:15173/api/ping` 均返回 HTTP 200、`errorNo=0`、`pong`;前端页面返回 200。 +- `https://127.0.0.1/config/credential.key` 返回 404,没有把新运行目录当作静态资源公开。自签名证书探活使用跳过本机证书信任验证参数,不代表公开 CA 信任验收。 +- `node test/run.mjs all -- -count=1 -p 2 ./...` 通过;随后单独复跑 `TestDirInit`,三个用例通过、符号链接用例跳过。 +- `go -C backend vet -p 2 ./...`、PowerShell 语法检查、`git diff --check` 和 Go 格式检查通过;启动脚本仍保留 Windows PowerShell 5 需要的 UTF-8 BOM。 +- Git 忽略规则确认根目录配置、密钥、数据库、回退副本、日志和编译产物不会进入提交。 + +本地回退副本:`test-log/archive/runtime-config-20260905-202201/`。目录包含密钥和数据库,仅留在本机,不上传。 + +检查证据: + +- `test-log/reports/runtime-config-migration-20260905-202201.json`:完整移动、哈希和源码保留清单。 +- `test-log/reports/runtime-config-sqlite-before-start.json`:重启前数据库完整性、记录数及账号一致性。 +- `test-log/reports/runtime-config-after-start.json`:重启后路径、运行文件与账号一致性。 +- `test-log/reports/runtime-config-build-20260905.log`:本次后端及管理工具构建记录。 +- `test-log/run-2026-09-05T12-20-49-905Z-23176/`:完整自动测试记录。 +- `test-log/run-2026-09-05T12-24-16-351Z-24952/`:初始化目录专项回归记录。 + +真实邮件收发、浏览器全部流程和 Linux/Windows 正式安装不在本次迁移验收范围内,不能以探活成功代替这些验收。 diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index 82328b7..6654fd0 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -28,7 +28,10 @@ test('前端构建必须自动同步 Go 嵌入资源', async () => { test('开发启动必须固定运行配置根目录并集中保存日志', async () => { const content = await read('deploy/start-dev.ps1') - assert.match(content, /\$env:SMAIL_ROOT = Join-Path \$root 'backend'/) + assert.match(content, /\$env:SMAIL_ROOT = \$root\r?\n/) + assert.match(content, /\$legacyConfig = Join-Path \$root 'backend\\config'/) + assert.match(content, /Test-Path -LiteralPath \(Join-Path \$legacyConfig \$name\)/) + assert.match(content, /\$workingDirectory = Join-Path \$root 'backend'/) assert.match(content, /out\\backend\\smail-dev\.exe/) assert.match(content, /test-log\\runtime/) assert.match(content, /-WindowStyle Hidden/)