diff --git a/README.en.md b/README.en.md index c9e19b5..e06f7b5 100644 --- a/README.en.md +++ b/README.en.md @@ -258,6 +258,8 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis - `install` runs `prepare` first if needed, then swaps in `sdk\emulator` and the other files in the package. All instances, manager windows and MCP clients must be closed first; if any program is using the files, it changes nothing (exit code 5), and the staged new version is kept so the next run can swap it in directly; - `recover` finishes or rolls back a swap that was left unfinished last time (it also runs first at the start of every step). - The update source is chosen in the manager's "Settings" (`设置`): GitHub (the latest Release of `ModAVD/ModAVD`, the full package) or MirrorChyan (downloads need a CDK, and it can provide delta packages; on errors it downloads from GitHub instead and says so in the result's `notices`). The CDK is encrypted with Windows DPAPI and stored in `%LOCALAPPDATA%\ModAVD\update-settings.json`; only this Windows user can decrypt it, and it is not written to logs and does not appear on the command line or in API responses. For now the manager can only change the settings and check for updates; the "Update" (`更新`) button is not available. +- 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 ff7be03..d643b44 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,8 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd - `install` 需要时先 `prepare`,再换上 `sdk\emulator` 和包里的其他文件。要先关掉所有实例、管理器窗口和 MCP 客户端,有程序在用就什么都不动(退出码 5),暂存的新版留着,下次直接换; - `recover` 补完或退回上次没做完的换上(每一步开始前也会先做)。 - 更新源在管理器的「设置」里选:GitHub(`ModAVD/ModAVD` 的最新 Release,整包)或 Mirror 酱(填了 CDK 才能下载,能拿到增量包;出错时改从 GitHub 下,并在结果的 `notices` 里写明)。CDK 用 Windows DPAPI 加密存在 `%LOCALAPPDATA%\ModAVD\update-settings.json`,只有这个 Windows 用户能解开,不写日志、不出现在命令行和接口返回里。管理器里暂时只能改设置和检查更新,「更新」按钮不可用。 +- 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 f5c6b55..8a2cbb5 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -333,16 +333,19 @@ func init() { run: runVersion, human: humanOf(formatVersion), }, { - name: "update", args: "[status|check|prepare|install|recover] [--automas-version V]", + name: "update", args: "[status|check|prepare|install|recover] [--automas-version V] [--package ZIP --sha256 HEX]", summary: "更新这个根目录的 ModAVD 包(模拟器、组件、脚本和管理器)", details: "按管理器「设置」里选的更新源(GitHub 或 Mirror 酱,Mirror 酱下载要填 CDK)。不自动联网,只做给出的那一步:" + "status(默认)只看本地;check 查最新版本;prepare 下载、按清单核对、暂存好,实例开着也行;" + "install 需要时先 prepare,再换上 sdk\\emulator 和包里的其他文件,要先关掉所有实例、管理器窗口和 MCP 客户端;" + "recover 补完或退回上次没做完的换上。和 AUTO-MAS 用同一个 .update 目录,能接着对方没做完的做。" + "--automas-version 是调用方 AUTO-MAS 的版本,新版要求更高的 AUTO-MAS 时不装。" + + "macOS 可用 --package ZIP --sha256 HEX 准备或安装本地完整签名包;摘要需从可信来源独立取得。" + "查不了是退出码 9(update_unavailable),下载没通过核对也是 9(update_rejected),有程序在用是 5。", posNames: []string{"status、check、prepare、install 或 recover"}, maxPos: 1, - flags: []flagSpec{optAutoMAS}, final: true, + flags: []flagSpec{optAutoMAS, + {name: "package", kind: kindString, arg: "ZIP", help: "macOS 本地完整更新包"}, + {name: "sha256", kind: kindString, arg: "HEX", help: "本地更新包的独立可信 SHA-256"}}, final: true, run: runUpdate, human: humanOf(formatUpdateStatus), }, { @@ -1384,12 +1387,16 @@ func runUpdate(c *cmdCtx) (any, error) { return nil, errs.Newf(errs.Usage, "只能是 status、check、prepare、install 或 recover,收到「%s」", 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") + } r, err := c.deps.resolveRoot(c.inv.root) if err != nil { return nil, err } var last time.Time u := update.New(r, c.log, update.Options{AutoMASVersion: c.inv.strOpt("automas-version"), + PackagePath: c.inv.strOpt("package"), PackageSHA256: c.inv.strOpt("sha256"), Progress: func(p update.Progress) { if p.Stage != "downloading" || (time.Since(last) < 2*time.Second && p.Done != p.Total) { return diff --git a/internal/components/settings.go b/internal/components/settings.go index 453be66..9268ce6 100644 --- a/internal/components/settings.go +++ b/internal/components/settings.go @@ -4,10 +4,11 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "modavd/internal/errs" - "modavd/internal/root" + "modavd/internal/host" ) // SourceSettings is the component download source selected by the user. An @@ -31,19 +32,11 @@ type sourceSettingsFile struct { // settings, but a separate file so the two settings cannot overwrite one // another. var sourceSettingsPath = func() string { - var base string - if root.CurrentPlatform().Windows() { - base = os.Getenv("LOCALAPPDATA") - if base == "" { - base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") - } - } else { - base, _ = os.UserConfigDir() - if base == "" { - return "" - } + base := host.UserSettingsDir() + if base == "" { + return "" } - return filepath.Join(base, "ModAVD", "components-settings.json") + return filepath.Join(base, "components-settings.json") } // LoadSourceSettings reads the per-user component source. Missing, malformed, @@ -85,7 +78,14 @@ func SaveSourceSettings(mirror string, urls []string) (SourceSettings, error) { return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败") } path := sourceSettingsPath() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if path == "" { + return SourceSettings{}, errs.New(errs.Internal, "找不到当前用户的配置目录,无法保存组件下载源") + } + dirMode := os.FileMode(0o755) + if runtime.GOOS == "darwin" { + dirMode = 0o700 + } + if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil { return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败: "+err.Error()) } tmp := path + ".tmp" 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/host/darwin.go b/internal/host/darwin.go index 577063a..6e2298a 100644 --- a/internal/host/darwin.go +++ b/internal/host/darwin.go @@ -59,3 +59,6 @@ func DiskFree(path string) (uint64, error) { return macos.DiskFree func AvailableMemoryMB() (uint64, error) { return macos.AvailableMemoryMB() } func MetalDevices(ctx context.Context) ([]string, error) { return macos.MetalDevices(ctx) } func RenameNoReplace(src, dst string) error { return macos.RenameNoReplace(src, dst) } + +func Protect(data, entropy []byte) ([]byte, error) { return macos.Protect(data, entropy) } +func Unprotect(data, entropy []byte) ([]byte, error) { return macos.Unprotect(data, entropy) } diff --git a/internal/host/protect_other.go b/internal/host/protect_other.go new file mode 100644 index 0000000..6a6dca9 --- /dev/null +++ b/internal/host/protect_other.go @@ -0,0 +1,9 @@ +//go:build !windows && (!darwin || !cgo) + +package host + +// Protect requires Windows DPAPI or the native macOS Keychain backend. +func Protect([]byte, []byte) ([]byte, error) { return nil, errUnsupported } + +// Unprotect requires Windows DPAPI or the native macOS Keychain backend. +func Unprotect([]byte, []byte) ([]byte, error) { return nil, errUnsupported } diff --git a/internal/host/settings.go b/internal/host/settings.go new file mode 100644 index 0000000..7c0954c --- /dev/null +++ b/internal/host/settings.go @@ -0,0 +1,26 @@ +package host + +import ( + "os" + "path/filepath" + "runtime" +) + +// UserSettingsDir is shared by update and component source settings. Windows +// retains its LOCALAPPDATA convention; macOS uses Application Support in the +// current user's home directory, independently of any ModAVD installation. +func UserSettingsDir() string { + var base string + if runtime.GOOS == "windows" { + base = os.Getenv("LOCALAPPDATA") + if base == "" { + base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") + } + } else { + base, _ = os.UserConfigDir() + if base == "" { + return "" + } + } + return filepath.Join(base, "ModAVD") +} diff --git a/internal/host/unsupported_other.go b/internal/host/unsupported_other.go index 75f4987..035c755 100644 --- a/internal/host/unsupported_other.go +++ b/internal/host/unsupported_other.go @@ -47,12 +47,6 @@ func VolumeFileSystem(string) (string, error) { return "", errUnsupported } // MoveFileReplaceThrough calls MoveFileExW (REPLACE_EXISTING | WRITE_THROUGH). func MoveFileReplaceThrough(string, string) error { return errUnsupported } -// Protect calls CryptProtectData (DPAPI) for the current Windows user. -func Protect([]byte, []byte) ([]byte, error) { return nil, errUnsupported } - -// Unprotect calls CryptUnprotectData (DPAPI) for the current Windows user. -func Unprotect([]byte, []byte) ([]byte, error) { return nil, errUnsupported } - // SetEmulatorWindowsVisible finds the emulator window and calls ShowWindow on Windows. func SetEmulatorWindowsVisible(uint32, bool) (int, error) { return 0, errUnsupported } diff --git a/internal/macos/protect_darwin.go b/internal/macos/protect_darwin.go new file mode 100644 index 0000000..03bc7db --- /dev/null +++ b/internal/macos/protect_darwin.go @@ -0,0 +1,128 @@ +//go:build darwin && cgo + +package macos + +/* +#cgo LDFLAGS: -framework Security -framework CoreFoundation -framework Foundation -framework LocalAuthentication +#include +#include +int32_t modavd_wrapping_key(const char *service, const char *account, unsigned char *key, int create); +*/ +import "C" + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sync" + "unsafe" +) + +// Each purpose gets an independent random AES key in this user's Keychain. +// Only the encrypted, versioned envelope is written to the settings file. +const wrappingKeyService = "org.modavd.manager.settings.v1" + +var wrappingKeyMu sync.Mutex + +// The last byte is the envelope version. Unknown versions are rejected before +// accessing Keychain; raw Windows DPAPI blobs are never mistaken for this form. +var protectedHeader = []byte{'M', 'o', 'd', 'A', 'V', 'D', 'K', 1} + +func wrappingKey(entropy []byte, create bool) ([]byte, error) { + wrappingKeyMu.Lock() + defer wrappingKeyMu.Unlock() + digest := sha256.Sum256(entropy) + service := C.CString(wrappingKeyService) + defer C.free(unsafe.Pointer(service)) + account := C.CString(hex.EncodeToString(digest[:])) + defer C.free(unsafe.Pointer(account)) + key := make([]byte, 32) + flag := C.int(0) + if create { + flag = 1 + } + status := int32(C.modavd_wrapping_key(service, account, (*C.uchar)(unsafe.Pointer(&key[0])), flag)) + if status != 0 { + clear(key) + return nil, keychainError(status) + } + return key, nil +} + +func keychainError(status int32) error { + var reason string + switch status { + case -25300: + reason = "没有对应的包装密钥(设置可能来自其他用户或设备)" + case -25308, -25293: + reason = "钥匙串已锁定或未授权;请先解锁并授权当前应用" + case -25291: + reason = "钥匙串当前不可用" + case -26275: + reason = "包装密钥格式损坏" + default: + reason = "无法读取或保存包装密钥" + } + return fmt.Errorf("macOS Keychain:%s(OSStatus %d)", reason, status) +} + +func protectedCipher(entropy []byte, create bool) (cipher.AEAD, error) { + key, err := wrappingKey(entropy, create) + if err != nil { + return nil, err + } + defer clear(key) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func protectedAAD(entropy []byte) []byte { + aad := make([]byte, 0, len(protectedHeader)+len(entropy)) + aad = append(aad, protectedHeader...) + return append(aad, entropy...) +} + +// Protect encrypts data with AES-256-GCM using a purpose-specific random key +// kept in the current user's Keychain. A locked or unauthorized Keychain fails +// without opening an authentication dialog or falling back to a plaintext key. +func Protect(data, entropy []byte) ([]byte, error) { + gcm, err := protectedCipher(entropy, true) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("生成设置加密随机数:%w", err) + } + envelope := make([]byte, len(protectedHeader), len(protectedHeader)+len(nonce)+len(data)+gcm.Overhead()) + copy(envelope, protectedHeader) + envelope = append(envelope, nonce...) + return gcm.Seal(envelope, nonce, data, protectedAAD(entropy)), nil +} + +// Unprotect authenticates the version, purpose and ciphertext before returning +// any plaintext. It never creates a key when the original key is missing. +func Unprotect(data, entropy []byte) ([]byte, error) { + // Standard GCM uses a 12-byte nonce and 16-byte tag, including empty data. + if len(data) < len(protectedHeader)+12+16 || !bytes.Equal(data[:len(protectedHeader)], protectedHeader) { + return nil, errors.New("macOS 加密设置的格式或版本无效") + } + gcm, err := protectedCipher(entropy, false) + if err != nil { + return nil, err + } + body := data[len(protectedHeader):] + plain, err := gcm.Open(nil, body[:gcm.NonceSize()], body[gcm.NonceSize():], protectedAAD(entropy)) + if err != nil { + return nil, errors.New("macOS 加密设置校验失败(内容被修改或用途不匹配)") + } + return plain, nil +} diff --git a/internal/macos/protect_darwin.m b/internal/macos/protect_darwin.m new file mode 100644 index 0000000..c946521 --- /dev/null +++ b/internal/macos/protect_darwin.m @@ -0,0 +1,70 @@ +//go:build darwin && cgo + +#import +#import +#import +#include +#include + +// Queries are always noninteractive: CLI and background manager requests must +// return an error when Keychain needs consent, rather than opening a modal UI. +int32_t modavd_wrapping_key(const char *service, const char *account, + unsigned char *key, int create) { + @autoreleasepool { + NSString *serviceString = [NSString stringWithUTF8String:service]; + NSString *accountString = [NSString stringWithUTF8String:account]; + if (!serviceString || !accountString) return errSecParam; + LAContext *context = [[LAContext alloc] init]; + context.interactionNotAllowed = YES; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSMutableDictionary *query = [@{ + (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrService: serviceString, + (__bridge id)kSecAttrAccount: accountString, + (__bridge id)kSecAttrSynchronizable: @NO, + (__bridge id)kSecUseAuthenticationContext: context, + // The file-based login Keychain also needs this explicit flag; + // LAContext alone covers the data-protection Keychain backend. + (__bridge id)kSecUseAuthenticationUI: (__bridge id)kSecUseAuthenticationUIFail, + (__bridge id)kSecReturnData: @YES, + (__bridge id)kSecMatchLimit: (__bridge id)kSecMatchLimitOne, + } mutableCopy]; +#pragma clang diagnostic pop + CFTypeRef result = NULL; + BOOL created = NO; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result); + if (status == errSecItemNotFound && create) { + status = SecRandomCopyBytes(kSecRandomDefault, 32, key); + if (status == errSecSuccess) { + [query removeObjectForKey:(__bridge id)kSecReturnData]; + [query removeObjectForKey:(__bridge id)kSecMatchLimit]; + query[(__bridge id)kSecValueData] = [NSData dataWithBytes:key length:32]; + status = SecItemAdd((__bridge CFDictionaryRef)query, NULL); + created = status == errSecSuccess; + if (status == errSecDuplicateItem) { + // Another manager won the creation race. Read its key; + // never replace it or publish data encrypted with our key. + [query removeObjectForKey:(__bridge id)kSecValueData]; + query[(__bridge id)kSecReturnData] = @YES; + query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne; + status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result); + } + } + } + if (result) { + if (status == errSecSuccess && CFGetTypeID(result) == CFDataGetTypeID() + && CFDataGetLength((CFDataRef)result) == 32) { + memcpy(key, CFDataGetBytePtr((CFDataRef)result), 32); + } else if (status == errSecSuccess) { + status = errSecDecode; + } + CFRelease(result); + } else if (status == errSecSuccess && !created) { + status = errSecDecode; + } + [query release]; + [context release]; + return status; + } +} 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 b84709e..932fec0 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -161,7 +161,8 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9, `update.Status` 的全部字段(和 `modavd update --json` 的 `data` 相同:`root`、`updatable`、`not_updatable_reason`、`current_version`、`current_build`、`source`、`cdk_set`、`checked`、`check_error`、`latest_version`、`latest_source`、`update_available`、`required_automas`、`release_url`、`phase`、`prepared_version`、`last_result`、`recovered`、`updated`、`message`、`notices`),加上 `checking`、`checked_at`(RFC 3339,本次启动后没查过是 `""`)、`error`(上次检查的错误对象,没有是 `null`)、`install_by_cli`(现在恒为 `true`:管理器里不能安装,由 AUTO-MAS 调 `modavd update install`)。 - 只有点「检查更新」才联网;没有自动检查。 -- 更新源和 CDK 是本机这个 Windows 用户的设置(`%LOCALAPPDATA%\ModAVD\update-settings.json`),命令行更新时按它来。 +- 更新源和 CDK 是当前系统用户的设置,命令行更新时按它来。Windows 使用 `%LOCALAPPDATA%\ModAVD\update-settings.json` 和 DPAPI;macOS 使用 `~/Library/Application Support/ModAVD/update-settings.json`,CDK 的随机 AES-GCM 包装密钥保存在当前用户的 Keychain。钥匙串锁定或未授权时保存失败,不弹授权框,不保存明文密钥。 +- 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..b0e5d26 --- /dev/null +++ b/internal/update/MACOS.md @@ -0,0 +1,41 @@ +# macOS update package contract + +Native Darwin/ARM64 builds use `modavd-macos-update-v1`, independently of the Windows/AUTO-MAS transaction format. Local packages are selected only by `update prepare` or `update install` with both `--package ZIP` and `--sha256 HEX`. Obtain that SHA256 through a trusted independent channel. Installation of a staged package and interrupted-transaction recovery need no network or saved credentials. + +A ZIP contains `modavd-update-manifest.json` at its payload root, without an enclosing directory. The transport manifest is not installed. Its object has these fields: + +```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": "..."} + } +} +``` + +The example is structural; it is not an installable inventory. Every non-directory payload entry must appear exactly once. File hashes cover bytes; symlink hashes cover the exact UTF-8 target bytes. Modes are decimal Unix permission bits (493 is 0755). Only ordinary files and symlinks are accepted. Directories are inferred. `xattrs` optionally maps the supported `com.apple.cs.*` signature attributes to base64 bytes; packaging must capture them when a signature uses them. Quarantine and unrelated attributes are not copied. + +The four units above are required. Other allowed unit names are explicitly listed in `nativeAllowedUnits`; guest data, root settings, platform-tools and system-images are never units. Paths must be normalized ASCII and case-unambiguous. Links must resolve entirely within their own unit, with no dangling targets, cycles or traversal through files. ZIP and manifest must agree on type, mode, size, hash and link target. + +These ordinary files must contain ARM64 Mach-O code and have executable bits: + +- `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` + +The manager opens the system browser; no separate WebView helper is required. `ModAVD.app/Contents/Info.plist` must name `ModAVD` as `CFBundleExecutable`. `sdk/emulator/source.properties` must have `Pkg.BuildId=mas-`, the headless QEMU binary must contain `free-page-reporting`, and the installed `modavd-manifest.json` version must match the transport version. Every Mach-O is checked with `codesign --verify --strict`; the app and emulator trees additionally use `--deep --strict`. This checks integrity, including ad hoc signatures; it does not authenticate a trusted publisher or notarization. Each verification process is time-bounded, and the verification pass has a total deadline. + +Limits are 20,000 ZIP entries, 4 GiB unpacked bytes, a 16 MiB transport manifest, 12 MiB encoded signature attributes, and a 1 MiB transaction journal. Writes retain at least 20 GiB free space and stop if free space cannot be determined. Local input is copied privately before digest verification and extraction. Staged content is hashed and signed code reverified before replacement; modification time alone is never trusted. + +Installation holds managed instance start locks and scans running executable paths under the SDK, app and tools. These checks are advisory on macOS: a program launched outside the manager can race them. Close all instances, manager windows and MCP clients first. The executing updater itself can replace its pathname while its original inode continues running. Each unit is journaled before/after renames; recovery verifies the complete candidate across staged and live units, then finishes it or restores originals. The metadata undo record restores this transaction's emulator version and history on rollback while preserving unrelated settings. Corrupt or ambiguous journals retain the scene for inspection. + +Remote release selection uses `macos-arm64`, requires a SHA256 and full-package size, and refuses missing assets as `update_unavailable`. Publishing packages in this format, distribution signing and packaging-profile alignment belong to the later release-packaging stage. 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..7162fb7 --- /dev/null +++ b/internal/update/native_transaction_darwin.go @@ -0,0 +1,612 @@ +//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 + } + 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 9ede540..e62eb4b 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 { @@ -54,6 +57,9 @@ func updateTree(t *testing.T, dir string) []string { } func TestSaveSettingsUnsupported(t *testing.T) { + if nativeDarwinSettings && root.CurrentPlatform().Supported() { + t.Skip("native macOS supports per-user settings") + } if _, err := SaveSettings(SourceGitHub, nil); errs.CodeOf(err) != errs.Unsupported { t.Fatalf("SaveSettings: %v", err) } diff --git a/internal/update/settings.go b/internal/update/settings.go index a34adba..78ac0a5 100644 --- a/internal/update/settings.go +++ b/internal/update/settings.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "modavd/internal/errs" @@ -19,9 +20,9 @@ const ( ) // Settings are the user's update choices, set in the manager (设置 → 更新) and used by `modavd update` (AUTO-MAS -// calls it): which source to download from, and the MirrorChyan CDK. One file per Windows user, -// %LOCALAPPDATA%\ModAVD\update-settings.json; the CDK is encrypted for that user with DPAPI, never written, logged or -// returned in clear. +// calls it): which source to download from, and the MirrorChyan CDK. Settings +// use the per-user ModAVD configuration directory. The CDK is encrypted with +// Windows DPAPI or a macOS Keychain wrapping key, never written or logged clear. type Settings struct { Source string // SourceGitHub or SourceMirrorChyan cdk string @@ -35,23 +36,28 @@ func (s Settings) HasCDK() bool { return s.cdk != "" } type settingsFile struct { Source string `json:"source"` - CDK string `json:"cdk,omitempty"` // base64 of the DPAPI blob + CDK string `json:"cdk,omitempty"` // base64 of the platform-protected blob } -// cdkEntropy is DPAPI's extra secret for the CDK: another program of the same user cannot decrypt the blob with a -// plain CryptUnprotectData call without it. +// cdkEntropy binds the protected blob to this use. It is DPAPI's optional +// entropy on Windows and the Keychain key purpose / authenticated data on macOS. var cdkEntropy = []byte("ModAVD update: MirrorChyan CDK") -// settingsPath is %LOCALAPPDATA%\ModAVD\update-settings.json; a variable for the tests. +func settingsSupported() bool { + p := root.CurrentPlatform() + return p.Windows() || nativeDarwinSettings && p.Supported() +} + +// settingsPath is per user, independent of the installation; a variable for tests. var settingsPath = func() string { - if !root.CurrentPlatform().Windows() { + if !settingsSupported() { return "" } - base := os.Getenv("LOCALAPPDATA") + base := host.UserSettingsDir() if base == "" { - base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") + return "" } - return filepath.Join(base, "ModAVD", "update-settings.json") + return filepath.Join(base, "update-settings.json") } // LoadSettings reads the settings; missing or unreadable means GitHub without CDK. A CDK that does not decrypt @@ -84,7 +90,7 @@ func LoadSettings() Settings { // SaveSettings changes the source (when not "") and the CDK (when cdk is not nil; "" clears it). func SaveSettings(source string, cdk *string) (Settings, error) { s := LoadSettings() - if !root.CurrentPlatform().Windows() { + if !settingsSupported() { return s, errs.New(errs.Unsupported, "当前平台暂不支持保存更新设置") } switch source { @@ -114,7 +120,14 @@ func SaveSettings(source string, cdk *string) (Settings, error) { return s, errs.Wrap(errs.Internal, err, "保存更新设置失败") } path := settingsPath() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if path == "" { + return s, errs.New(errs.Internal, "找不到当前用户的配置目录,无法保存更新设置") + } + dirMode := os.FileMode(0o755) + if runtime.GOOS == "darwin" { + dirMode = 0o700 + } + if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil { return s, errs.Wrap(errs.Internal, err, "保存更新设置失败: "+err.Error()) } tmp := path + ".tmp" diff --git a/internal/update/settings_darwin.go b/internal/update/settings_darwin.go new file mode 100644 index 0000000..ab2bebb --- /dev/null +++ b/internal/update/settings_darwin.go @@ -0,0 +1,5 @@ +//go:build darwin && cgo + +package update + +const nativeDarwinSettings = true diff --git a/internal/update/settings_other.go b/internal/update/settings_other.go new file mode 100644 index 0000000..3a0c995 --- /dev/null +++ b/internal/update/settings_other.go @@ -0,0 +1,5 @@ +//go:build !darwin || !cgo + +package update + +const nativeDarwinSettings = false 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 a947cf3..5e6e09f 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 b931076..da3c44b 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) } @@ -62,10 +65,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 @@ -132,7 +136,10 @@ func (u *Updater) notUpdatable() string { // status fills what needs no network. func (u *Updater) status() *Status { - set := LoadSettings() + set := Settings{Source: SourceGitHub} + if u.Opt.PackagePath == "" && !u.hasNativeTransaction() && !u.localOperation { + set = LoadSettings() + } st := &Status{Root: u.Root.Dir, Source: set.Source, CDKSet: set.HasCDK(), Notices: []string{}} st.NotUpdatableReason = u.notUpdatable() st.Updatable = st.NotUpdatableReason == "" @@ -206,6 +213,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()) } @@ -263,6 +275,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 } @@ -420,6 +435,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 @@ -487,6 +505,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 }