From a39ad4e6afe9922226a21115e58b6ec600b3a635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 08:42:54 +0800 Subject: [PATCH] fix(skills): make backups cross-filesystem safe --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 286 +++++++++-- build/npm/package.json.tmpl | 2 +- docs/rfc-skill-installation-and-upgrade.md | 21 +- internal/app/skill_setup.go | 5 +- .../app/skill_setup_cleanup_coverage_test.go | 15 +- internal/upgrade/paths.go | 11 +- internal/upgrade/paths_multi_test.go | 41 ++ internal/upgrade/skill_path_move.go | 342 +++++++++++++ internal/upgrade/skill_path_move_test.go | 449 +++++++++++++++++ internal/upgrade/skill_path_move_unix.go | 12 + internal/upgrade/skill_path_move_unix_test.go | 12 + internal/upgrade/skill_path_move_windows.go | 13 + .../upgrade/skill_path_move_windows_test.go | 20 + scripts/install-devapp.ps1 | 229 ++++++++- scripts/install-devapp.sh | 34 +- scripts/install-event.sh | 24 + scripts/install-skills.sh | 24 + scripts/install.ps1 | 205 +++++++- scripts/install.sh | 46 +- test/scripts/install_js_smoke.mjs | 212 ++++++++ test/scripts/install_script_test.go | 465 ++++++++++++++++++ 22 files changed, 2356 insertions(+), 114 deletions(-) create mode 100644 internal/upgrade/skill_path_move.go create mode 100644 internal/upgrade/skill_path_move_test.go create mode 100644 internal/upgrade/skill_path_move_unix.go create mode 100644 internal/upgrade/skill_path_move_unix_test.go create mode 100644 internal/upgrade/skill_path_move_windows.go create mode 100644 internal/upgrade/skill_path_move_windows_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index c42462e8..2608ccd6 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index f091b4a2..5550d449 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -153,18 +153,222 @@ function backupStamp() { ); } +function pathExistsLexicallySync(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function copyPathLexicallySync(src, dest) { + const info = fs.lstatSync(src); + if (info.isDirectory()) { + fs.mkdirSync(dest, { mode: 0o700 }); + for (const name of fs.readdirSync(src)) { + copyPathLexicallySync(path.join(src, name), path.join(dest, name)); + } + fs.chmodSync(dest, info.mode & 0o777); + return; + } + if (info.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dest); + return; + } + if (!info.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${info.mode.toString(8)})`); + } + fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL); + fs.chmodSync(dest, info.mode & 0o777); +} + +function restoreDirectoryModesSync(modes, chmodFn) { + const failures = []; + for (let i = modes.length - 1; i >= 0; i -= 1) { + const item = modes[i]; + try { + if (pathExistsLexicallySync(item.path)) chmodFn(item.path, item.mode); + } catch (err) { + failures.push(`restore source directory mode ${item.path}: ${err.message}`); + } + } + if (failures.length > 0) throw new Error(failures.join("; ")); +} + +function prepareTreeRemovalSync(root, chmodFn) { + const modes = []; + const visit = (target) => { + const info = fs.lstatSync(target); + if (!info.isDirectory()) return; + const mode = info.mode & 0o777; + if ((mode & 0o700) !== 0o700) { + chmodFn(target, mode | 0o700); + modes.push({ path: target, mode }); + } + for (const name of fs.readdirSync(target)) visit(path.join(target, name)); + }; + try { + visit(root); + return modes; + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function makeTreeWritableBestEffortSync(root) { + try { + const info = fs.lstatSync(root); + if (!info.isDirectory()) return; + try { fs.chmodSync(root, 0o700); } catch {} + let names = []; + try { names = fs.readdirSync(root); } catch {} + for (const name of names) makeTreeWritableBestEffortSync(path.join(root, name)); + } catch {} +} + +function removePublishedSourceSync(src, removeFn, chmodFn) { + const modes = prepareTreeRemovalSync(src, chmodFn); + try { + removeFn(src); + if (pathExistsLexicallySync(src)) throw new Error("source still exists after removal"); + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function fileDigestSync(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function verifyPathCopySync(src, dest) { + const srcInfo = fs.lstatSync(src); + const destInfo = fs.lstatSync(dest); + const srcType = srcInfo.mode & fs.constants.S_IFMT; + const destType = destInfo.mode & fs.constants.S_IFMT; + if (srcType !== destType) { + throw new Error(`Skill path type mismatch: ${src} != ${dest}`); + } + if (srcInfo.isDirectory()) { + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill directory mode mismatch: ${src} != ${dest}`); + } + const bytewise = (left, right) => Buffer.from(left).compare(Buffer.from(right)); + const srcNames = fs.readdirSync(src).sort(bytewise); + const destNames = fs.readdirSync(dest).sort(bytewise); + if (srcNames.length !== destNames.length || srcNames.some((name, index) => name !== destNames[index])) { + throw new Error(`Skill directory entries mismatch: ${src} != ${dest}`); + } + for (const name of srcNames) { + verifyPathCopySync(path.join(src, name), path.join(dest, name)); + } + return; + } + if (srcInfo.isSymbolicLink()) { + if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) { + throw new Error(`Skill symlink target mismatch: ${src} != ${dest}`); + } + return; + } + if (!srcInfo.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${srcInfo.mode.toString(8)})`); + } + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill file mode mismatch: ${src} != ${dest}`); + } + if (srcInfo.size !== destInfo.size) { + throw new Error(`Skill file size mismatch: ${src} != ${dest}`); + } + if (fileDigestSync(src) !== fileDigestSync(dest)) { + throw new Error(`Skill file digest mismatch: ${src} != ${dest}`); + } +} + +function movePathRecoverablySync(src, dest, options = {}) { + const renameFn = options.renameFn || fs.renameSync; + const copyFn = options.copyFn || copyPathLexicallySync; + const verifyFn = options.verifyFn || verifyPathCopySync; + const removeFn = options.removeFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); + const mkdirTempFn = options.mkdirTempFn || fs.mkdtempSync; + const chmodFn = options.chmodFn || fs.chmodSync; + if (pathExistsLexicallySync(dest)) { + throw new Error(`move destination already exists: ${dest}`); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + try { + renameFn(src, dest); + return; + } catch (err) { + if (!err || err.code !== "EXDEV") throw err; + } + + const stageRoot = mkdirTempFn(path.join(path.dirname(dest), `.${path.basename(dest)}.cross-device-`)); + const stage = path.join(stageRoot, "payload"); + let stageCleaned = false; + try { + copyFn(src, stage); + verifyFn(src, stage); + const stageInfo = fs.lstatSync(stage); + const stageMode = stageInfo.mode & 0o777; + if (stageInfo.isDirectory()) chmodFn(stage, stageMode | 0o700); + renameFn(stage, dest); + if (stageInfo.isDirectory()) chmodFn(dest, stageMode); + verifyFn(src, dest); + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + try { + removePublishedSourceSync(src, removeFn, chmodFn); + } catch (err) { + throw new Error(`verified target published but source removal failed; both copies retained (${src}, ${dest}): ${err.message}`); + } + if (pathExistsLexicallySync(src)) { + throw new Error(`verified target published but source still exists; both copies retained (${src}, ${dest})`); + } + } catch (err) { + if (!stageCleaned) { + try { + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + } catch (cleanupErr) { + throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`); + } + } + throw err; + } +} + +function skillMoveOptions(options = {}) { + return { + renameFn: options.renameFn, + copyFn: options.backupCopyFn, + verifyFn: options.backupVerifyFn, + removeFn: options.backupRemoveFn, + mkdirTempFn: options.backupMkdirTempFn, + }; +} + // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it (non-interactive // installs cannot confirm, so removals must stay reversible). Missing paths // are a no-op success. On any backup failure the directory is left in place -// and false is returned so callers skip that target rather than silently -// deleting data. -function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) { - let info; +// and an error is returned so callers cannot silently delete data. +function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { try { - info = fs.lstatSync(dir); + fs.lstatSync(dir); } catch (err) { - if (err && err.code === "ENOENT") return true; + if (err && err.code === "ENOENT") return ""; throw err; } const rel = path.relative(homeDir, dir); @@ -172,30 +376,29 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.ren rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("-") : path.basename(dir); - const stamp = backupStamp(); + const stamp = (options.backupStampFn || backupStamp)(); const backupRoot = path.join(homeDir, ".dws", "skill-backups"); let targetRoot = path.join(backupRoot, stamp); let target = path.join(targetRoot, name); - for (let i = 1; fs.existsSync(target); i++) { + for (let i = 1; pathExistsLexicallySync(target); i++) { if (i > 1000) { - console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`); - return false; + throw new Error(`backup directory collision limit exceeded; source retained: ${dir}`); } targetRoot = path.join(backupRoot, `${stamp}-${i}`); target = path.join(targetRoot, name); } try { fs.mkdirSync(targetRoot, { recursive: true }); - renameFn(dir, target); + movePathRecoverablySync(dir, target, options); } catch (err) { console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); - return false; + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); } if (backups) { backups.push({ original: dir, backup: target }); } console.log(` × 已备份并移除 ${dir} → ${target}`); - return true; + return target; } function findBinary(root) { @@ -453,7 +656,8 @@ function isManagedMultiSkillDir(dir, managedNames) { return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name); } -function retireManagedSkillRoot(homeDir, baseDir, managedNames) { +function retireManagedSkillRoot(homeDir, baseDir, managedNames, options = {}) { + const moveOptions = skillMoveOptions(options); const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { @@ -465,16 +669,14 @@ function retireManagedSkillRoot(homeDir, baseDir, managedNames) { const backups = []; try { for (const victim of victims) { - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } } catch (err) { const restoreErrors = []; for (let i = backups.length - 1; i >= 0; i -= 1) { try { fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); } catch (restoreErr) { restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`); } @@ -496,7 +698,10 @@ function samePhysicalDir(left, right) { } } -function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { +function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { + const moveOptions = skillMoveOptions(options); + const publishRenameFn = options.publishRenameFn || fs.renameSync; + const publishRemoveFn = options.publishRemoveFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); fs.mkdirSync(baseDir, { recursive: true }); const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); @@ -505,12 +710,24 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const published = []; const correctLinks = new Set(); const restore = () => { + const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { - fs.rmSync(published[i], { recursive: true, force: true }); + try { + publishRemoveFn(published[i]); + } catch (err) { + restoreErrors.push(`remove ${published[i]}: ${err.message}`); + } } for (let i = backups.length - 1; i >= 0; i -= 1) { - fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + try { + fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); + } catch (err) { + restoreErrors.push(`restore ${backups[i].original} from ${backups[i].backup}: ${err.message}`); + } + } + if (restoreErrors.length > 0) { + throw new Error(restoreErrors.join("; ")); } }; try { @@ -532,12 +749,10 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const normalized = path.resolve(victim); if (seen.has(normalized) || correctLinks.has(normalized)) continue; seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { - fs.renameSync(item.staged, item.dest); + publishRenameFn(item.staged, item.dest); published.push(item.dest); } } catch (err) { @@ -592,6 +807,7 @@ function publishManagedMultiSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); const staged = []; @@ -611,7 +827,7 @@ function publishManagedMultiSkillSetAtomically( const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -635,9 +851,7 @@ function publishManagedMultiSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { @@ -669,6 +883,7 @@ function publishManagedMonoSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); const stagedDir = path.join(stageRoot, "dws"); @@ -689,7 +904,7 @@ function publishManagedMonoSkillSetAtomically( const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -709,9 +924,7 @@ function publishManagedMonoSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } published.push(destDir); @@ -995,7 +1208,12 @@ module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + backupAndRemoveSkillDir, + copyPathLexicallySync, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + verifyPathCopySync, }; diff --git a/build/npm/package.json.tmpl b/build/npm/package.json.tmpl index f1d70d18..b7d34154 100644 --- a/build/npm/package.json.tmpl +++ b/build/npm/package.json.tmpl @@ -32,6 +32,6 @@ "README.md" ], "engines": { - "node": ">=16" + "node": ">=16.7.0" } } diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index 0f530c5d..56e70758 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -165,6 +165,14 @@ Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别 4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录; 5. 仅在没有目标失败且至少一个目标成功时更新状态快照。 +旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入 +`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子 +rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并 +保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、 +文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标 +再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除 +失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。 + Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前; 备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同 Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与 @@ -173,11 +181,19 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 ## 9. 备份合同 - 路径:`~/.dws/skill-backups//...`; -- 主要操作:同一文件系统内使用 rename 移动; +- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的 + copy → verify → publish → remove 回退; - 失败语义:备份失败时原目录保持不变,目标安装失败; +- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在, + 但恢复必须失败并明确提示两份均被保留; - 可见性:计划和执行日志显示原路径与备份路径; - 保留策略:自动修剪,仅保留最近 5 批。 +跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统 +原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell +入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现 +上述验证和失败合同。 + 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 @@ -226,6 +242,9 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - Windows、macOS、Linux 的路径和覆盖率门禁; - symlinked parent、Windows junction、链接失败复制回退与 broken link 修复; - Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; +- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的 + 正向备份、反向恢复、普通链接及 dangling symlink 词法保留; +- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 92c52f12..6c0becf4 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -130,6 +130,7 @@ var ( skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir + skillSetupRestoreBackup = upgrade.RestoreSkillPath skillSetupMkdirAll = os.MkdirAll skillSetupWalk = filepath.Walk skillSetupRel = filepath.Rel @@ -2048,7 +2049,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir } for i := len(backups) - 1; i >= 0; i-- { item := backups[i] - if _, err := skillSetupStat(item.original); err == nil { + if _, err := skillSetupLstat(item.original); err == nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup)) continue } else if !errors.Is(err, os.ErrNotExist) { @@ -2059,7 +2060,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup)) continue } - if err := skillSetupPublishRename(item.backup, item.original); err != nil { + if err := skillSetupRestoreBackup(item.backup, item.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup)) } } diff --git a/internal/app/skill_setup_cleanup_coverage_test.go b/internal/app/skill_setup_cleanup_coverage_test.go index 957496d4..6fea3de5 100644 --- a/internal/app/skill_setup_cleanup_coverage_test.go +++ b/internal/app/skill_setup_cleanup_coverage_test.go @@ -445,14 +445,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("stat", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") { t.Fatalf("restore stat error = %v", err) } }) t.Run("mkdir", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") { @@ -460,9 +460,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("rename", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") { t.Fatalf("restore rename error = %v", err) @@ -479,10 +479,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } return "", failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) restoreErr := errors.New("restore failure") - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr }) _, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard) if !errors.Is(err, failure) || !errors.Is(err, restoreErr) { t.Fatalf("backup rollback error = %v", err) @@ -491,7 +491,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("publish rollback failure", func(t *testing.T) { testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) err := publishSkillSetupTarget( []skillSetupStagedDir{{staged: "staged", dest: "dest"}}, diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 644b3f96..f0424ae6 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -193,8 +193,11 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp) target := filepath.Join(backupRoot, name) for i := 1; ; i++ { - if _, err := os.Stat(target); os.IsNotExist(err) { - break + if _, err := upgradeLstat(target); err != nil { + if os.IsNotExist(err) { + break + } + return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err) } backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i)) target = filepath.Join(backupRoot, name) @@ -205,7 +208,7 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) } - if err := upgradeRename(dir, target); err != nil { + if err := moveSkillPathRecoverably(dir, target); err != nil { return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err) } // Keep the backup directory bounded; a prune failure must not fail the @@ -503,7 +506,7 @@ func restoreSkillSet(published []string, backups []backedUpSkillDir) error { restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err)) continue } - if err := upgradeRename(backup.backup, backup.original); err != nil { + if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err)) } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index dfee1cae..a0d67135 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" @@ -733,6 +734,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("victim must be gone after backup, stat err=%v", err) } + // A dangling link at the first backup target is still an occupied lexical + // path and must select a numbered target rather than being overwritten. + danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision") + if err := os.MkdirAll(danglingVictim, 0o755); err != nil { + t.Fatal(err) + } + danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision") + if err := os.Symlink("missing-backup", danglingTaken); err == nil { + got, err = backupAndRemoveSkillDir(home, danglingVictim) + if err != nil { + t.Fatalf("backup with dangling collision error = %v", err) + } + want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision") + if got != want { + t.Fatalf("dangling collision backup path = %q, want %q", got, want) + } + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + // An unreadable target probe is not equivalent to a free path. + probeVictim := filepath.Join(home, "probe-victim") + if err := os.MkdirAll(probeVictim, 0o755); err != nil { + t.Fatal(err) + } + probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim") + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { + if path == probeTarget { + return nil, errors.New("target probe denied") + } + return os.Lstat(path) + }) + if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") { + t.Fatalf("target probe error = %v", err) + } + if _, err := os.Stat(probeVictim); err != nil { + t.Fatalf("probe victim must survive target check failure: %v", err) + } + testseam.Swap(t, &upgradeLstat, os.Lstat) + // Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir. victim2 := filepath.Join(home, "victim2") if err := os.MkdirAll(victim2, 0o755); err != nil { diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go new file mode 100644 index 00000000..605f9056 --- /dev/null +++ b/internal/upgrade/skill_path_move.go @@ -0,0 +1,342 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +var ( + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod +) + +// moveSkillPathRecoverably moves one managed Skill path without weakening the +// backup contract when source and destination are on different filesystems. +// The cross-device path publishes a fully copied and verified sibling staging +// path before removing the source. Any failure before source removal leaves the +// source intact; a removal failure deliberately leaves both verified copies. +func moveSkillPathRecoverably(src, dst string) (err error) { + if _, statErr := os.Lstat(dst); statErr == nil { + return fmt.Errorf("目标已存在: %s", dst) + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) + } + if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { + return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) + } + if renameErr := skillPathRename(src, dst); renameErr == nil { + return nil + } else if !isCrossDeviceError(renameErr) { + return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) + } + + stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-") + if err != nil { + return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err) + } + stage := filepath.Join(stageRoot, "payload") + stageCleaned := false + defer func() { + if stageCleaned { + return + } + makeSkillPathTreeWritable(stageRoot) + if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) + } + }() + + if err := skillPathCopy(src, stage); err != nil { + return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err) + } + if err := skillPathVerify(src, stage); err != nil { + return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) + } + stageInfo, err := os.Lstat(stage) + if err != nil { + return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) + } + if stageInfo.IsDir() { + // Darwin refuses to rename a read-only directory even when both parent + // directories are writable. The exact source mode was verified above; + // temporarily add owner access for publication, then restore it before + // the final verification and before the source is removed. + if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil { + return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) + } + } + if err := skillPathRename(stage, dst); err != nil { + return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) + } + if stageInfo.IsDir() { + if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil { + return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err) + } + } + if err := skillPathVerify(src, dst); err != nil { + return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err) + } + if err := skillPathRemoveAll(stageRoot); err != nil { + return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err) + } + stageCleaned = true + if err := removePublishedSkillSource(src); err != nil { + return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err) + } + return nil +} + +type skillPathDirMode struct { + path string + mode os.FileMode +} + +func removePublishedSkillSource(src string) error { + dirModes, err := prepareSkillPathTreeRemoval(src) + if err != nil { + return err + } + removeErr := skillPathRemoveAll(src) + if removeErr == nil { + if _, statErr := os.Lstat(src); os.IsNotExist(statErr) { + return nil + } else if statErr == nil { + removeErr = errors.New("源路径仍存在") + } else { + removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr) + } + } + return errors.Join(removeErr, restoreSkillPathDirModes(dirModes)) +} + +func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { + var modes []skillPathDirMode + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode&0o700 == 0o700 { + return nil + } + if err := skillPathChmod(path, mode|0o700); err != nil { + return err + } + modes = append(modes, skillPathDirMode{path: path, mode: mode}) + return nil + }) + if err != nil { + return nil, errors.Join(err, restoreSkillPathDirModes(modes)) + } + return modes, nil +} + +func restoreSkillPathDirModes(modes []skillPathDirMode) error { + var restoreErr error + for i := len(modes) - 1; i >= 0; i-- { + item := modes[i] + if _, err := os.Lstat(item.path); os.IsNotExist(err) { + continue + } else if err != nil { + restoreErr = errors.Join(restoreErr, err) + continue + } + if err := skillPathChmod(item.path, item.mode); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err)) + } + } + return restoreErr +} + +func makeSkillPathTreeWritable(root string) { + // Best effort only: RemoveAll below remains the source of truth and its + // error is returned. This preparation lets it traverse read-only staging + // directories left by a copy or verification failure. + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr == nil && entry.IsDir() { + _ = os.Chmod(path, 0o700) + } + return nil + }) +} + +// RestoreSkillPath restores a previously recorded backup using the same +// rename-first, verified cross-device fallback as backup publication. +func RestoreSkillPath(backup, original string) error { + return moveSkillPathRecoverably(backup, original) +} + +func copySkillPathLexically(src, dst string) error { + info, err := os.Lstat(src) + if err != nil { + return err + } + switch { + case info.IsDir(): + // Staging must remain writable while children are copied. Restore the + // source mode only after the directory is complete so read-only Skill + // directories (for example 0555) can still be migrated lexically. + if err := os.Mkdir(dst, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { + return err + } + } + return os.Chmod(dst, info.Mode().Perm()) + case info.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(src) + if err != nil { + return err + } + return os.Symlink(target, dst) + case info.Mode().IsRegular(): + return copyRegularSkillFile(src, dst, info.Mode().Perm()) + default: + return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode()) + } +} + +func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { err = errors.Join(err, in.Close()) }() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { err = errors.Join(err, out.Close()) }() + if _, err := io.Copy(out, in); err != nil { + return err + } + if err := out.Sync(); err != nil { + return err + } + return os.Chmod(dst, mode) +} + +func verifySkillPathCopy(src, dst string) error { + srcInfo, err := os.Lstat(src) + if err != nil { + return err + } + dstInfo, err := os.Lstat(dst) + if err != nil { + return err + } + if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType { + return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode()) + } + switch { + case srcInfo.IsDir(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + srcNames, err := skillPathEntryNames(src) + if err != nil { + return err + } + dstNames, err := skillPathEntryNames(dst) + if err != nil { + return err + } + if len(srcNames) != len(dstNames) { + return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames)) + } + for i, name := range srcNames { + if name != dstNames[i] { + return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i]) + } + if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil { + return err + } + } + return nil + case srcInfo.Mode()&os.ModeSymlink != 0: + srcTarget, err := os.Readlink(src) + if err != nil { + return err + } + dstTarget, err := os.Readlink(dst) + if err != nil { + return err + } + if srcTarget != dstTarget { + return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget) + } + return nil + case srcInfo.Mode().IsRegular(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + if srcInfo.Size() != dstInfo.Size() { + return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size()) + } + srcDigest, err := skillPathFileDigest(src) + if err != nil { + return err + } + dstDigest, err := skillPathFileDigest(dst) + if err != nil { + return err + } + if srcDigest != dstDigest { + return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst) + } + return nil + default: + return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode()) + } +} + +func skillPathEntryNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func skillPathFileDigest(path string) ([sha256.Size]byte, error) { + f, err := os.Open(path) + if err != nil { + return [sha256.Size]byte{}, err + } + defer f.Close() + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return [sha256.Size]byte{}, err + } + var digest [sha256.Size]byte + copy(digest[:], hash.Sum(nil)) + return digest, nil +} diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go new file mode 100644 index 00000000..5567d1c2 --- /dev/null +++ b/internal/upgrade/skill_path_move_test.go @@ -0,0 +1,449 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) { + t.Run("directory file and lexical links", func(t *testing.T) { + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil { + t.Fatal(err) + } + linksCreated := true + if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil { + if runtime.GOOS != "windows" { + t.Fatal(err) + } + linksCreated = false + } + if linksCreated { + if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil { + t.Fatal(err) + } + } + + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" { + t.Fatalf("copied file = %q, %v", got, err) + } + if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil || info.Mode().Perm() != 0o640 { + t.Fatalf("copied file mode = %v, %v", info, err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(dst, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("regular file root", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "source-file") + dst := filepath.Join(root, "backup", "source-file") + if err := os.WriteFile(src, []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(dst); err != nil || string(got) != "state" { + t.Fatalf("backup file = %q, %v", got, err) + } + }) + + t.Run("read-only directory mode is restored after copying children", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "backup", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil { + t.Fatal(err) + } + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("copied directory mode = %v, %v", info, err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" { + t.Fatalf("copied read-only file = %q, %v", got, err) + } + }) + + t.Run("rollback uses the same cross-device contract", func(t *testing.T) { + root := t.TempDir() + backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat") + if err := os.MkdirAll(backup, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, backup, original) + if err := RestoreSkillPath(backup, original); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(backup); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed after verified restore: %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { + t.Run("non cross-device rename never copies", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + copied := false + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + if copied { + t.Fatal("permission failure must not enter cross-device copy fallback") + } + assertSkillSourcePreserved(t, src) + }) + + for _, tc := range []struct { + name string + copy func(string, string) error + verify func(string, string) error + }{ + {name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }}, + {name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + if tc.copy != nil { + testseam.Swap(t, &skillPathCopy, tc.copy) + } + if tc.verify != nil { + testseam.Swap(t, &skillPathVerify, tc.verify) + } + if err := moveSkillPathRecoverably(src, dst); err == nil { + t.Fatal("expected failure") + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); !os.IsNotExist(err) { + t.Fatalf("formal backup must not publish: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + } + + t.Run("staging publish failure", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(from, to string) error { + if from == src && to == dst { + return testCrossDeviceError() + } + if to == dst { + return errors.New("publish failed") + } + return originalRename(from, to) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("published verification failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + calls := 0 + testseam.Swap(t, &skillPathVerify, func(string, string) error { + calls++ + if calls == 2 { + return errors.New("published verify failed") + } + return nil + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("verified backup candidate must remain: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("source removal failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("formal backup must remain: %v", err) + } + }) + + t.Run("read-only source mode is restored after removal failure", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("source directory mode after failed removal = %v, %v", info, err) + } + if err := os.Chmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("source permission preparation failure preserves both", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalChmod := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if path == src { + return errors.New("chmod failed") + } + return originalChmod(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + if err := originalChmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("lying remover is detected", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return nil + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) + + t.Run("same-filesystem fast path", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source still exists: %v", err) + } + }) + + t.Run("existing destination is never overwritten", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) +} + +func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { + t.Run("type", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + t.Run("directory entries", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") { + t.Fatalf("error = %v", err) + } + }) + t.Run("file size and digest", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("same"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") { + t.Fatalf("error = %v", err) + } + }) + t.Run("symlink target", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Symlink("one", src); err != nil { + if runtime.GOOS == "windows" { + t.Skip("symlink creation unavailable") + } + t.Fatal(err) + } + if err := os.Symlink("two", dst); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") { + t.Fatalf("error = %v", err) + } + }) +} + +func forceCrossDeviceRename(t *testing.T, src, dst string) { + t.Helper() + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(from, to string) error { + if from == src && to == dst { + return testCrossDeviceError() + } + return originalRename(from, to) + }) +} + +func makeSkillMoveFixture(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + return src, dst +} + +func assertSkillSourcePreserved(t *testing.T, src string) { + t.Helper() + if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("source content = %q, %v", got, err) + } +} + +func assertNoCrossDeviceStage(t *testing.T, dst string) { + t.Helper() + entries, err := os.ReadDir(filepath.Dir(dst)) + if err != nil { + if os.IsNotExist(err) { + return + } + t.Fatal(err) + } + prefix := "." + filepath.Base(dst) + ".cross-device-" + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + t.Fatalf("cross-device staging leaked: %s", entry.Name()) + } + } +} diff --git a/internal/upgrade/skill_path_move_unix.go b/internal/upgrade/skill_path_move_unix.go new file mode 100644 index 00000000..587c8217 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "errors" + "syscall" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, syscall.EXDEV) +} diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go new file mode 100644 index 00000000..e5ccde16 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "os" + "syscall" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} +} diff --git a/internal/upgrade/skill_path_move_windows.go b/internal/upgrade/skill_path_move_windows.go new file mode 100644 index 00000000..f43fe688 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package upgrade + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE) +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go new file mode 100644 index 00000000..401e4ed0 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -0,0 +1,20 @@ +//go:build windows + +package upgrade + +import ( + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} +} + +func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { + if !isCrossDeviceError(testCrossDeviceError()) { + t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") + } +} diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 2ed13bee..e8cc3bce 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -109,13 +109,201 @@ function Test-PathLexically([string]$path) { Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) } -function Backup-DevSkill([string]$path) { +function Move-DevSkillPath([string]$Source, [string]$Destination) { + Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} + +function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) { + $exception = $Record.Exception + while ($null -ne $exception) { + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-DevSkillMetadata($SourceItem, [string]$Destination) { + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-DevSkillPermissionFingerprint([string]$Path) { + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) { + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name) + } + Copy-DevSkillMetadata $item $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-DevSkillMetadata $item $Destination +} + +function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) { + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill path type mismatch: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill link target mismatch: $Source != $Destination" + } + return + } + if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) { + throw "Skill path permissions mismatch: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" } + Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" } + if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne + (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) { + throw "Skill file digest mismatch: $Source != $Destination" + } +} + +function Remove-DevSkillPathLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { + if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-DevSkillPath $Source $Destination + return + } catch { + if (!(Test-DevCrossDeviceMoveError $_)) { throw } + } + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Move-DevSkillPath $stage $Destination + Assert-DevSkillPathCopy $Source $Destination + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + try { Remove-DevSkillPathLexically $Source } catch { + throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" + } + if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + try { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop } catch { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot`: $_" + } + } + throw $failure + } +} + +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + if ($null -ne $BackupPath) { $BackupPath.Value = "" } if (!(Test-PathLexically $path)) { return } $backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))" New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null $target = Join-Path $backupRoot (Split-Path $path -Leaf) while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" } - Move-Item -LiteralPath $path -Destination $target -ErrorAction Stop + Move-DevSkillPathRecoverably $path $target + if ($null -ne $BackupPath) { $BackupPath.Value = $target } +} + +function Restore-DevSkillBackup([string]$Destination, [string]$Backup) { + if (Test-PathLexically $Destination) { Remove-DevSkillPathLexically $Destination } + if (![string]::IsNullOrWhiteSpace($Backup)) { + Move-DevSkillPathRecoverably $Backup $Destination + } +} + +function Publish-DevSkillCopy([string]$Source, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + Assert-DevSkillPathCopy $Source $Destination + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup } catch { + throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + } + throw $publishFailure + } + } finally { + if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +function Publish-DevSkillJunction([string]$Target, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" } + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup } catch { + throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + } + throw $publishFailure + } + } finally { + if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + } } function Get-Arch { @@ -133,6 +321,26 @@ function Get-Arch { } } +function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) { + $checksums = Join-Path $TempDir "checksums.txt" + if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) { + try { + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" ` + -OutFile $checksums -UseBasicParsing -ErrorAction Stop + } catch { + Die "Could not download checksums.txt; refusing unverified release assets." + } + } + $line = Get-Content -LiteralPath $checksums | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $line) { Die "$AssetName is missing from checksums.txt." } + $expected = ($line -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." } + Say "SHA256 checksum verified: $AssetName" +} + # Read the releases list (newest first) and take the top tag, so this also works # if a release is ever published as a prerelease (which /releases/latest skips). if (-not $Version) { @@ -163,6 +371,7 @@ try { Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" ` -OutFile $zip -UseBasicParsing } catch { Die "Binary download failed - does release $Version have $asset?" } +Assert-DevReleaseAssetChecksum $zip $asset $tmp Expand-Archive -Path $zip -DestinationPath $tmp -Force $exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1 @@ -177,6 +386,7 @@ if (-not $NoSkills) { $skzip = Join-Path $tmp "dws-skills.zip" Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" ` -OutFile $skzip -UseBasicParsing + Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp $skdir = Join-Path $tmp "sk" Expand-Archive -Path $skzip -DestinationPath $skdir -Force @@ -188,15 +398,11 @@ if (-not $NoSkills) { if ($src) { # cache so `dws skill setup --mode multi` can find a source later $cache = Join-Path $HOME ".dws\skills\multi\$SkillName" - if (Test-Path $cache) { Remove-Item -Recurse -Force $cache } - New-Item -ItemType Directory -Path $cache -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force + Publish-DevSkillCopy $src $cache $canonicalBase = Join-Path $HOME ".agents\skills" $canonical = Join-Path $canonicalBase $SkillName - Backup-DevSkill $canonical - New-Item -ItemType Directory -Path $canonical -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $canonical -Recurse -Force + Publish-DevSkillCopy $src $canonical $installed = 1 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { @@ -209,9 +415,8 @@ if (-not $NoSkills) { $dest = Join-Path $base $SkillName if ($universal) { Backup-DevSkill $dest; continue } New-Item -ItemType Directory -Path $base -Force | Out-Null - Backup-DevSkill $dest - try { New-Item -ItemType Junction -Path $dest -Target ([System.IO.Path]::GetFullPath($canonical)) -ErrorAction Stop | Out-Null } - catch { New-Item -ItemType Directory -Path $dest -Force | Out-Null; Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force } + try { Publish-DevSkillJunction $canonical $dest } + catch { Publish-DevSkillCopy $canonical $dest } $installed++ } Say "Skill dingtalk-misc -> $installed agent dir(s)" @@ -219,7 +424,7 @@ if (-not $NoSkills) { Say "(dingtalk-misc not found in skills bundle; skipped)" } } catch { - Say "(skill install skipped: $($_.Exception.Message))" + Die "Skill install failed: $($_.Exception.Message)" } } diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index cb4c6802..70a631ad 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "✅ SHA256 checksum verified: ${name}" +} + copy_tree() { src="$1"; dest="$2"; parent="$(dirname "$dest")" mkdir -p "$parent" || return 1 @@ -33,7 +55,10 @@ copy_tree() { if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage" "$dest"; then return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -106,7 +131,10 @@ link_or_copy_skill() { if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -202,6 +230,7 @@ main() { say "⬇ Downloading ${asset} ..." curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp" else @@ -215,6 +244,7 @@ main() { # 2) dev skill from the release's skills bundle if [ "$NO_SKILLS" != "1" ]; then if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then + verify_release_asset "dws-skills.zip" "$tmp/skills.zip" mkdir -p "$tmp/sk" if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi say "" diff --git a/scripts/install-event.sh b/scripts/install-event.sh index a046b865..58c56cda 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "SHA256 checksum verified: ${name}" +} + detect_os() { case "$(uname -s)" in Linux*) echo linux ;; @@ -283,6 +305,7 @@ install_binary() { say "Downloading ${asset} ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then extract_zip "$tmp/$asset" "$tmp/bin" @@ -311,6 +334,7 @@ install_skills() { say "Downloading dws-skills.zip ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \ || err "dws-skills.zip download failed for release ${EVENT_VERSION}" + verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip" mkdir -p "$tmp/skills" extract_zip "$tmp/dws-skills.zip" "$tmp/skills" diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 2659cd60..4a7c486b 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -123,6 +123,29 @@ sha256_stdin() { fi } +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; } + curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || { + printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2 + return 1 + } + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; } + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || { + printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2 + return 1 + } + [ "$_checksum_actual" = "$_checksum_expected" ] || { + printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2 + return 1 + } + printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -813,6 +836,7 @@ main() { [ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; } printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION" curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" + verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK" extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7d6cb4c5..795ee1be 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -261,6 +261,161 @@ function Move-SkillPath { Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop } +function Test-CrossDeviceMoveError { + param([System.Management.Automation.ErrorRecord]$Record) + $exception = $Record.Exception + while ($null -ne $exception) { + # Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as + # the low word of an IOException HRESULT. + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-SkillPathMetadata { + param($SourceItem, [string]$Destination) + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-SkillPathPermissionFingerprint { + param([string]$Path) + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl + } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name) + } + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { + throw "不支持复制特殊 Skill 路径 $Source" + } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination +} + +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill 路径类型不一致: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill 链接目标不一致: $Source != $Destination" + } + return + } + if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne + (Get-SkillPathPermissionFingerprint -Path $Destination)) { + throw "Skill 路径权限不一致: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { + throw "Skill 目录项数量不一致: $Source != $Destination" + } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { + throw "Skill 目录项不一致: $Source != $Destination" + } + Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { + throw "Skill 文件大小不一致: $Source != $Destination" + } + $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash + $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash + if ($sourceHash -ne $destinationHash) { + throw "Skill 文件内容摘要不一致: $Source != $Destination" + } +} + +function Remove-SkillPathLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on +# the destination filesystem, copy links lexically, verify, publish, then +# remove the source. Before source removal every failure keeps the original; +# a removal failure deliberately leaves both verified copies and fails loud. +function Move-SkillPathRecoverably { + param([string]$Source, [string]$Destination) + if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-SkillPath -Source $Source -Destination $Destination + return + } catch { + if (!(Test-CrossDeviceMoveError -Record $_)) { throw } + } + + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-SkillPathLexically -Source $Source -Destination $stage + Assert-SkillPathCopy -Source $Source -Destination $stage + Move-SkillPath -Source $stage -Destination $Destination + Assert-SkillPathCopy -Source $Source -Destination $Destination + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + try { + Remove-SkillPathLexically -Path $Source + } catch { + throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_" + } + if (Test-SkillPathLexically -Path $Source) { + throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)" + } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + try { + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + } catch { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot`: $_" + } + } + throw $failure + } +} + function Get-Arch { # Allow manual override via environment variable if ($env:DWS_ARCH) { @@ -340,6 +495,26 @@ function Get-GiteeAssetUrl { return "" } +function Assert-ReleaseAssetChecksum { + param([string]$AssetPath, [string]$AssetName, [string]$TempDir) + if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } + if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." } + $checksumPath = Join-Path $TempDir "checksums.txt" + try { + Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop + } catch { + Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName." + } + $expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." } + $expected = ($expectedLine -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." } + Write-Say "✅ SHA256 checksum verified: $AssetName" +} + function Resolve-Source { # Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable. if ($GiteeRepo -ne "") { return } @@ -482,7 +657,7 @@ function Backup-SkillDir { $name = Split-Path $Dir -Leaf $target = Join-Path (Join-Path $backupRoot $stamp) $name $i = 1 - while (Test-Path $target) { + while (Test-SkillPathLexically -Path $target) { $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name $i++ if ($i -gt 1000) { @@ -492,9 +667,9 @@ function Backup-SkillDir { } try { New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $Dir -Destination $target + Move-SkillPathRecoverably -Source $Dir -Destination $target } catch { - Write-Say "⚠️ 备份失败,保留原目录 $Dir" + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" return $false } if ($null -ne $BackupPath) { $BackupPath.Value = $target } @@ -525,7 +700,7 @@ function Restore-MultiSkillSet { throw "恢复目标仍存在" } New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $item.Backup -Destination $item.Original + Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original } catch { Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_" $ok = $false @@ -811,26 +986,7 @@ function Install-Binary { $archivePath = Join-Path $tmpDir $archiveName Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing - # Download and verify SHA256 checksum - if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } - try { - $checksumPath = Join-Path $tmpDir "checksums.txt" - Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing - $checksumContent = Get-Content $checksumPath - $expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) } - if ($expectedLine) { - $expected = ($expectedLine -split '\s+')[0] - $actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected.ToLower()) { - Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting." - } - Write-Say "✅ SHA256 checksum verified" - } else { - Write-Say "⚠️ Archive not found in checksums.txt; skipping verification" - } - } catch { - Write-Say "⚠️ Could not download checksums.txt; skipping verification" - } + Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir Write-Say "📦 Extracting..." Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force @@ -1256,6 +1412,7 @@ function Install-Skills { Write-Err "Cannot download skills from GitHub and no local source checkout found." } } + Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir $extractRoot = Join-Path $tmpDir "skills" Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force diff --git a/scripts/install.sh b/scripts/install.sh index 84df3ea5..b494a402 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -141,6 +141,23 @@ sha256_stdin() { fi } +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}." + download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \ + err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}." + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt." + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || \ + err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl." + [ "$_checksum_actual" = "$_checksum_expected" ] || \ + err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}." + say "✅ SHA256 checksum verified: ${_checksum_asset}" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -1085,32 +1102,7 @@ install_binary() { download "$download_url" "$tmpdir/$archive_name" - # Download and verify SHA256 checksum - checksum_url="$(asset_url checksums.txt)" - if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then - expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")" - if [ -n "$expected" ]; then - if need_cmd sha256sum; then - actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')" - elif need_cmd shasum; then - actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')" - else - actual="" - fi - if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then - err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting." - fi - if [ -n "$actual" ]; then - say "✅ SHA256 checksum verified" - else - say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification" - fi - else - say "⚠️ Archive not found in checksums.txt; skipping verification" - fi - else - say "⚠️ Could not download checksums.txt; skipping verification" - fi + verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir" say "📦 Extracting..." tar xzf "$tmpdir/$archive_name" -C "$tmpdir" @@ -1175,6 +1167,8 @@ install_skills() { fi fi + verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills" + extract_root="$tmpdir_skills/skills" mkdir -p "$extract_root" if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 04533c5a..ba0ad970 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -65,7 +65,10 @@ const { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + backupAndRemoveSkillDir, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, } = require(installJsSource); @@ -126,6 +129,175 @@ function writeManagedState(home, names) { writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`); } +function crossDeviceError() { + return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" }); +} + +function runCrossDeviceMoveContract() { + const roots = []; + const fixture = (name) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `dws-installjs-exdev-${name}-`)); + roots.push(root); + const src = path.join(root, "external", "skill"); + const dest = path.join(root, "home", ".dws", "skill-backups", "stamp", "skill"); + writeFile(path.join(src, "SKILL.md"), "old skill\n", 0o640); + return { root, src, dest }; + }; + const injectedRename = (source, target) => { + if (path.basename(source) === "skill" && path.basename(target) === "skill") { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }; + try { + { + const { src, dest } = fixture("success"); + fs.symlinkSync("SKILL.md", path.join(src, "skill-link")); + fs.symlinkSync("missing.md", path.join(src, "dangling-link")); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.lstatSync(dest).isDirectory(), true); + assert.equal(fs.statSync(path.join(dest, "SKILL.md")).mode & 0o777, 0o640); + assert.equal(fs.lstatSync(path.join(dest, "skill-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "skill-link")), "SKILL.md"); + assert.equal(fs.lstatSync(path.join(dest, "dangling-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "dangling-link")), "missing.md"); + assert.equal(fs.existsSync(src), false, "verified EXDEV move removes source last"); + + const restored = path.join(path.dirname(src), "restored-skill"); + movePathRecoverablySync(dest, restored, { + renameFn(source, target) { + if (source === dest && target === restored) throw crossDeviceError(); + fs.renameSync(source, target); + }, + }); + assert.equal(fs.readFileSync(path.join(restored, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "EXDEV rollback consumes verified backup"); + } + + for (const [name, options] of [ + ["copy", { copyFn() { throw new Error("copy failed"); } }], + ["verify", { verifyFn() { throw new Error("verify failed"); } }], + ]) { + const { root, src, dest } = fixture(name); + assert.throws( + () => movePathRecoverablySync(src, dest, { renameFn: injectedRename, ...options }), + new RegExp(`${name} failed`), + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false); + const backupParent = path.dirname(dest); + if (fs.existsSync(backupParent)) { + assert.equal( + fs.readdirSync(backupParent).some((entry) => entry.startsWith(".skill.cross-device-")), + false, + `${name} failure cleans destination-filesystem staging`, + ); + } + assert.ok(root); + } + + { + const { src, dest } = fixture("remove"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const { src, dest } = fixture("read-only"); + fs.chmodSync(src, 0o555); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.statSync(dest).mode & 0o777, 0o555, "read-only directory mode is restored after publish"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("read-only-remove-failure"); + fs.chmodSync(src, 0o555); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.statSync(src).mode & 0o777, 0o555, "failed removal restores source directory mode"); + fs.chmodSync(src, 0o755); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("permission"); + let copied = false; + const permission = Object.assign(new Error("permission denied"), { code: "EACCES" }); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn() { throw permission; }, + copyFn() { copied = true; }, + }), + /permission denied/, + ); + assert.equal(copied, false, "EACCES must not be mistaken for EXDEV"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-")); + roots.push(root); + const home = path.join(root, "home"); + const victim = path.join(home, ".agents", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + const stamp = "20260813-150000"; + const occupied = path.join(home, ".dws", "skill-backups", stamp, ".agents-skills-dingtalk-chat"); + fs.mkdirSync(path.dirname(occupied), { recursive: true }); + fs.symlinkSync("missing-backup", occupied); + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }); + assert.ok(backup.includes(`${stamp}-1`), "dangling backup target selects a numbered sibling"); + assert.equal(fs.readlinkSync(occupied), "missing-backup", "dangling collision is never overwritten"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-link-rollback-")); + roots.push(root); + const home = path.join(root, "home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const victim = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "new\n"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [victim], { + renameFn(source, target) { + if (source === victim || (source.includes(`${path.sep}skill-backups${path.sep}`) && target === victim)) { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }, + publishRenameFn() { throw new Error("link publish failed"); }, + }), + /link publish failed/, + ); + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old\n"); + } + } finally { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + } +} + // stagePkg builds a fake npm package whose assets/dws-skills.zip contains // exactly the given zip entries ({ "relative/path": "content" }) plus any // listed empty directories. Returns { tmp, pkg, home }. @@ -300,33 +472,54 @@ scenario("upstream Agent roots honor XDG and custom homes with relative links", }); const xdg = path.join(tmp, "xdg config"); const autohand = path.join(tmp, "autohand home"); + const claude = path.join(tmp, "claude config"); const codex = path.join(tmp, "codex home"); + const hermes = path.join(tmp, "hermes home"); try { writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n"); writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n"); writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n"); writeFile(path.join(autohand, "config.json"), "{}\n"); + writeFile(path.join(claude, "config.json"), "{}\n"); + writeFile(path.join(claude, "skills", "dingtalk-chat", "SKILL.md"), "old claude copy\n"); writeFile(path.join(codex, "config.toml"), "model=test\n"); writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + writeFile(path.join(hermes, "config.json"), "{}\n"); + writeFile(path.join(hermes, "skills", "dingtalk-chat", "SKILL.md"), "old hermes copy\n"); writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n"); writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n"); const res = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: xdg, AUTOHAND_HOME: autohand, + CLAUDE_CONFIG_DIR: claude, CODEX_HOME: codex, + HERMES_HOME: hermes, }); assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); for (const linked of [ path.join(xdg, "goose", "skills", "dingtalk-chat"), path.join(autohand, "skills", "dingtalk-chat"), + path.join(claude, "skills", "dingtalk-chat"), + path.join(hermes, "skills", "dingtalk-chat"), path.join(home, ".qoderwork", "skills", "dingtalk-chat"), ]) { assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`); assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`); assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); } + const backupText = []; + const collectBackupText = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) collectBackupText(full); + else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); + } + }; + collectBackupText(path.join(home, ".dws", "skill-backups")); + assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up"); + assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up"); for (const retired of [ path.join(xdg, "agents", "skills", "dingtalk-chat"), path.join(codex, "skills", "dingtalk-chat"), @@ -518,6 +711,13 @@ scenario("multi set publish failure restores the complete previous set", () => { [first, second], { renameFn(src, dest) { + if ( + src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (dest === first || dest === second)) + ) { + throw crossDeviceError(); + } if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") { throw new Error("injected second publish failure"); } @@ -601,6 +801,14 @@ for (const failureKind of ["backup", "publish"]) { if (failureKind === "backup" && src === second) { throw new Error("injected second backup failure"); } + if ( + failureKind === "publish" && + (src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (target === first || target === second))) + ) { + throw crossDeviceError(); + } if ( failureKind === "publish" && src.includes(".dws-mono-set.tmp-") && @@ -626,6 +834,10 @@ for (const failureKind of ["backup", "publish"]) { }); } +process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... "); +runCrossDeviceMoveContract(); +process.stdout.write("ok\n"); + for (const [name, fn] of scenarios) { process.stdout.write(`• ${name} ... `); fn(); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 5ab08442..44620d0a 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "crypto/sha256" + "encoding/hex" "os" "os/exec" "path/filepath" @@ -214,13 +216,41 @@ func TestInstallPowerShellScriptInstallsToAgentsDir(t *testing.T) { } } +func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required string + forbidden string + }{ + {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, + {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + if !strings.Contains(text, check.required) { + t.Errorf("%s missing explicit failure contract %q", check.path, check.required) + } + if strings.Contains(text, check.forbidden) { + t.Errorf("%s still contains silent failure path %q", check.path, check.forbidden) + } + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() for _, rel := range []string{ filepath.Join("..", "..", "scripts", "install.sh"), filepath.Join("..", "..", "scripts", "install-event.sh"), + filepath.Join("..", "..", "scripts", "install-devapp.sh"), filepath.Join("..", "..", "scripts", "install.ps1"), + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), filepath.Join("..", "..", "scripts", "install-skills.sh"), } { scriptPath, err := filepath.Abs(rel) @@ -237,6 +267,9 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { if !strings.Contains(text, "releases/download") || !strings.Contains(text, "dws-skills.zip") { t.Fatalf("%s should download dws-skills.zip from GitHub Releases", scriptPath) } + if !strings.Contains(text, "checksums.txt") || !strings.Contains(strings.ToLower(text), "sha256") { + t.Fatalf("%s should verify dws-skills.zip against release checksums", scriptPath) + } if strings.Contains(text, "archive/refs/heads/main.tar.gz") || strings.Contains(text, "archive/refs/tags/") { t.Fatalf("%s should not download skills from repository archive refs", scriptPath) } @@ -319,6 +352,7 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", "SKILL.md": "legacy mono root\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, dir := range []string{ @@ -439,6 +473,7 @@ func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) @@ -484,6 +519,7 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { "multi/dingtalk-shared/SKILL.md": "new shared\n", "mono/SKILL.md": "new mono\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) existing := filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md") @@ -518,6 +554,46 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { } } +func TestInstallEventChecksumMismatchPreservesExistingSkills(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + root := t.TempDir() + home := filepath.Join(root, "home") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "new event\n", + "multi/dingtalk-shared/SKILL.md": "new shared\n", + "multi/dingtalk-misc/SKILL.md": "new misc\n", + "mono/SKILL.md": "new mono\n", + }) + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Repeat("0", 64)+" dws-skills.zip\n"), 0o644) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + existing := filepath.Join(home, ".agents", "skills", "dingtalk-event", "SKILL.md") + mustWriteFile(t, existing, []byte("old event stays\n"), 0o644) + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_SKILLS_ONLY=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME=unused", + ) + output, runErr := cmd.CombinedOutput() + if runErr == nil || !strings.Contains(string(output), "checksum mismatch") { + t.Fatalf("checksum mismatch result = %v\n%s", runErr, output) + } + if got, err := os.ReadFile(existing); err != nil || string(got) != "old event stays\n" { + t.Fatalf("existing Skill after checksum mismatch = %q, %v", got, err) + } +} + func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -540,6 +616,7 @@ func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc"} { mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md"), []byte("keep "+name+"\n"), 0o644) @@ -596,6 +673,7 @@ func TestInstallEventScriptDefaultsToLatestStableRelease(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) writeFakeGH(t, filepath.Join(stubRoot, "gh"), "v1.0.51") @@ -1195,6 +1273,82 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" } } +func TestInstallSkillsShellMvCrossFilesystemBackupAndRestore(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("real cross-filesystem mv fixture uses Linux /dev/shm") + } + sharedMemoryRoot, err := os.MkdirTemp("/dev/shm", "dws-shell-exdev-") + if err != nil { + t.Skipf("/dev/shm is unavailable: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(sharedMemoryRoot) }) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatal("install-skills.sh final main invocation not found") + } + library := filepath.Join(t.TempDir(), "install-skills-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + source := filepath.Join(t.TempDir(), "dingtalk-chat") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + probe := filepath.Join(sharedMemoryRoot, "rename-probe") + if err := os.Rename(source, probe); err == nil { + if restoreErr := os.Rename(probe, source); restoreErr != nil { + t.Fatal(restoreErr) + } + t.Skip("/dev/shm and the test temp directory share a filesystem") + } else if !strings.Contains(strings.ToLower(err.Error()), "cross-device") { + t.Skipf("could not establish a cross-filesystem fixture: %v", err) + } + + home := filepath.Join(sharedMemoryRoot, "home") + manifest := filepath.Join(sharedMemoryRoot, "backups.manifest") + harness := `. "$DWS_TEST_LIBRARY" +backup_and_remove_skill_dir "$DWS_TEST_SOURCE" +backup=$DWS_LAST_SKILL_BACKUP +[ -n "$backup" ] && [ -d "$backup" ] && [ ! -e "$DWS_TEST_SOURCE" ] +printf '%s\n%s\n' "$DWS_TEST_SOURCE" "$backup" > "$DWS_TEST_MANIFEST" +restore_multi_skill_set /dev/null "$DWS_TEST_MANIFEST" +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_MANIFEST="+manifest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-filesystem shell backup/restore failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored shell content = %q, %v", got, err) + } + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(source, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("shell %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(source, name)); err != nil || got != want { + t.Fatalf("shell %s target = %q, %v; want %q", name, got, err, want) + } + } +} + func TestInstallSkillsShellPreservesUnregisteredDingtalkSkill(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) if err != nil { @@ -2196,6 +2350,295 @@ exit 0 } } +func TestInstallPowerShellCrossDeviceMoveContract(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + + for _, phase := range []string{"success", "copy", "verify", "remove", "permission"} { + phase := phase + t.Run(phase, func(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "skill") + destination := filepath.Join(home, ".dws", "skill-backups", "stamp", "skill") + restored := filepath.Join(root, "external", "restored-skill") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + linksCreated := false + if phase == "success" { + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + } + + harness := prefix + ` +$script:OriginalCopySkillPathLexically = ${function:Copy-SkillPathLexically} +$script:OriginalAssertSkillPathCopy = ${function:Assert-SkillPathCopy} +$script:OriginalRemoveSkillPathLexically = ${function:Remove-SkillPathLexically} +function New-CrossDeviceError { return [System.IO.IOException]::new("injected cross-device rename", -2147024879) } +function Move-SkillPath { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "permission" -and $Source -eq $env:DWS_TEST_SOURCE) { + throw [System.UnauthorizedAccessException]::new("permission denied") + } + if (($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) -or + ($Source -eq $env:DWS_TEST_DESTINATION -and $Destination -eq $env:DWS_TEST_RESTORED)) { + throw (New-CrossDeviceError) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "copy") { throw "copy failed" } + if ($env:DWS_TEST_PHASE -eq "permission") { + [System.IO.File]::WriteAllText($env:DWS_TEST_COPY_MARKER, "called") + } + & $script:OriginalCopySkillPathLexically -Source $Source -Destination $Destination +} +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "verify") { throw "verify failed" } + & $script:OriginalAssertSkillPathCopy -Source $Source -Destination $Destination +} +function Remove-SkillPathLexically { + param([string]$Path) + if ($env:DWS_TEST_PHASE -eq "remove" -and $Path -eq $env:DWS_TEST_SOURCE) { throw "remove failed" } + & $script:OriginalRemoveSkillPathLexically -Path $Path +} +try { + Move-SkillPathRecoverably -Source $env:DWS_TEST_SOURCE -Destination $env:DWS_TEST_DESTINATION + if ($env:DWS_TEST_PHASE -ne "success") { exit 2 } + Move-SkillPathRecoverably -Source $env:DWS_TEST_DESTINATION -Destination $env:DWS_TEST_RESTORED + exit 0 +} catch { + if ($env:DWS_TEST_PHASE -eq "success") { Write-Error $_; exit 3 } + if ($env:DWS_TEST_PHASE -eq "copy" -and $_ -notmatch "copy failed") { Write-Error $_; exit 4 } + if ($env:DWS_TEST_PHASE -eq "verify" -and $_ -notmatch "verify failed") { Write-Error $_; exit 5 } + if ($env:DWS_TEST_PHASE -eq "remove" -and $_ -notmatch "均保留") { Write-Error $_; exit 6 } + if ($env:DWS_TEST_PHASE -eq "permission" -and $_ -notmatch "permission denied") { Write-Error $_; exit 7 } + exit 0 +} +` + harnessPath := filepath.Join(t.TempDir(), "install-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + copyMarker := filepath.Join(root, "copy-called") + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + "DWS_TEST_RESTORED="+restored, + "DWS_TEST_COPY_MARKER="+copyMarker, + "DWS_TEST_PHASE="+phase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell %s EXDEV harness failed: %v\n%s", phase, err, output) + } + + if phase == "success" { + if got, err := os.ReadFile(filepath.Join(restored, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed by rollback: %v", err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(restored, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(restored, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + return + } + + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("source after %s failure = %q, %v", phase, got, err) + } + if phase == "remove" { + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("published backup after remove failure = %q, %v", got, err) + } + } else if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("destination after %s failure = %v", phase, err) + } + if phase == "permission" { + if _, err := os.Stat(copyMarker); !os.IsNotExist(err) { + t.Fatalf("permission error must not trigger copy fallback: %v", err) + } + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".skill.cross-device-*")); err != nil || len(matches) != 0 { + t.Fatalf("PowerShell %s staging leftovers = %v, %v", phase, matches, err) + } + }) + } +} + +func TestInstallDevappPowerShellCrossDeviceBackup(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old dev skill\n"), 0o640) + linksCreated := false + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +Backup-DevSkill $env:DWS_TEST_SOURCE +if (Test-PathLexically $env:DWS_TEST_SOURCE) { exit 2 } +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_SOURCE="+source) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp EXDEV backup failed: %v\n%s", err, output) + } + backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n") + if backup == "" { + t.Fatal("PowerShell devapp backup missing") + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(backup, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("devapp %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(backup, name)); err != nil || got != want { + t.Fatalf("devapp %s target = %q, %v; want %q", name, got, err, want) + } + } + } +} + +func TestInstallDevappPowerShellPublishFailureRestoresAcrossDevice(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -match "[\\/].dws-dev-copy-[^\\/]+[\\/]payload$") { throw "injected publish failure" } + if ($Source -match "[\\/]skill-backups[\\/]" -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rollback", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_ -notmatch "injected publish failure") { Write-Error $_; exit 3 } +} +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publish-rollback-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publish rollback failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old dev skill\n" { + t.Fatalf("restored devapp content = %q, %v", got, err) + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".dws-dev-copy-*")); err != nil || len(matches) != 0 { + t.Fatalf("devapp publish staging leftovers = %v, %v", matches, err) + } +} + func TestInstallPowerShellMonoTransactionFailuresRestoreOldSet(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { @@ -2416,12 +2859,34 @@ done case "$url" in *"/${FAKE_ASSET_NAME}") cp "$FAKE_RELEASE_DIR/$FAKE_ASSET_NAME" "$out" ;; *"/dws-skills.zip") cp "$FAKE_RELEASE_DIR/dws-skills.zip" "$out" ;; + *"/checksums.txt") cp "$FAKE_RELEASE_DIR/checksums.txt" "$out" ;; *) echo "fake curl: unexpected URL $url" >&2; exit 1 ;; esac ` mustWriteFile(t, path, []byte(script), 0o755) } +func writeInstallerFixtureChecksums(t *testing.T, releaseDir string) { + t.Helper() + entries, err := os.ReadDir(releaseDir) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", releaseDir, err) + } + var lines []string + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "checksums.txt" { + continue + } + data, err := os.ReadFile(filepath.Join(releaseDir, entry.Name())) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", entry.Name(), err) + } + digest := sha256.Sum256(data) + lines = append(lines, hex.EncodeToString(digest[:])+" "+entry.Name()) + } + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + func writeFakeGH(t *testing.T, path, version string) { t.Helper() script := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n"