diff --git a/README.en.md b/README.en.md index 03bcf1a..cc06f15 100644 --- a/README.en.md +++ b/README.en.md @@ -294,6 +294,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis - Not printed with `--quiet` (progress is info level). Without `--json` the human-readable text is unchanged. stdout still carries only the final result line. - "Update" (`更新`) in the manager's "Settings": the manager downloads, verifies and stages the new version (instances keep running meanwhile); if another program runs this root's modavd (for example `modavd mcp`) or an instance is being created, it stops nothing and asks you to deal with that first; it then shuts down every running instance normally, then starts `tools\modavd.exe update install --after-pid --reopen [--reopen-arg ARG]...` and exits. The CLI waits for the manager to exit, swaps the new version in, writes the result to `.update\manager-result.json` and starts the manager again with its original arguments; the new manager shows the result when it opens. If the swap cannot be done, the manager is started again all the same, and the downloaded version is kept so it does not have to be downloaded again. The CLI's output goes to `logs\modavd-update.log`. `--after-pid`, `--reopen` and `--reopen-arg` only go with `install` and are meant for the manager. - On macOS, update settings and component download sources use `~/Library/Application Support/ModAVD/update-settings.json` and `components-settings.json`. The CDK is encrypted with AES-GCM using a random wrapping key kept in the current user's Keychain. A locked or unauthorized Keychain makes saving fail without plaintext storage; copying the settings to another user or device does not transfer that key. +- Native Apple Silicon builds accept `modavd update prepare --package /path/update.zip --sha256 <64-digit digest from a trusted independent source>`, followed by offline `modavd update install` or `recover`. Packages must follow the [macOS update contract](internal/update/MACOS.md); ARM64 Mach-O, code signature integrity, modes and symlinks are checked, with at least 20 GiB free space retained. Signature verification does not establish publisher identity. Native transactions are separate from the Windows/AUTO-MAS journal. Remote checks return `update_unavailable` when no compatible macOS release asset exists; release packaging is a later stage. - Only files from the package are swapped: `avd\`, `mas-avd.json` (only `components.emulator` is changed and an `updateHistory` entry is recorded), `sdk\platform-tools`, `sdk\system-images`, `logs\` and so on are never touched; `sdk\emulator` is replaced as a whole. A root folder that sets `sdkRoot` in `mas-avd.json` is not updated. - `--automas-version` is the version of the calling AUTO-MAS: if the first line of the new version's announcement requires a higher AUTO-MAS, it does not install. - Exit codes: being unable to check (offline, rate-limited, a GitHub error) is 9 (`update_unavailable`), a downloaded package that fails verification is also 9 (`update_rejected`), and a program in use or an update already in progress is 5. diff --git a/README.md b/README.md index 7398776..13d7187 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ Apple Silicon macOS 可运行 `ModAVD` 管理器;`ModAVD.app` 放在包根目 - `--quiet` 时不出(进度是 info 级)。不带 `--json` 时照旧是人看的文字。stdout 仍然只有最后一行结果。 - 管理器「设置」里点「更新」:管理器先下载、核对、暂存好(这时实例照常用);有别的程序在跑这个根目录的 modavd(比如 `modavd mcp`)或有实例正在新建,就一台都不关、提示先处理;再正常关掉所有在运行的实例,然后启动 `tools\modavd.exe update install --after-pid <管理器 pid> --reopen <管理器程序> [--reopen-arg 参数]...` 并退出。命令行等管理器退出、换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数重新打开管理器,新管理器打开时提示更新结果。换不上也照样重新打开,下载好的新版留着,下次不用重下。命令行的输出在 `logs\modavd-update.log`。`--after-pid`、`--reopen`、`--reopen-arg` 只和 `install` 一起用,是给管理器用的。 - macOS 的更新设置和组件下载源分别保存在 `~/Library/Application Support/ModAVD/update-settings.json` 和 `components-settings.json`。CDK 使用 AES-GCM 加密,随机包装密钥留在当前用户的 Keychain;钥匙串锁定或未授权时保存失败,不会降级为明文存储。复制设置文件到其他用户或设备不会同时转移密钥。 +- Apple Silicon 原生构建可用 `modavd update prepare --package /path/update.zip --sha256 <可信渠道取得的64位摘要>` 暂存本地包,再用 `modavd update install` 离线安装;`recover` 也可离线补完或恢复。包须符合 [macOS 更新包协议](internal/update/MACOS.md),核对 ARM64 Mach-O、代码签名完整性、模式和符号链接,并保留至少 20 GiB 可用空间。签名核对不代表发布者身份可信,SHA256 必须独立取得。原生事务与 Windows/AUTO-MAS 状态隔离;尚无符合协议的 macOS 发布资产时,远端检查返回 `update_unavailable`。发布打包流程由后续阶段接入。 - 只换包里的文件:`avd\`、`mas-avd.json`(只改 `components.emulator` 并记一条 `updateHistory`)、`sdk\platform-tools`、`sdk\system-images`、`logs\` 等一律不碰;`sdk\emulator` 整个换掉。根目录在 `mas-avd.json` 里设了 `sdkRoot` 的不更新。 - `--automas-version` 是调用方 AUTO-MAS 的版本:新版的公告第一行要求更高的 AUTO-MAS 时不装。 - 退出码:查不了(断网、限流、GitHub 出错)是 9(`update_unavailable`),下载的包没通过核对也是 9(`update_rejected`),有程序在用、正在更新是 5。 diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 737a8cb..5f63547 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -356,7 +356,7 @@ func init() { run: runVersion, human: humanOf(formatVersion), }, { - name: "update", args: "[status|check|prepare|install|recover] [--source github|mirrorchyan] [--cdk-dpapi BASE64] [--automas-version V] [--after-pid PID --reopen 程序 [--reopen-arg 参数]...]", + name: "update", args: "[status|check|prepare|install|recover] [--source github|mirrorchyan] [--cdk-dpapi BASE64] [--automas-version V] [--package ZIP --sha256 HEX] [--after-pid PID --reopen 程序 [--reopen-arg 参数]...]", summary: "更新这个根目录的 ModAVD 包(模拟器、组件、脚本和管理器)", details: "按管理器「设置」里选的更新源(GitHub 或 Mirror 酱,Mirror 酱下载要填 CDK);" + "调用方有自己的设置时用 --source 和 --cdk-dpapi 传这一次的,只盖过「设置」、不保存,status 里的 source、cdk_set 是这次生效的。" + @@ -368,12 +368,16 @@ func init() { "--automas-version 是调用方 AUTO-MAS 的版本,新版要求更高的 AUTO-MAS 时不装。" + "--after-pid、--reopen、--reopen-arg 是管理器「设置」里点「更新」时用的:管理器下载核对好、关掉实例后启动这条命令并退出," + "命令等它退出再换上新版,然后重新打开管理器。" + + "macOS 可用 --package ZIP --sha256 HEX 准备或安装本地完整签名包;摘要需从可信来源独立取得。" + "--json 时每个进度在 stderr 的日志行里多一个 progress 对象(checking、extracting、verifying、installing 开始时各一行,percent 为 -1;" + "downloading 第一行就带真实进度,之后约 2 秒一行,下完那行一定有):" + `{"stage":"checking|downloading|extracting|verifying|installing","percent":-1 或 0–100,"done_bytes":N,"total_bytes":N}。` + "查不了是退出码 9(update_unavailable),下载没通过核对也是 9(update_rejected),有程序在用是 5。", posNames: []string{"status、check、prepare、install 或 recover"}, maxPos: 1, - flags: []flagSpec{optUpdateSource, optCDKDPAPI, optAutoMAS, optAfterPID, optReopen, optReopenArg}, final: true, + flags: []flagSpec{optUpdateSource, optCDKDPAPI, optAutoMAS, + {name: "package", kind: kindString, arg: "ZIP", help: "macOS 本地完整更新包"}, + {name: "sha256", kind: kindString, arg: "HEX", help: "本地更新包的独立可信 SHA-256"}, + optAfterPID, optReopen, optReopenArg}, final: true, run: runUpdate, human: humanOf(formatUpdateStatus), }, { @@ -1447,6 +1451,9 @@ func runUpdate(c *cmdCtx) (any, error) { return nil, errs.Newf(errs.Usage, "只能是 status、check、prepare、install 或 recover,收到「%s」", c.inv.shownPos(c.inv.pos[0])). WithAdvice(findCommand("update").usageAdvice()) } + if (c.inv.strOpt("package") != "" || c.inv.strOpt("sha256") != "") && action != "prepare" && action != "install" { + return nil, errs.New(errs.Usage, "--package 和 --sha256 只用于 update prepare 或 install") + } afterPID, reopen, reopenArgs := c.inv.intOpt("after-pid"), c.inv.strOpt("reopen"), c.inv.strsOpt("reopen-arg") handoff := afterPID != 0 || reopen != "" || len(reopenArgs) > 0 switch { @@ -1460,7 +1467,8 @@ func runUpdate(c *cmdCtx) (any, error) { case reopen != "" && !filepath.IsAbs(reopen): return nil, errs.Newf(errs.Usage, "--reopen 要给程序的完整路径,收到「%s」", reopen) } - opt := update.Options{AutoMASVersion: c.inv.strOpt("automas-version")} + opt := update.Options{AutoMASVersion: c.inv.strOpt("automas-version"), + PackagePath: c.inv.strOpt("package"), PackageSHA256: c.inv.strOpt("sha256")} if err := updateCallerSettings(c.inv, action, &opt); err != nil { return nil, err } diff --git a/internal/download/download.go b/internal/download/download.go index f71fa10..cfd12ab 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -223,6 +223,9 @@ type FileOptions struct { Fresh bool Start func(offset int64, restarted bool) Progress func(done int64) + // BeforeWrite can stop a transfer before opening the output or writing the + // next chunk (for example when the target volume runs out of space). + BeforeWrite func() error } // File resumes from the existing file size and validates the response before writing. Success requires exactly @@ -271,6 +274,11 @@ func (c *Client) File(ctx context.Context, o FileOptions) error { return sizeError("文件大小和预期不符(服务器给 %d 字节,应为 %d 字节)", resp.ContentLength, expected) } flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if o.BeforeWrite != nil { + if err := o.BeforeWrite(); err != nil { + return err + } + } restarted := existing > 0 && status == http.StatusOK switch { case resume: @@ -293,6 +301,11 @@ func (c *Client) File(ctx context.Context, o FileOptions) error { for { n, rerr := resp.read(buf, c.stallTimeout()) if n > 0 { + if o.BeforeWrite != nil { + if err := o.BeforeWrite(); err != nil { + return err + } + } if done+int64(n) > expected { return sizeError("文件大小和预期不符(收到的比 %d 字节多)", expected) } diff --git a/internal/macos/xattr_darwin.go b/internal/macos/xattr_darwin.go new file mode 100644 index 0000000..75e0639 --- /dev/null +++ b/internal/macos/xattr_darwin.go @@ -0,0 +1,105 @@ +//go:build darwin && cgo + +package macos + +/* +#include +#include +static ssize_t signature_listxattr(const char *path, void *buf, size_t size) { + return listxattr(path, buf, size, XATTR_NOFOLLOW); +} +static ssize_t signature_getxattr(const char *path, const char *name, void *buf, size_t size) { + return getxattr(path, name, buf, size, 0, XATTR_NOFOLLOW); +} +static int signature_setxattr(const char *path, const char *name, const void *buf, size_t size) { + return setxattr(path, name, buf, size, 0, XATTR_NOFOLLOW); +} +*/ +import "C" + +import ( + "encoding/base64" + "fmt" + "strings" + "unsafe" +) + +var signatureAttrs = map[string]bool{ + "com.apple.cs.CodeDirectory": true, "com.apple.cs.CodeRequirements": true, + "com.apple.cs.CodeSignature": true, "com.apple.cs.CodeEntitlements": true, +} + +// SignatureXattrs reads the generic code-signature attributes ZIP cannot retain. +// Other metadata is omitted. Unknown code-signature attributes fail closed. +func SignatureXattrs(p string) (map[string]string, error) { + cp := C.CString(p) + defer C.free(unsafe.Pointer(cp)) + n, err := C.signature_listxattr(cp, nil, 0) + if n < 0 || n > 65536 { + return nil, fmt.Errorf("读取签名属性列表失败: %s (%v)", p, err) + } + names := make([]byte, int(n)+1) + read, err := C.signature_listxattr(cp, unsafe.Pointer(&names[0]), C.size_t(n)) + if read != n { + return nil, fmt.Errorf("签名属性列表发生变化: %s (%v)", p, err) + } + out := map[string]string{} + for _, name := range strings.Split(string(names[:n]), "\x00") { + if !strings.HasPrefix(name, "com.apple.cs.") { + continue + } + if !signatureAttrs[name] { + return nil, fmt.Errorf("不支持的代码签名属性: %s", name) + } + cn := C.CString(name) + n, err := C.signature_getxattr(cp, cn, nil, 0) + if n < 0 || n > 1<<20 { + C.free(unsafe.Pointer(cn)) + return nil, fmt.Errorf("代码签名属性不可读或太大: %s (%v)", p, err) + } + b := make([]byte, int(n)+1) + read, err := C.signature_getxattr(cp, cn, unsafe.Pointer(&b[0]), C.size_t(n)) + C.free(unsafe.Pointer(cn)) + if read != n { + return nil, fmt.Errorf("代码签名属性发生变化: %s (%v)", p, err) + } + out[name] = base64.StdEncoding.EncodeToString(b[:n]) + } + return out, nil +} + +func ValidateSignatureXattrs(attrs map[string]string) error { + for name, value := range attrs { + if !signatureAttrs[name] || len(value) > (1<<20)*4/3+4 { + return fmt.Errorf("不允许的签名扩展属性: %s", name) + } + b, err := base64.StdEncoding.DecodeString(value) + if err != nil || len(b) > 1<<20 { + return fmt.Errorf("签名扩展属性格式错误: %s", name) + } + } + return nil +} + +// RestoreSignatureXattrs accepts only the bounded code-signature allowlist. +func RestoreSignatureXattrs(p string, attrs map[string]string) error { + if err := ValidateSignatureXattrs(attrs); err != nil { + return err + } + cp := C.CString(p) + defer C.free(unsafe.Pointer(cp)) + for name, value := range attrs { + b, _ := base64.StdEncoding.DecodeString(value) + cn := C.CString(name) + var ptr unsafe.Pointer + if len(b) > 0 { + ptr = unsafe.Pointer(&b[0]) + } + result, err := C.signature_setxattr(cp, cn, ptr, C.size_t(len(b))) + C.free(unsafe.Pointer(cn)) + if result != 0 { + return fmt.Errorf("不能恢复签名属性 %s: %w", name, err) + } + } + return nil +} diff --git a/internal/ui/API.md b/internal/ui/API.md index afed256..2c4be02 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -179,6 +179,8 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9, 3. 用 `host.StartOrphan` 启动根目录的 `tools\modavd.exe --root <根> --json update install --after-pid <管理器 pid> --reopen <管理器程序> --reopen-arg …`(输出追加到 `logs\modavd-update.log`)。半秒后它还在跑,任务才算成功,结果 `{handoff: true, version, stopped, message}`;从这时起别的任务一律回 409,约 1.5 秒后管理器退出。命令行等管理器退出,换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数(`--root`,以及 `--port`、`--no-window`、`--dev`)重新打开管理器;新管理器启动时读走这份结果,放进 `handoff`。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 - 管理器装的时候不带 AUTO-MAS 的版本检查(和人用命令行装一样),页面在 `required_automas` 不为空时提示用 AUTO-MAS 的先更新 AUTO-MAS。 - 开始前就拒绝、不开任务(422):不是 Windows,或者管理器不知道怎么重新打开自己(`unsupported`);根目录没有 `tools\modavd.exe`,或者它的版本和管理器不一样(`not_ready`,旧版命令行不认这些选项)。有别的任务在跑时回 409 `busy`;更新任务在跑时,别的任务也回 409。 +- macOS 原生更新由 CLI 的 `update prepare/install --package ZIP --sha256 HEX` 选择本地签名包;暂存后可离线 `install/recover`。没有兼容的 `macos-arm64` 远端资产时,检查结果为 `update_unavailable`。界面不提供安装入口;包协议见 `internal/update/MACOS.md`。 + ## 生命周期 diff --git a/internal/update/MACOS.md b/internal/update/MACOS.md new file mode 100644 index 0000000..0711e29 --- /dev/null +++ b/internal/update/MACOS.md @@ -0,0 +1,41 @@ +# macOS 更新包约定 + +原生 Darwin/ARM64 构建使用 `modavd-macos-update-v1`,与 Windows/AUTO-MAS 的事务格式相互独立。只有 `update prepare` 或 `update install` 可以选择本地更新包,而且必须同时指定 `--package ZIP` 和 `--sha256 HEX`。SHA256 摘要须从可信的独立渠道取得。安装已暂存的更新包、恢复中断的事务,都不需要联网或读取已保存的凭据。 + +ZIP 根目录须直接包含 `modavd-update-manifest.json`,不得在外面再包一层目录。这份传输清单不会安装到目标目录。其 JSON 对象包含以下字段: + +```json +{ + "schema": 1, + "format": "modavd-macos-update-v1", + "version": "v0.37.0", + "build": 37, + "platform": "macos-arm64", + "units": ["sdk/emulator", "ModAVD.app", "tools", "modavd-manifest.json"], + "files": { + "tools/modavd": {"type": "file", "mode": 493, "size": 123, "sha256": "..."}, + "sdk/emulator/resources/Current": {"type": "symlink", "target": "versions/A", "sha256": "..."} + } +} +``` + +以上示例仅展示结构,并非可直接安装的完整文件清单。除传输清单自身外,包内每个非目录条目都必须在清单中恰好出现一次。普通文件的摘要根据文件内容字节计算;符号链接的摘要根据链接目标字符串的原始 UTF-8 字节计算。权限使用十进制 Unix 权限位表示,例如 493 对应八进制 0755。只接受普通文件和符号链接,目录由条目路径推导。可选字段 `xattrs` 将受支持的 `com.apple.cs.*` 签名扩展属性映射为属性值的 base64 编码;签名使用这些属性时,打包必须保留。隔离属性和无关的扩展属性不会复制。 + +上述四个更新单元均为必需项,其他允许的单元名称在 `nativeAllowedUnits` 中明确列出。客体数据、根目录设置、platform-tools 和 system-images 均不能作为更新单元。路径必须是规范化的 ASCII 路径,且不得存在大小写歧义。符号链接的整个解析过程必须保持在所属单元内,不得存在悬空目标、循环链接或穿过普通文件的路径。ZIP 内容与清单中的条目类型、权限、大小、摘要和链接目标必须一致。 + +以下入口必须是包含 ARM64 Mach-O 代码的普通文件,并设置可执行权限位: + +- `ModAVD.app/Contents/MacOS/ModAVD` +- `tools/modavd` +- `tools/modavd-manager` +- `sdk/emulator/emulator` +- `sdk/emulator/qemu/darwin-aarch64/qemu-system-aarch64` +- `sdk/emulator/qemu/darwin-aarch64/qemu-system-aarch64-headless` + +管理器打开系统浏览器,无须独立的 WebView 辅助程序。`ModAVD.app/Contents/Info.plist` 中的 `CFBundleExecutable` 必须为 `ModAVD`。`sdk/emulator/source.properties` 必须包含 `Pkg.BuildId=mas-`,无界面 QEMU 二进制必须包含 `free-page-reporting`,随包安装的 `modavd-manifest.json` 中的版本必须与传输清单中的版本一致。每个 Mach-O 都使用 `codesign --verify --strict` 校验;应用和模拟器目录树还需使用 `--deep --strict` 校验。这些检查验证签名完整性,也接受 ad hoc 临时签名,但不证明发布者可信或已通过 Apple 公证。每个校验进程都有执行时限,整轮校验也有总时限。 + +各项上限为:20,000 个 ZIP 条目、4 GiB 解压后总量、16 MiB 传输清单、12 MiB 编码后的签名扩展属性,以及 1 MiB 事务日志。写入时必须保留至少 20 GiB 可用空间,无法确定剩余空间时停止操作。本地输入包先复制到更新器专用的临时文件,再校验摘要并解压。替换前会重新计算暂存内容的摘要,并重新校验代码签名,不会仅凭修改时间判断内容可信。 + +安装期间会持有受管理实例的启动锁,并扫描 SDK、应用和工具目录下正在运行的可执行文件路径。这些检查在 macOS 上属于协作式排他,管理器之外启动的程序仍可能与检查发生竞态。更新前须关闭所有实例、管理器窗口和 MCP 客户端。正在执行的更新器可以替换自身路径,原进程继续使用旧 inode 运行。每个单元重命名前后都会记录事务日志;恢复时,先校验由暂存区和当前安装目录中的单元共同组成的完整候选版本,再继续完成安装或还原原文件。回滚时,元数据撤销记录只恢复本事务修改的模拟器版本和更新历史,保留无关设置。遇到损坏或存在歧义的事务日志时,会保留现场供检查。 + +远端发布版本按 `macos-arm64` 选择资产,必须提供 SHA256 摘要和完整包大小;缺少兼容资产时返回 `update_unavailable`。按本格式发布更新包、分发签名和打包配置对齐,由后续发布打包阶段负责。 diff --git a/internal/update/native_guard_darwin.go b/internal/update/native_guard_darwin.go new file mode 100644 index 0000000..9c535e0 --- /dev/null +++ b/internal/update/native_guard_darwin.go @@ -0,0 +1,141 @@ +//go:build darwin && cgo + +package update + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "modavd/internal/errs" + "modavd/internal/host" + "modavd/internal/root" +) + +func managerPaths() []string { + return []string{"ModAVD.app/Contents/MacOS/ModAVD", "tools/modavd", "tools/modavd-manager"} +} + +// Reject redirected workspace paths before taking the lock or writing state. +// Symlinks declared inside a signed payload are checked separately as a graph. +func (u *Updater) validateNativeWorkspace() error { + paths := []string{u.Root.Dir, u.Root.AvdHome(), u.l.at("sdk"), u.l.dir(), u.l.download(), u.l.stage(), u.l.previous(), u.l.trash(), + filepath.Join(u.l.stage(), "payload"), filepath.Join(u.l.stage(), "payload", "sdk"), + filepath.Join(u.l.previous(), "native"), filepath.Join(u.l.previous(), "native", "sdk")} + for _, p := range paths { + st, err := os.Lstat(p) + if os.IsNotExist(err) { + continue + } + if err != nil || !st.IsDir() || st.Mode()&os.ModeSymlink != 0 { + return packageError("更新目录不是独立的普通目录: %s", p) + } + } + for _, p := range []string{u.l.state(), u.l.state() + ".tmp", u.l.lock()} { + st, err := os.Lstat(p) + if os.IsNotExist(err) { + continue + } + if err != nil || !st.Mode().IsRegular() { + return packageError("更新状态或锁文件不安全: %s", p) + } + } + return nil +} + +// Unit roots are rename boundaries. Internal payload links are permitted only +// within that unit, never in a parent used to reach the unit itself. +func (u *Updater) validateNativeUnitPaths(unit string, wantDirectory bool) error { + if err := u.validateNativeWorkspace(); err != nil { + return err + } + for _, p := range []string{u.l.at(unit), u.nativeBackup(unit), filepath.Join(u.nativePayload(), filepath.FromSlash(unit))} { + st, err := os.Lstat(p) + if os.IsNotExist(err) { + continue + } + if err != nil || st.Mode()&os.ModeSymlink != 0 || wantDirectory && !st.IsDir() || !wantDirectory && !st.Mode().IsRegular() { + return packageError("更新单元不是预期类型: %s", p) + } + } + return nil +} + +func (u *Updater) nativeProcessesUsing(programs bool) ([]processUse, error) { + procs, err := host.ListProcesses() + if err != nil { + return nil, err + } + key := func(p string) string { return strings.ToLower(realOrSame(p)) } + var prefixes []string + for _, p := range []string{u.l.emulator(), u.nativeBackup("sdk/emulator")} { + prefixes = append(prefixes, key(p)+string(filepath.Separator)) + } + if programs { + for _, p := range []string{"ModAVD.app", "tools"} { + prefixes = append(prefixes, key(u.l.at(p))+string(filepath.Separator)) + } + } + var uses []processUse + for _, p := range procs { + if p.PID == uint32(os.Getpid()) || p.PID == 0 { + continue + } + image, err := p.ImagePath() + if err != nil { + name := strings.ToLower(p.Name) + if p.Alive() && (strings.HasPrefix(name, "qemu-system") || strings.HasPrefix(name, "emulator") || strings.HasPrefix(name, "netsimd") || programs && strings.HasPrefix(name, "modavd")) { + uses = append(uses, processUse{PID: p.PID, Name: p.Name, Unknown: true}) + } + continue + } + image = key(image) + for _, prefix := range prefixes { + if strings.HasPrefix(image, prefix) && p.Alive() { + uses = append(uses, processUse{PID: p.PID, Name: p.Name}) + break + } + } + } + return uses, nil +} + +// Exclude managed starts through every rename and recovery. macOS allows an +// executing inode to be renamed; this is advisory exclusion plus a final full +// process check, not the mandatory Windows PE image-lock mechanism. +func (u *Updater) lockNativeSwap(verb string) (func(), error) { + if err := u.ensureNotInUse(verb); err != nil { + return nil, err + } + var locks []*host.FileLock + release := func() { + for _, lock := range locks { + lock.Unlock() + } + } + for i := 0; i <= root.MaxIndex; i++ { + if !root.ValidIndex(i) { + continue + } + path := filepath.Join(u.Root.AvdHome(), "."+root.AvdName(i)+".start.lock") + if st, err := os.Lstat(path); err == nil && !st.Mode().IsRegular() { + release() + return nil, packageError("实例启动锁不安全: %s", path) + } + lock, free, err := host.TryLock(path) + if err != nil || !free { + release() + if err != nil { + return nil, fmt.Errorf("打开实例启动锁: %w", err) + } + return nil, errs.Newf(errs.Busy, "实例 %d 正在启动,暂不能%s", i, verb) + } + locks = append(locks, lock) + } + if err := u.ensureNotInUse(verb); err != nil { + release() + return nil, err + } + return release, nil +} diff --git a/internal/update/native_io_darwin.go b/internal/update/native_io_darwin.go new file mode 100644 index 0000000..4245067 --- /dev/null +++ b/internal/update/native_io_darwin.go @@ -0,0 +1,221 @@ +//go:build darwin && cgo + +package update + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "modavd/internal/errs" + "modavd/internal/host" +) + +const nativeMinFreeBytes = 20 << 30 + +func readUpdateStateFile(path string) ([]byte, error) { return nativeReadFile(path, 1<<20) } + +func (u *Updater) nativeCheckSpace() error { + free, err := host.DiskFree(u.l.root) + if err != nil { + return errs.Wrap(errs.PrecheckFailed, err, "无法确认更新剩余空间,已停止写入") + } + if free < nativeMinFreeBytes { + return errs.Newf(errs.PrecheckFailed, "更新需保留至少 20 GiB 可用空间(当前 %.1f GiB),请先清理", float64(free)/(1<<30)) + } + return nil +} + +func nativeCopy(out io.Writer, in io.Reader, check func() error) (int64, error) { + buf := make([]byte, 1<<20) + var total int64 + for { + if check != nil { + if err := check(); err != nil { + return total, err + } + } + n, err := in.Read(buf) + if n > 0 { + wrote, werr := out.Write(buf[:n]) + total += int64(wrote) + if werr != nil { + return total, werr + } + if wrote != n { + return total, io.ErrShortWrite + } + } + if err == io.EOF { + return total, nil + } + if err != nil { + return total, err + } + } +} + +func nativeOpenRegular(p string) (*os.File, error) { + before, err := os.Lstat(p) + if err != nil || !before.Mode().IsRegular() { + return nil, packageError("更新文件不是普通文件: %s", p) + } + f, err := os.Open(p) + if err != nil { + return nil, err + } + after, err := f.Stat() + if err != nil || !after.Mode().IsRegular() || !os.SameFile(before, after) { + f.Close() + return nil, packageError("更新文件在读取时被替换: %s", p) + } + return f, nil +} + +func nativeHashFile(p string) (string, error) { + f, err := nativeOpenRegular(p) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func nativeReadFile(p string, limit int64) ([]byte, error) { + f, err := nativeOpenRegular(p) + if err != nil { + return nil, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, packageError("更新元数据太大: %s", p) + } + return b, nil +} + +func nativeCopyFile(src, dst string, check func() error) (err error) { + f, err := nativeOpenRegular(src) + if err != nil { + return err + } + defer f.Close() + info, err := f.Stat() + if err != nil || info.Size() > maxUnpackedBytes { + return packageError("本地更新归档太大或不可读") + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + n, err := nativeCopy(out, io.LimitReader(f, info.Size()+1), check) + if err == nil && n != info.Size() { + err = packageError("本地更新归档在复制时改变了大小") + } + if err == nil { + err = out.Sync() + } + if cerr := out.Close(); err == nil { + err = cerr + } + return err +} + +func nativeExtractEntry(e zipEntry, dst string, check func() error) (err error) { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + rc, err := e.f.Open() + if err != nil { + return packageError("更新条目打不开: %s", e.rel) + } + defer rc.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + n, err := nativeCopy(out, io.LimitReader(rc, int64(e.f.UncompressedSize64)+1), check) + if err == nil && n != int64(e.f.UncompressedSize64) { + err = packageError("更新条目大小不符: %s", e.rel) + } + if err == nil { + err = out.Sync() + } + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + os.Remove(dst) + } + return err +} + +// encoding/json accepts duplicate object fields. The package/journal contract +// rejects them, unknown fields, trailing values and excessively nested input. +func decodeNativeJSON(b []byte, out any) error { + dec := json.NewDecoder(bytes.NewReader(b)) + var walk func(int) error + walk = func(depth int) error { + if depth > 64 { + return fmt.Errorf("JSON nesting exceeds 64") + } + token, err := dec.Token() + if err != nil { + return err + } + if d, ok := token.(json.Delim); ok { + switch d { + case '{': + seen := map[string]bool{} + for dec.More() { + key, err := dec.Token() + if err != nil { + return err + } + s, ok := key.(string) + if !ok || seen[s] { + return fmt.Errorf("duplicate or invalid JSON field") + } + seen[s] = true + if err := walk(depth + 1); err != nil { + return err + } + } + case '[': + for dec.More() { + if err := walk(depth + 1); err != nil { + return err + } + } + default: + return fmt.Errorf("invalid JSON delimiter") + } + _, err = dec.Token() + } + return err + } + if err := walk(0); err != nil { + return packageError("更新 JSON 无效: %v", err) + } + if _, err := dec.Token(); err != io.EOF { + return packageError("更新 JSON 有多余内容") + } + dec = json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(out); err != nil { + return packageError("更新 JSON 字段无效: %v", err) + } + return nil +} diff --git a/internal/update/native_metadata_darwin.go b/internal/update/native_metadata_darwin.go new file mode 100644 index 0000000..f7527e4 --- /dev/null +++ b/internal/update/native_metadata_darwin.go @@ -0,0 +1,122 @@ +//go:build darwin && cgo + +package update + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + + "modavd/internal/jsonord" + "modavd/internal/root" +) + +// Persist an undo record before the metadata write. A crash after that write but +// before the idle journal must not leave the attempted version after rollback. +type nativeMetadataUndo struct { + ID string `json:"id"` + HadComponents bool `json:"had_components"` + HadHistory bool `json:"had_history"` + Components json.RawMessage `json:"components"` + History json.RawMessage `json:"history"` +} + +func nativeLastTransaction(o *jsonord.Obj) string { + h := o.Get("updateHistory").Items() + if len(h) == 0 || h[len(h)-1].Kind() != jsonord.Object { + return "" + } + id, _ := h[len(h)-1].Obj().Get("transaction").Str() + return id +} + +func (u *Updater) recordNativeMetadata(st *nativeState) error { + return u.Root.EditMetadata(func(o *jsonord.Obj) (bool, error) { + if st.Metadata != nil && nativeLastTransaction(o) == st.Metadata.ID { + return false, nil + } + if st.Metadata == nil { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + return false, err + } + st.Metadata = &nativeMetadataUndo{ID: hex.EncodeToString(id[:]), + HadComponents: o.Has("components"), HadHistory: o.Has("updateHistory"), + Components: o.Get("components").MarshalPython(), History: o.Get("updateHistory").MarshalPython()} + if err := u.writeNativeState(st); err != nil { + return false, err + } + } + var history []*jsonord.Value + for _, item := range o.Get("updateHistory").Items() { + if item.Kind() == jsonord.Object { + history = append(history, item) + } + } + entry := jsonord.NewObject() + for key, value := range map[string]string{"action": "update", "at": root.NowISO(), "from": st.From, + "to": st.Target.Version, "source": st.Target.Source, "asset": st.Target.Asset, "transaction": st.Metadata.ID} { + entry.Obj().Set(key, jsonord.NewString(value)) + } + history = append(history, entry) + if len(history) > 20 { + history = history[len(history)-20:] + } + components := o.Get("components").Clone() + if components.Kind() != jsonord.Object { + components = jsonord.NewObject() + } + components.Obj().Set("emulator", jsonord.NewString(st.Target.Version)) + o.Set("components", components) + o.Set("updateHistory", jsonord.NewArray(history...)) + return true, nil + }) +} + +func (u *Updater) restoreNativeMetadata(st *nativeState) error { + if st.Metadata == nil { + return nil + } + undo := st.Metadata + components, err := jsonord.Parse(undo.Components) + if err != nil { + return err + } + history, err := jsonord.Parse(undo.History) + if err != nil { + return err + } + return u.Root.EditMetadata(func(o *jsonord.Obj) (bool, error) { + // Other updater histories are protected by the root update lock. If an + // external writer changed the last transaction, preserve its changes. + if nativeLastTransaction(o) != undo.ID { + return false, nil + } + if undo.HadHistory { + o.Set("updateHistory", history) + } else { + o.Delete("updateHistory") + } + current := o.Get("components") + if current.Kind() == jsonord.Object { + value, _ := current.Obj().Get("emulator").Str() + if value == st.Target.Version { + current = current.Clone() + if components.Kind() == jsonord.Object && components.Obj().Has("emulator") { + current.Obj().Set("emulator", components.Obj().Get("emulator")) + } else { + current.Obj().Delete("emulator") + } + o.Set("components", current) + if current.Obj().Len() == 0 && components.Kind() != jsonord.Object { + if undo.HadComponents { + o.Set("components", components) + } else { + o.Delete("components") + } + } + } + } + return true, nil + }) +} diff --git a/internal/update/native_other.go b/internal/update/native_other.go new file mode 100644 index 0000000..51f9f31 --- /dev/null +++ b/internal/update/native_other.go @@ -0,0 +1,26 @@ +//go:build !darwin || !cgo + +package update + +import ( + "context" + + "modavd/internal/errs" + "modavd/internal/jsonord" + "modavd/internal/root" +) + +func (u *Updater) nativeUpdates() bool { return false } +func (u *Updater) hasNativeTransaction() bool { return false } +func (u *Updater) recoverNative() (string, error) { + return "", errs.New(errs.Unsupported, "当前平台暂不支持 macOS 原生更新") +} +func (u *Updater) nativeUpdate(context.Context, bool) (*Status, error) { + return u.status(), errs.New(errs.Unsupported, "本地签名包更新只支持原生 macOS ARM64 构建") +} +func (l layout) nativeStagedVersion(*jsonord.Obj) (Version, bool) { return Version{}, false } +func (u *Updater) validateNativeWorkspace() error { return nil } +func (u *Updater) nativeCheckSpace() error { return nil } +func (u *Updater) nativeProcessesUsing(bool) ([]processUse, error) { return nil, nil } +func readUpdateStateFile(path string) ([]byte, error) { return root.ReadFileShared(path) } +func (u *Updater) recoverNativeWorkspace() (string, error) { return "", nil } diff --git a/internal/update/native_package_darwin.go b/internal/update/native_package_darwin.go new file mode 100644 index 0000000..57773bc --- /dev/null +++ b/internal/update/native_package_darwin.go @@ -0,0 +1,553 @@ +//go:build darwin && cgo + +package update + +import ( + "archive/zip" + "context" + "crypto/sha256" + "debug/macho" + "encoding/binary" + "encoding/hex" + "io" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "modavd/internal/root" +) + +const nativeFormat = "modavd-macos-update-v1" +const nativeManifestName = "modavd-update-manifest.json" + +type nativeFile struct { + Type string `json:"type"` + Mode uint32 `json:"mode,omitempty"` + Size int64 `json:"size,omitempty"` + SHA256 string `json:"sha256"` + Target string `json:"target,omitempty"` + Xattrs map[string]string `json:"xattrs,omitempty"` +} +type nativeManifest struct { + Schema int `json:"schema"` + Format string `json:"format"` + Version string `json:"version"` + Build int `json:"build"` + Platform string `json:"platform"` + Units []string `json:"units"` + Files map[string]nativeFile `json:"files"` +} + +var nativeAllowedUnits = map[string]bool{ + "sdk/emulator": true, "ModAVD.app": true, "tools": true, + "components": true, "licenses": true, "provenance": true, + "modavd-manifest.json": true, "PACKAGE-MANIFEST.json": true, + "BUILD-RECEIPT.json": true, "README.md": true, "README.zh-CN.txt": true, + "CHANGELOG.md": true, "LICENSE": true, "NOTICE": true, "VERSION": true, + "SHA256SUMS": true, ".modavd-macos-package.json": true, +} + +func nativeDirectoryUnit(p string) bool { + return p == "sdk/emulator" || p == "ModAVD.app" || p == "tools" || p == "components" || p == "licenses" || p == "provenance" +} + +func nativeCleanPath(p string) bool { + if p == "" || p == "." || path.IsAbs(p) || path.Clean(p) != p || strings.ContainsAny(p, "\\\x00") || strings.HasPrefix(p, "../") { + return false + } + for _, c := range p { + if c < 32 || c > 126 { + return false + } + } + return true +} +func (m *nativeManifest) unit(p string) string { + for _, u := range m.Units { + if p == u || strings.HasPrefix(p, u+"/") { + return u + } + } + return "" +} +func digestBytes(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } + +// validateNativeManifest validates the complete graph before any archive path is written. +// Symlinks may contain '..', but every resolution step stays inside its move unit. +func validateNativeManifest(m *nativeManifest) (Version, error) { + v, ok := ParseVersion(m.Version) + if !ok || m.Schema != 1 || m.Format != nativeFormat || m.Platform != root.CurrentPlatform().PackageSuffix || m.Build != v.Build || len(m.Files) == 0 || len(m.Files) > maxEntries || len(m.Units) > len(nativeAllowedUnits) { + return Version{}, packageError("macOS 更新清单格式、平台或版本不正确") + } + units := map[string]bool{} + for _, u := range m.Units { + if !nativeAllowedUnits[u] || units[u] { + return Version{}, packageError("更新包含不允许的单元: %s", u) + } + units[u] = true + } + for _, u := range []string{"sdk/emulator", "ModAVD.app", "tools", manifestName} { + if !units[u] { + return Version{}, packageError("更新缺少单元: %s", u) + } + } + graph := map[string]string{} + fold := map[string]string{} + add := func(p, kind string) error { + key := strings.ToLower(p) + if old, ok := fold[key]; ok && old != p { + return packageError("更新存在大小写路径冲突: %s / %s", old, p) + } + fold[key] = p + if old, ok := graph[p]; ok && old != kind { + return packageError("更新路径类型冲突: %s", p) + } + graph[p] = kind + return nil + } + var signatureBytes int + var unpacked int64 + for p, s := range m.Files { + if err := validateNativeXattrs(s.Xattrs); err != nil { + return Version{}, err + } + for _, value := range s.Xattrs { + signatureBytes += len(value) + } + if signatureBytes > 12<<20 { + return Version{}, packageError("签名扩展属性总量太大") + } + if !nativeCleanPath(p) || m.unit(p) == "" || !sha256Hex.MatchString(s.SHA256) || s.Mode & ^uint32(0777) != 0 { + return Version{}, packageError("清单路径或属性不安全: %s", p) + } + if s.Type != "file" && s.Type != "symlink" { + return Version{}, packageError("不支持的文件类型: %s", p) + } + if s.Type == "file" && (s.Size < 0 || s.Target != "" || s.Size > maxUnpackedBytes || unpacked > maxUnpackedBytes-s.Size) { + return Version{}, packageError("文件记录不正确: %s", p) + } + if s.Type == "file" { + unpacked += s.Size + } + if s.Type == "symlink" && (s.Size != 0 || s.Mode != 0 || len(s.Target) > 4096 || len(s.Xattrs) != 0 || s.Target == "" || path.IsAbs(s.Target) || strings.ContainsAny(s.Target, "\\\x00\r\n") || digestBytes([]byte(s.Target)) != s.SHA256) { + return Version{}, packageError("符号链接不安全: %s", p) + } + if err := add(p, s.Type); err != nil { + return Version{}, err + } + for d := path.Dir(p); d != "."; d = path.Dir(d) { + if err := add(d, "dir"); err != nil { + return Version{}, err + } + } + } + for _, u := range m.Units { + want := "file" + if nativeDirectoryUnit(u) { + want = "dir" + } + if graph[u] != want { + return Version{}, packageError("更新单元为空或类型不正确: %s", u) + } + } + for p, spec := range m.Files { + if spec.Type != "symlink" { + continue + } + unit := m.unit(p) + stack := strings.Split(unit, "/") + floor := len(stack) + todo := strings.Split(strings.TrimPrefix(p, unit+"/"), "/") + expansions := 0 + for len(todo) > 0 { + part := todo[0] + todo = todo[1:] + if part == "" || part == "." { + continue + } + if part == ".." { + if len(stack) <= floor { + return Version{}, packageError("链接越过更新单元: %s", p) + } + stack = stack[:len(stack)-1] + continue + } + prefix := strings.Join(append(append([]string{}, stack...), part), "/") + kind, ok := graph[prefix] + if !ok { + return Version{}, packageError("悬空符号链接: %s", p) + } + if kind == "symlink" { + expansions++ + if expansions > 64 { + return Version{}, packageError("符号链接循环: %s", p) + } + todo = append(strings.Split(m.Files[prefix].Target, "/"), todo...) + continue + } + if len(todo) > 0 && kind != "dir" { + return Version{}, packageError("链接穿过普通文件: %s", p) + } + stack = append(stack, part) + } + } + return v, nil +} + +func readNativeManifest(p string) (*nativeManifest, error) { + b, err := nativeReadFile(p, 16<<20) + if err != nil { + return nil, err + } + if len(b) > 16<<20 { + return nil, packageError("更新清单太大") + } + var m nativeManifest + if err := decodeNativeJSON(b, &m); err != nil { + return nil, packageError("更新清单读不懂: %v", err) + } + if _, err := validateNativeManifest(&m); err != nil { + return nil, err + } + return &m, nil +} + +func extractNativePackage(archive, dst, topDir string, check func() error) (*nativeManifest, error) { + zr, closer, err := openZip(archive) + if err != nil { + return nil, err + } + defer closer.Close() + if len(zr.File) > maxEntries { + return nil, packageError("更新包条目太多") + } + entries := map[string]*zip.File{} + fold := map[string]bool{} + var total uint64 + for _, f := range zr.File { + name, err := zipName(f) + if err != nil { + return nil, err + } + if topDir != "" { + prefix := strings.TrimRight(topDir, "/") + "/" + if name == prefix { + continue + } + if !strings.HasPrefix(name, prefix) { + return nil, packageError("更新包目录不正确") + } + name = strings.TrimPrefix(name, prefix) + } + name = strings.TrimSuffix(name, "/") + if !nativeCleanPath(name) || fold[strings.ToLower(name)] { + return nil, packageError("更新包路径不安全或重复: %s", name) + } + fold[strings.ToLower(name)] = true + if f.Mode().IsDir() { + continue + } + if !f.Mode().IsRegular() && f.Mode()&os.ModeSymlink == 0 { + return nil, packageError("更新包含特殊文件: %s", name) + } + if f.UncompressedSize64 > maxUnpackedBytes || total > maxUnpackedBytes-f.UncompressedSize64 { + return nil, packageError("更新包解开后太大") + } + total += f.UncompressedSize64 + entries[name] = f + } + mf := entries[nativeManifestName] + if mf == nil || !mf.Mode().IsRegular() || mf.UncompressedSize64 > 16<<20 { + return nil, packageError("缺少 macOS 更新清单") + } + rc, err := mf.Open() + if err != nil { + return nil, err + } + b, err := io.ReadAll(io.LimitReader(rc, 16<<20+1)) + rc.Close() + if err != nil { + return nil, err + } + var m nativeManifest + if err := decodeNativeJSON(b, &m); err != nil { + return nil, packageError("更新清单读不懂: %v", err) + } + if _, err := validateNativeManifest(&m); err != nil { + return nil, err + } + if len(entries) != len(m.Files)+1 { + return nil, packageError("更新包与清单条目不一致") + } + for p, s := range m.Files { + f := entries[p] + if f == nil { + return nil, packageError("更新包缺少 %s", p) + } + if (f.Mode()&os.ModeSymlink != 0) != (s.Type == "symlink") { + return nil, packageError("文件类型不符: %s", p) + } + if s.Type == "file" && (uint64(s.Size) != f.UncompressedSize64 || uint32(f.Mode().Perm()) != s.Mode) { + return nil, packageError("文件属性不符: %s", p) + } + } + if err := removeTree(dst); err != nil { + return nil, err + } + if err := os.MkdirAll(dst, 0755); err != nil { + return nil, err + } + keys := make([]string, 0, len(m.Files)) + for p := range m.Files { + keys = append(keys, p) + } + sort.Strings(keys) + for _, p := range keys { + if m.Files[p].Type == "file" { + if err := nativeExtractEntry(zipEntry{p, entries[p]}, filepath.Join(dst, filepath.FromSlash(p)), check); err != nil { + return nil, err + } + if err := os.Chmod(filepath.Join(dst, filepath.FromSlash(p)), os.FileMode(m.Files[p].Mode)); err != nil { + return nil, err + } + if err := restoreNativeXattrs(filepath.Join(dst, filepath.FromSlash(p)), m.Files[p].Xattrs); err != nil { + return nil, err + } + } + } + for _, p := range keys { + s := m.Files[p] + if s.Type != "symlink" { + continue + } + f := entries[p] + if f.UncompressedSize64 > 4096 { + return nil, packageError("链接目标太长: %s", p) + } + rc, err := f.Open() + if err != nil { + return nil, err + } + b, err := io.ReadAll(io.LimitReader(rc, 4097)) + rc.Close() + if err != nil || string(b) != s.Target { + return nil, packageError("链接内容不符: %s", p) + } + dstPath := filepath.Join(dst, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { + return nil, err + } + if err := os.Symlink(s.Target, dstPath); err != nil { + return nil, err + } + } + if err := os.WriteFile(filepath.Join(dst, nativeManifestName), b, 0644); err != nil { + return nil, err + } + return &m, nil +} + +// verifyNativeTree uses Lstat/WalkDir: it never follows archive links while hashing. +func verifyNativeTree(base string, m *nativeManifest) error { + return verifyNativeTreeAt(m, func(p string) string { return filepath.Join(base, filepath.FromSlash(p)) }) +} + +func verifyNativeTreeAt(m *nativeManifest, at func(string) string) error { + if _, err := validateNativeManifest(m); err != nil { + return err + } + seen := map[string]bool{} + for _, unit := range m.Units { + unitPath := at(unit) + err := filepath.WalkDir(unitPath, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(unitPath, p) + if err != nil { + return err + } + if rel == "." { + rel = unit + } else { + rel = unit + "/" + filepath.ToSlash(rel) + } + s, ok := m.Files[rel] + if !ok { + return packageError("清单未列出的文件: %s", rel) + } + seen[rel] = true + info, err := d.Info() + if err != nil { + return err + } + if s.Type == "symlink" { + if info.Mode()&os.ModeSymlink == 0 { + return packageError("链接被替换: %s", rel) + } + target, err := os.Readlink(p) + if err != nil || target != s.Target { + return packageError("链接内容不符: %s", rel) + } + return nil + } + if !info.Mode().IsRegular() || info.Size() != s.Size || uint32(info.Mode().Perm()) != s.Mode { + return packageError("文件属性不符: %s", rel) + } + sum, err := nativeHashFile(p) + if err != nil || sum != s.SHA256 { + return packageError("文件内容不符: %s", rel) + } + attrs, err := nativeXattrs(p) + if err != nil || len(attrs) != len(s.Xattrs) { + return packageError("签名扩展属性不符: %s", rel) + } + for key, value := range attrs { + if s.Xattrs[key] != value { + return packageError("签名扩展属性不符: %s", rel) + } + } + return nil + }) + if err != nil { + return err + } + } + if len(seen) != len(m.Files) { + return packageError("更新文件缺失") + } + return nil +} + +func arm64MachO(p string) (bool, error) { + f, err := os.Open(p) + if err != nil { + return false, err + } + var magic [4]byte + n, err := f.Read(magic[:]) + f.Close() + if n < 4 { + return false, nil + } + m := binary.BigEndian.Uint32(magic[:]) + if m != macho.Magic32 && m != macho.Magic64 && m != 0xcefaedfe && m != 0xcffaedfe && m != macho.MagicFat && m != 0xbebafeca { + return false, nil + } + if fat, err := macho.OpenFat(p); err == nil { + defer fat.Close() + for _, a := range fat.Arches { + if a.Cpu == macho.CpuArm64 { + return true, nil + } + } + return true, packageError("Mach-O 缺少 ARM64: %s", p) + } + thin, err := macho.Open(p) + if err != nil { + return true, packageError("Mach-O 损坏: %s", p) + } + defer thin.Close() + if thin.Cpu != macho.CpuArm64 { + return true, packageError("Mach-O 不是 ARM64: %s", p) + } + return true, nil +} + +func verifyDarwinPackage(base string, m *nativeManifest, target Version) error { + return verifyDarwinPackageContext(context.Background(), base, m, target) +} + +func verifyDarwinPackageContext(ctx context.Context, base string, m *nativeManifest, target Version) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + return verifyDarwinPackageAtContext(ctx, m, target, func(p string) string { return filepath.Join(base, filepath.FromSlash(p)) }) +} + +func verifyDarwinPackageAt(m *nativeManifest, target Version, at func(string) string) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + return verifyDarwinPackageAtContext(ctx, m, target, at) +} + +func verifyDarwinPackageAtContext(ctx context.Context, m *nativeManifest, target Version, at func(string) string) error { + if err := verifyNativeTreeAt(m, at); err != nil { + return err + } + if err := checkEmulator(at("sdk/emulator"), target); err != nil { + return err + } + required := append(managerPaths(), "sdk/emulator/"+root.CurrentPlatform().Emulator, "sdk/emulator/qemu/"+root.CurrentPlatform().QemuDir+"/"+root.CurrentPlatform().QEMU, "sdk/emulator/qemu/"+root.CurrentPlatform().QemuDir+"/"+root.CurrentPlatform().QEMUHeadless) + machos := map[string]bool{} + for p, s := range m.Files { + if ctx.Err() != nil { + return packageError("签名校验已取消或超时: %v", ctx.Err()) + } + if s.Type != "file" { + continue + } + full := at(p) + native, err := arm64MachO(full) + if err != nil { + return err + } + if native { + machos[p] = true + if out, err := nativeCommandOutput(ctx, "/usr/bin/codesign", "--verify", "--strict", full); err != nil { + return packageError("原生文件签名核对失败 %s: %s", p, strings.TrimSpace(string(out))) + } + } + } + for _, p := range required { + if !machos[p] || m.Files[p].Mode&0o111 == 0 { + return packageError("必需文件不是真实 ARM64 Mach-O: %s", p) + } + } + info := at("ModAVD.app/Contents/Info.plist") + out, err := nativeCommandOutput(ctx, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleExecutable", info) + if err != nil || strings.TrimSpace(string(out)) != "ModAVD" { + return packageError("应用包 CFBundleExecutable 不正确") + } + for _, p := range []string{"ModAVD.app", "sdk/emulator"} { + out, err := nativeCommandOutput(ctx, "/usr/bin/codesign", "--verify", "--deep", "--strict", at(p)) + if err != nil { + return packageError("签名完整性核对失败 %s: %s", p, strings.TrimSpace(string(out))) + } + } + installed, ok := manifestVersion(filepath.Dir(at(manifestName))) + if !ok || installed != target { + return packageError("载荷版本清单与更新版本不一致") + } + return nil +} + +type nativeCommandBuffer struct{ data []byte } + +func (b *nativeCommandBuffer) Write(p []byte) (int, error) { + if left := (32 << 10) - len(b.data); left > 0 { + b.data = append(b.data, p[:min(left, len(p))]...) + } + return len(p), nil +} + +func nativeCommandOutput(ctx context.Context, executable string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, executable, args...) + command.WaitDelay = time.Second + var output nativeCommandBuffer + command.Stdout, command.Stderr = &output, &output + err := command.Run() + if ctx.Err() != nil { + err = ctx.Err() + } + return output.data, err +} diff --git a/internal/update/native_transaction_darwin.go b/internal/update/native_transaction_darwin.go new file mode 100644 index 0000000..666a5a7 --- /dev/null +++ b/internal/update/native_transaction_darwin.go @@ -0,0 +1,613 @@ +//go:build darwin && cgo + +package update + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "modavd/internal/errs" + "modavd/internal/jsonord" + "modavd/internal/root" +) + +type nativeUnit struct { + Path string `json:"path"` + HadOld bool `json:"had_old"` + Step string `json:"step"` // pending, old_moved, new_moved +} +type nativeState struct { + Format string `json:"format"` + Phase string `json:"phase"` + UpdatedAt string `json:"updatedAt,omitempty"` + Target struct { + Version string `json:"version"` + Source string `json:"source"` + Asset string `json:"asset_name"` + } `json:"target"` + ManifestSHA256 string `json:"manifest_sha256"` + From string `json:"from"` + Units []nativeUnit `json:"units"` + Metadata *nativeMetadataUndo `json:"metadata,omitempty"` +} + +func (u *Updater) nativeUpdates() bool { return root.CurrentPlatform().Supported() } +func (u *Updater) hasNativeTransaction() bool { + s, _ := u.l.readState().Get("format").Str() + return s == nativeFormat +} +func (l layout) nativeStagedVersion(st *jsonord.Obj) (Version, bool) { + f, _ := st.Get("format").Str() + if f != nativeFormat || phaseOf(st) != phaseStaged || !isDir(filepath.Join(l.stage(), "payload")) { + return Version{}, false + } + v, _ := st.Get("target").Obj().Get("version").Str() + return ParseVersion(v) +} +func (u *Updater) nativePayload() string { return filepath.Join(u.l.stage(), "payload") } +func (u *Updater) nativeBackup(p string) string { + return filepath.Join(u.l.previous(), "native", filepath.FromSlash(p)) +} +func (u *Updater) writeNativeState(st *nativeState) error { + b, err := json.Marshal(st) + if err != nil { + return err + } + if len(b) > 1<<20 { + return packageError("更新事务元数据太大") + } + v, err := jsonord.Parse(b) + if err != nil { + return err + } + v.Obj().Set("updatedAt", jsonord.NewString(root.NowISO())) + if len(v.MarshalPython()) > 1<<20 { + return packageError("更新事务元数据太大") + } + return u.l.writeState(v.Obj()) +} +func (u *Updater) readNativeState() (*nativeState, error) { + b, err := nativeReadFile(u.l.state(), 1<<20) + if err != nil { + return nil, err + } + var st nativeState + if err := decodeNativeJSON(b, &st); err != nil { + return nil, err + } + if st.Format != nativeFormat || len(st.Units) > len(nativeAllowedUnits) { + return nil, packageError("更新事务格式错误") + } + if st.Metadata != nil { + id, err := hex.DecodeString(st.Metadata.ID) + if err != nil || len(id) != 16 || len(st.Metadata.Components) == 0 || len(st.Metadata.History) == 0 { + return nil, packageError("更新事务元数据恢复记录错误") + } + } + seen := map[string]bool{} + for _, unit := range st.Units { + if !nativeAllowedUnits[unit.Path] || seen[unit.Path] { + return nil, packageError("更新事务单元不安全: %s", unit.Path) + } + seen[unit.Path] = true + switch unit.Step { + case "pending", "old_moved", "new_moved", "restoring", "restored": + default: + return nil, packageError("更新事务步骤不正确") + } + if st.Phase != "rolling_back" && (unit.Step == "restored" || unit.Step == "restoring") { + return nil, packageError("交换状态含回退步骤") + } + } + if _, ok := ParseVersion(st.Target.Version); !ok { + return nil, packageError("更新事务版本错误") + } + if st.Phase != phasePreparing && st.Phase != phaseStaged && st.Phase != phaseSwapping && st.Phase != "rolling_back" { + return nil, packageError("更新事务状态错误") + } + if st.Phase != phasePreparing && !sha256Hex.MatchString(st.ManifestSHA256) { + return nil, packageError("更新事务清单摘要错误") + } + if (st.Phase == phaseSwapping || st.Phase == "rolling_back") && len(st.Units) == 0 { + return nil, packageError("更新事务缺少交换单元") + } + if st.Phase == phaseSwapping || st.Phase == "rolling_back" { + for _, unit := range []string{"sdk/emulator", "ModAVD.app", "tools", manifestName} { + if !seen[unit] { + return nil, packageError("更新事务缺少必需单元: %s", unit) + } + } + } + return &st, nil +} + +func (u *Updater) nativeUpdate(ctx context.Context, install bool) (*Status, error) { + if !u.nativeUpdates() { + return u.status(), errs.New(errs.Unsupported, "本地签名包更新只支持原生 macOS ARM64 构建") + } + u.localOperation = u.Opt.PackagePath != "" + if u.hasNativeTransaction() { + if pending, err := u.readNativeState(); err == nil && pending.Target.Source == "local" { + u.localOperation = true + } + } + st := u.status() + if !st.Updatable && !u.hasNativeTransaction() { + return st, errs.New(errs.NotReady, st.NotUpdatableReason) + } + if (u.Opt.PackagePath == "") != (u.Opt.PackageSHA256 == "") { + return st, errs.New(errs.Usage, "本地更新必须同时指定 --package ZIP 和 --sha256 HEX") + } + lock, err := u.begin(st) + if err != nil { + return st, err + } + defer lock.Unlock() + if reason := u.notUpdatable(); reason != "" { + return u.finish(st), errs.New(errs.NotReady, reason) + } + if u.Opt.PackagePath != "" || phaseOf(u.l.readState()) != phaseStaged || !u.hasNativeTransaction() { + if err := u.prepareNative(ctx, st); err != nil { + return u.finish(st), err + } + } + if !install { + out := u.finish(st) + if out.PreparedVersion != "" { + out.Message = displayVersion(out.PreparedVersion) + " 已核对文件与签名,可以离线安装" + } + return out, nil + } + if !u.hasNativeTransaction() || phaseOf(u.l.readState()) != phaseStaged { + return u.finish(st), nil + } + ns, err := u.readNativeState() + if err != nil { + return u.finish(st), err + } + v, _ := ParseVersion(ns.Target.Version) + if cur, ok := LocalVersion(u.Root); ok && !cur.Less(v) { + return u.finish(st), packageError("更新包 %s 不比本地 %s 新", v.Display(), cur.Display()) + } + m, err := u.checkedNativeStage(ctx, ns) + if err != nil { + return u.finish(st), err + } + unlock, err := u.lockNativeSwap("安装") + if err != nil { + return u.finish(st), err + } + defer unlock() + if err := u.checkSpace(); err != nil { + return u.finish(st), err + } + if err := u.validateNativeDestinations(m); err != nil { + return u.finish(st), err + } + if ctx.Err() != nil { + return u.finish(st), errs.Wrap(errs.Canceled, ctx.Err(), "更新已取消") + } + if err := removeTree(u.l.previous()); err != nil { + return u.finish(st), err + } + if cur, ok := LocalVersion(u.Root); ok { + ns.From = cur.String() + } + ns.Phase = phaseSwapping + ns.Units = nil + for _, p := range m.Units { + ns.Units = append(ns.Units, nativeUnit{Path: p, HadOld: lexists(u.l.at(p)), Step: "pending"}) + } + if err := u.writeNativeState(ns); err != nil { + return u.finish(st), err + } + u.step("installing", "正在换上 macOS 新版") + if err := u.completeNativeSwap(ns, m); err != nil { + unlock() // recovery acquires the same start locks itself + msg, rerr := u.recoverNative() + if rerr != nil { + return u.finish(st), fmt.Errorf("%w; recovery: %v", err, rerr) + } + st.Recovered = msg + return u.finish(st), err + } + out := u.finish(st) + out.Updated = true + out.Message = "已更新到 " + v.Display() + return out, nil +} + +func (u *Updater) prepareNative(ctx context.Context, st *Status) (err error) { + check := func() error { + if ctx.Err() != nil { + return errs.Wrap(errs.Canceled, ctx.Err(), "更新已取消") + } + return u.checkSpace() + } + var archive, source, asset string + var expected Version + if u.Opt.PackagePath != "" { + archive, err = filepath.Abs(u.Opt.PackagePath) + if err != nil { + return err + } + for _, dir := range []string{u.l.stage(), u.l.previous(), u.l.pkg(), u.l.trash()} { + rel, e := filepath.Rel(dir, archive) + if e == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return packageError("本地更新归档不能放在更新暂存或备份目录内") + } + } + want := strings.ToLower(u.Opt.PackageSHA256) + if !sha256Hex.MatchString(want) { + return errs.New(errs.Usage, "--sha256 必须是 64 位十六进制摘要") + } + fi, e := os.Lstat(archive) + if e != nil || !fi.Mode().IsRegular() { + return packageError("本地更新包必须是普通文件") + } + if err := u.checkSpace(); err != nil { + return err + } + if err := os.MkdirAll(u.l.download(), 0755); err != nil { + return err + } + tmp, e := os.CreateTemp(u.l.download(), "local-*.zip") + if e != nil { + return e + } + selected := tmp.Name() + if e := tmp.Close(); e != nil { + return e + } + defer os.Remove(selected) + if e := nativeCopyFile(archive, selected, check); e != nil { + return e + } + sum, e := nativeHashFile(selected) + if e != nil || sum != want { + return packageError("本地更新包 SHA256 不符") + } + source, asset = "local", filepath.Base(archive) + archive = selected // Extract only the exact bytes whose supplied digest was checked. + } else { + r, e := u.pick(ctx, st) + if e != nil { + return e + } + if r == nil { + return nil + } + if r.incremental { + return packageError("macOS 更新需要完整签名包;增量更新尚未提供") + } + if !sha256Hex.MatchString(r.sha256) { + return sourceError("macOS 更新包缺少可信传输摘要,暂不能下载") + } + if r.size <= 0 || r.size > maxUnpackedBytes { + return sourceError("macOS 更新归档大小无效或超过限制") + } + archive, err = u.download(ctx, r) + if err != nil { + return err + } + source, asset, expected = r.source, r.assetName, r.version + if r.topDir != "" { + return packageError("macOS 原生更新包不允许额外顶层目录") + } + } + if err := u.checkSpace(); err != nil { + return err + } + ns := &nativeState{Format: nativeFormat, Phase: phasePreparing} + ns.Target.Version = expected.String() + ns.Target.Source = source + ns.Target.Asset = asset + if err := u.writeNativeState(ns); err != nil { + return err + } + defer func() { + if err != nil { + u.l.abortStaging(err.Error()) + } + }() + u.step("extracting", "正在解开 macOS 签名更新包") + m, err := extractNativePackage(archive, u.nativePayload(), "", check) + if err != nil { + return err + } + v, _ := ParseVersion(m.Version) + if source != "local" && v != expected { + return packageError("更新包版本与发布版本不一致") + } + if cur, ok := LocalVersion(u.Root); ok && !cur.Less(v) { + return packageError("更新包 %s 不比本地 %s 新", v.Display(), cur.Display()) + } + u.step("verifying", "正在核对 ARM64 文件、应用签名与清单") + if err := verifyDarwinPackageContext(ctx, u.nativePayload(), m, v); err != nil { + return err + } + if err := check(); err != nil { + return err + } + ns.ManifestSHA256, err = nativeHashFile(filepath.Join(u.nativePayload(), nativeManifestName)) + if err != nil { + return err + } + ns.Target.Version = v.String() + ns.Phase = phaseStaged + return u.writeNativeState(ns) +} + +func (u *Updater) checkedNativeStage(ctx context.Context, st *nativeState) (*nativeManifest, error) { + mp := filepath.Join(u.nativePayload(), nativeManifestName) + sum, err := nativeHashFile(mp) + if err != nil || sum != st.ManifestSHA256 { + return nil, packageError("暂存清单被修改或丢失") + } + m, err := readNativeManifest(mp) + if err != nil { + return nil, err + } + v, _ := ParseVersion(st.Target.Version) + if m.Version != v.String() && "v"+m.Version != v.String() { + return nil, packageError("暂存版本不一致") + } + if err := verifyDarwinPackageContext(ctx, u.nativePayload(), m, v); err != nil { + return nil, err + } + return m, nil +} + +// No transaction unit may be reached through a symlink in the installed root. +func (u *Updater) validateNativeDestinations(m *nativeManifest) error { + for _, unit := range m.Units { + p := u.l.at(unit) + for { + fi, err := os.Lstat(p) + if err == nil && fi.Mode()&os.ModeSymlink != 0 { + return packageError("更新目标是符号链接: %s", p) + } + if err != nil && !os.IsNotExist(err) { + return err + } + if p == u.Root.Dir { + break + } + p = filepath.Dir(p) + if p == filepath.Dir(p) { + return packageError("更新根目录不正确") + } + } + } + return nil +} + +func (u *Updater) completeNativeSwap(st *nativeState, m *nativeManifest) error { + if len(st.Units) != len(m.Units) { + return packageError("交换计划与签名包单元不一致") + } + for i, unit := range st.Units { + if unit.Path != m.Units[i] { + return packageError("交换计划与签名包单元顺序不一致") + } + } + if err := u.validateNativeDestinations(m); err != nil { + return err + } + // A cut rename may leave verified units in either location. Verify that + // complete candidate, including signatures, before doing another rename. + at := func(p string) string { + unit := m.unit(p) + base := filepath.Join(u.nativePayload(), filepath.FromSlash(unit)) + if !lexists(base) { + base = u.l.at(unit) + } + if p == unit { + return base + } + return filepath.Join(base, filepath.FromSlash(strings.TrimPrefix(p, unit+"/"))) + } + v, _ := ParseVersion(st.Target.Version) + if err := verifyDarwinPackageAt(m, v, at); err != nil { + return err + } + for i := range st.Units { + unit := &st.Units[i] + if err := u.validateNativeUnitPaths(unit.Path, nativeDirectoryUnit(unit.Path)); err != nil { + return err + } + src := filepath.Join(u.nativePayload(), filepath.FromSlash(unit.Path)) + dst := u.l.at(unit.Path) + old := u.nativeBackup(unit.Path) + if unit.Step == "pending" { + if unit.HadOld && !lexists(old) { + if !lexists(dst) { + return fmt.Errorf("old update unit missing: %s", unit.Path) + } + if err := os.MkdirAll(filepath.Dir(old), 0755); err != nil { + return err + } + if err := os.Rename(dst, old); err != nil { + return err + } + } + unit.Step = "old_moved" + if err := u.writeNativeState(st); err != nil { + return err + } + } + if unit.Step == "old_moved" { + if lexists(src) { + if lexists(dst) { + return fmt.Errorf("ambiguous update unit: %s", unit.Path) + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + if err := os.Rename(src, dst); err != nil { + return err + } + } else if !lexists(dst) { + return fmt.Errorf("new update unit missing: %s", unit.Path) + } + unit.Step = "new_moved" + if err := u.writeNativeState(st); err != nil { + return err + } + } + } + if err := verifyDarwinPackage(u.Root.Dir, m, v); err != nil { + return err + } + if err := u.recordNativeMetadata(st); err != nil { + return err + } + result := jsonord.NewObject().Obj() + result.Set("ok", jsonord.NewBool(true)) + result.Set("action", jsonord.NewString("update")) + result.Set("version", jsonord.NewString(st.Target.Version)) + if err := u.l.writeState(newState(phaseIdle, "lastResult", objVal(result))); err != nil { + return err + } + u.l.tidyUp(u.l.previous(), u.l.stage(), u.l.pkg()) + u.l.purgeTrash() + return nil +} + +func (u *Updater) recoverNative() (string, error) { + st, err := u.readNativeState() + if err != nil { + return "", err + } + if st.Target.Source == "local" { + u.localOperation = true + } + if err := u.validateNativeWorkspace(); err != nil { + return "", err + } + if st.Phase == phaseSwapping || st.Phase == "rolling_back" { + unlock, err := u.lockNativeSwap("恢复更新") + if err != nil { + return "", err + } + defer unlock() + } + switch st.Phase { + case phasePreparing: + if err := u.l.abortStaging("上次 macOS 更新准备未完成"); err != nil { + return "", err + } + return "已丢弃上次未完成的 macOS 更新暂存", nil + case phaseStaged: + return "", nil + case phaseSwapping: + mp := filepath.Join(u.nativePayload(), nativeManifestName) + sum, e := nativeHashFile(mp) + if e == nil && sum == st.ManifestSHA256 { + if m, e := readNativeManifest(mp); e == nil { + if e := u.completeNativeSwap(st, m); e == nil { + return "已离线补完 macOS 更新(" + displayVersion(st.Target.Version) + ")", nil + } + } + } + st.Phase = "rolling_back" + if err := u.writeNativeState(st); err != nil { + return "", err + } + return u.rollbackNative(st) + case "rolling_back": + return u.rollbackNative(st) + default: + return "", packageError("未知 macOS 更新事务状态") + } +} + +// A copied Windows/AUTO-MAS journal must never enter the native rename path. +func (u *Updater) recoverNativeWorkspace() (string, error) { + if err := u.validateNativeWorkspace(); err != nil { + return "", err + } + if !lexists(u.l.state()) { + return "", nil + } + b, err := nativeReadFile(u.l.state(), 1<<20) + if err != nil { + return "", err + } + v, err := jsonord.Parse(b) + if err != nil || v.Kind() != jsonord.Object { + return "", packageError("更新状态文件损坏,已保留现场") + } + state := v.Obj() + format, _ := state.Get("format").Str() + if format == nativeFormat { + return u.recoverNative() + } + if format != "" || phaseOf(state) != phaseIdle { + return "", packageError("当前更新事务不是 macOS 原生格式,已保留现场") + } + u.l.purgeTrash() + return "", nil +} + +func (u *Updater) rollbackNative(st *nativeState) (string, error) { + // If the staged payload is unavailable/corrupt, restore every unit from + // this transaction's backup. This is crash recovery, not a retained rollback. + for i := len(st.Units) - 1; i >= 0; i-- { + unit := &st.Units[i] + if err := u.validateNativeUnitPaths(unit.Path, nativeDirectoryUnit(unit.Path)); err != nil { + return "", err + } + if unit.Step == "restored" { + if lexists(u.l.at(unit.Path)) != unit.HadOld { + return "", fmt.Errorf("restored update unit missing or unexpectedly present: %s", unit.Path) + } + continue + } + old := u.nativeBackup(unit.Path) + dst := u.l.at(unit.Path) + if lexists(old) { + unit.Step = "restoring" + if err := u.writeNativeState(st); err != nil { + return "", err + } + if err := removeTree(dst); err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return "", err + } + if err := os.Rename(old, dst); err != nil { + return "", err + } + } else if !unit.HadOld && unit.Step != "pending" { + if err := removeTree(dst); err != nil { + return "", err + } + } else if unit.HadOld && !(unit.Step == "pending" && lexists(dst)) && !(unit.Step == "restoring" && lexists(dst)) { + return "", fmt.Errorf("cannot restore missing backup: %s", unit.Path) + } + unit.Step = "restored" + if err := u.writeNativeState(st); err != nil { + return "", err + } + } + if err := u.restoreNativeMetadata(st); err != nil { + return "", err + } + result := jsonord.NewObject().Obj() + result.Set("action", jsonord.NewString("update")) + result.Set("ok", jsonord.NewBool(false)) + result.Set("error", jsonord.NewString("上次 macOS 更新损坏,已恢复原单元")) + if err := u.l.writeState(newState(phaseIdle, "lastResult", objVal(result))); err != nil { + return "", err + } + u.l.tidyUp(u.l.previous(), u.l.stage(), u.l.pkg()) + u.l.purgeTrash() + return "上次 macOS 更新未完成,已恢复原单元", nil +} diff --git a/internal/update/native_xattr_darwin.go b/internal/update/native_xattr_darwin.go new file mode 100644 index 0000000..1703cd2 --- /dev/null +++ b/internal/update/native_xattr_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin && cgo + +package update + +import "modavd/internal/macos" + +func nativeXattrs(p string) (map[string]string, error) { return macos.SignatureXattrs(p) } +func validateNativeXattrs(attrs map[string]string) error { + if err := macos.ValidateSignatureXattrs(attrs); err != nil { + return packageError("签名扩展属性不安全: %v", err) + } + return nil +} +func restoreNativeXattrs(p string, attrs map[string]string) error { + if err := macos.RestoreSignatureXattrs(p, attrs); err != nil { + return packageError("不能恢复签名扩展属性: %v", err) + } + return nil +} diff --git a/internal/update/platform_other_test.go b/internal/update/platform_other_test.go index 483da70..42d3587 100644 --- a/internal/update/platform_other_test.go +++ b/internal/update/platform_other_test.go @@ -16,6 +16,9 @@ import ( ) func TestWriteOperationsUnsupportedWithoutWrites(t *testing.T) { + if nativeDarwinSettings && root.CurrentPlatform().Supported() { + t.Skip("native macOS supports update transactions") + } dir := testutil.TempDir(t) r, err := root.New(dir) if err != nil { diff --git a/internal/update/source.go b/internal/update/source.go index 5b43b37..7885160 100644 --- a/internal/update/source.go +++ b/internal/update/source.go @@ -299,11 +299,23 @@ const downloadAttempts = 3 // programs use the same names, so neither ever keeps the other's. func (u *Updater) download(ctx context.Context, r *release) (string, error) { l := u.l + if u.nativeUpdates() { + if err := u.checkSpace(); err != nil { + return "", err + } + } if err := os.MkdirAll(l.download(), 0o755); err != nil { return "", err } final := l.downloadAt(r.assetName) part := final + ".part" + if u.nativeUpdates() { + for _, path := range []string{final, part} { + if st, err := os.Lstat(path); err == nil && !st.Mode().IsRegular() { + return "", packageError("更新下载缓存不是普通文件") + } + } + } entries, _ := os.ReadDir(l.download()) for _, e := range entries { if p := l.downloadAt(e.Name()); p != final && p != part { @@ -330,6 +342,9 @@ func (u *Updater) download(ctx context.Context, r *release) (string, error) { if lastErr == nil { break } + if u.nativeUpdates() && errs.CodeOf(lastErr) == errs.PrecheckFailed { + return "", lastErr + } if ctx.Err() != nil { return "", errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } @@ -389,8 +404,18 @@ func (u *Updater) fetch(ctx context.Context, r *release, part, label string) err fresh = true } d := download.Client{HTTP: u.client(), StallTimeout: u.stallTimeout} + var beforeWrite func() error + if u.nativeUpdates() { + beforeWrite = func() error { + if st, err := os.Lstat(part); err == nil && !st.Mode().IsRegular() { + return packageError("更新下载缓存不是普通文件") + } + return u.checkSpace() + } + } return d.File(ctx, download.FileOptions{URL: r.url, Path: part, Label: label, Size: r.size, - Fresh: fresh, + Fresh: fresh, + BeforeWrite: beforeWrite, Progress: func(done int64) { if time.Since(last) >= 500*time.Millisecond || done == r.size { last = time.Now() diff --git a/internal/update/state.go b/internal/update/state.go index 07afa6c..250b390 100644 --- a/internal/update/state.go +++ b/internal/update/state.go @@ -75,7 +75,7 @@ func (l layout) downloadAt(n string) string { return filepath.Join(l.download(), // readState is state.json as an object (empty when missing, unreadable or not an object); phase defaults to idle. func (l layout) readState() *jsonord.Obj { - b, err := root.ReadFileShared(l.state()) + b, err := readUpdateStateFile(l.state()) if err == nil { if v, err := jsonord.Parse(trimBOM(b)); err == nil && v.Kind() == jsonord.Object { return v.Obj() diff --git a/internal/update/swap.go b/internal/update/swap.go index c39c2fe..01ebba1 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -93,6 +93,9 @@ func (l layout) abortStaging(reason string) error { // stagedVersion is the version waiting to be installed: phase staged with its stage\emulator there. func (l layout) stagedVersion() (Version, bool) { st := l.readState() + if v, ok := l.nativeStagedVersion(st); ok { + return v, true + } if phaseOf(st) != phaseStaged || !isDir(l.stageEmulator()) { return Version{}, false } @@ -455,6 +458,9 @@ func (u *Updater) recordHistory(from, to, source, asset string) { // recover is AUTO-MAS recover(): finish or undo a swap that was cut off, drop a preparation that was, clean up // leftovers. The message says what it did ("" when nothing). func (u *Updater) recover() (string, error) { + if u.nativeUpdates() { + return u.recoverNativeWorkspace() + } if phaseOf(u.l.readState()) == phaseSwapping { unlock, err := u.lockForSwap("恢复更新") if err != nil { @@ -581,6 +587,9 @@ func (u *Updater) processesUsing(programs bool) ([]processUse, error) { scan := u.scan if scan == nil { scan = func(programs bool) ([]processUse, error) { + if u.nativeUpdates() { + return u.nativeProcessesUsing(programs) + } return u.scanProcesses(programs, host.ListProcesses, host.Process.ImagePath) } } diff --git a/internal/update/updater.go b/internal/update/updater.go index 8e275c4..b277745 100644 --- a/internal/update/updater.go +++ b/internal/update/updater.go @@ -46,6 +46,9 @@ type Options struct { // AutoMASVersion is the calling AUTO-MAS's version; a release that needs a newer one is not installed. "" (a // person, the manager) skips that check. AutoMASVersion string + // PackagePath and PackageSHA256 select an explicit local macOS update ZIP. + // The caller must obtain the digest from a trusted source independently. + PackagePath, PackageSHA256 string // Progress, when set, is told about each step. Progress func(Progress) // Source, when not "", is the update source of this run instead of the saved setting (SourceGitHub or @@ -79,10 +82,11 @@ type Updater struct { httpClient *http.Client stallTimeout time.Duration scan func(programs bool) ([]processUse, error) + localOperation bool // offline updates do not open saved credentials } func unsupportedUpdatePlatform() error { - if !root.CurrentPlatform().Windows() { + if !root.CurrentPlatform().Windows() && !(nativeDarwinSettings && root.CurrentPlatform().Supported()) { return errs.New(errs.Unsupported, "当前平台暂不支持更新") } return nil @@ -165,7 +169,10 @@ func (u *Updater) notUpdatable() string { // status fills what needs no network. func (u *Updater) status() *Status { - set := u.settings() + set := Settings{Source: SourceGitHub} + if u.Opt.PackagePath == "" && !u.hasNativeTransaction() && !u.localOperation { + set = u.settings() + } st := &Status{Root: u.Root.Dir, Source: set.Source, CDKSet: set.HasCDK(), Notices: []string{}} st.NotUpdatableReason = u.notUpdatable() st.Updatable = st.NotUpdatableReason == "" @@ -239,6 +246,11 @@ func (u *Updater) latest(ctx context.Context, st *Status) (*release, error) { // lock is .update\modavd.lock: one modavd update step per root at a time. func (u *Updater) lock() (*host.FileLock, error) { + if u.nativeUpdates() { + if err := u.validateNativeWorkspace(); err != nil { + return nil, err + } + } if err := os.MkdirAll(u.l.dir(), 0o755); err != nil { return nil, errs.Wrap(errs.Internal, err, "建不了 "+u.l.dir()) } @@ -296,6 +308,9 @@ func (u *Updater) Recover(ctx context.Context) (*Status, error) { // Prepare downloads, verifies and stages the latest version (instances may keep running). func (u *Updater) Prepare(ctx context.Context) (*Status, error) { + if u.nativeUpdates() || u.Opt.PackagePath != "" || u.Opt.PackageSHA256 != "" { + return u.nativeUpdate(ctx, false) + } if err := unsupportedUpdatePlatform(); err != nil { return u.Status(), err } @@ -453,6 +468,9 @@ func versionLess(a, b string) bool { } func (u *Updater) checkSpace() error { + if u.nativeUpdates() { + return u.nativeCheckSpace() + } free, err := host.DiskFree(u.Root.Dir) if err != nil || free >= minFreeBytes { return nil @@ -520,6 +538,9 @@ func (u *Updater) stage(ctx context.Context, r *release) (err error) { // Install brings the root to the latest version: prepares it when it is newer and not staged yet, then swaps it in // (every instance off, no other program of the root running). Nothing newer and nothing staged: up to date. func (u *Updater) Install(ctx context.Context) (*Status, error) { + if u.nativeUpdates() || u.Opt.PackagePath != "" || u.Opt.PackageSHA256 != "" { + return u.nativeUpdate(ctx, true) + } if err := unsupportedUpdatePlatform(); err != nil { return u.Status(), err }