diff --git a/internal/download/download.go b/internal/download/download.go index 47c4daf..cb6e58a 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -354,50 +354,79 @@ func (c *Client) File(ctx context.Context, o FileOptions) error { return nil } -// Range reads bytes [offset, offset+length) of a file of total bytes with one range request. The answer must be 206 -// with exactly that Content-Range; anything else (a server that ignores Range answers 200 with the whole file) is an -// Error of Kind NoRange, and its body is not read. +// Range reads a small range into memory. Use RangeTo for entries that can be large. func (c *Client) Range(ctx context.Context, rawURL string, offset, length, total int64, what string) ([]byte, error) { - if offset < 0 || length <= 0 || offset+length > total { - return nil, &Error{Message: fmt.Sprintf("要的区间不对(%d+%d / %d)", offset, length, total)} + var b bytes.Buffer + if err := c.RangeTo(ctx, rawURL, offset, length, total, what, &b); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// RangeTo writes bytes [offset, offset+length) to dst using a fixed-size buffer. The answer must be 206 with exactly +// that Content-Range and byte count. Rejected headers leave dst untouched. A failed transfer may leave a prefix; +// the caller must reset dst before retrying. Writer errors are returned unchanged and must not be retried as network errors. +func (c *Client) RangeTo(ctx context.Context, rawURL string, offset, length, total int64, what string, dst io.Writer) error { + if offset < 0 || length <= 0 || offset > total || length > total-offset { + return &Error{Message: fmt.Sprintf("要的区间不对(%d+%d / %d)", offset, length, total)} } end := offset + length - 1 resp, err := c.get(ctx, rawURL, fmt.Sprintf("bytes=%d-%d", offset, end), what) if err != nil { - return nil, err + return err } defer resp.close() want := fmt.Sprintf("bytes %d-%d/%d", offset, end, total) if got := resp.Header.Get("Content-Range"); resp.StatusCode != http.StatusPartialContent || got != want { - return nil, &Error{Message: fmt.Sprintf("服务器没按区间回(HTTP %d,Content-Range: %q,要的是 %s)", resp.StatusCode, got, want), + return &Error{Message: fmt.Sprintf("服务器没按区间回(HTTP %d,Content-Range: %q,要的是 %s)", resp.StatusCode, got, want), Kind: NoRange, Status: resp.StatusCode} } if resp.ContentLength >= 0 && resp.ContentLength != length { - return nil, sizeError("区间大小和预期不符(服务器给 %d 字节,应为 %d 字节)", resp.ContentLength, length) + return sizeError("区间大小和预期不符(服务器给 %d 字节,应为 %d 字节)", resp.ContentLength, length) } - buf := make([]byte, length+1) // one more to notice a longer body - done := 0 + buf := make([]byte, 64<<10) + var done int64 for { - n, rerr := resp.read(buf[done:], c.stallTimeout()) - done += n - if done > int(length) { - return nil, sizeError("区间大小和预期不符(收到的比 %d 字节多)", length) + if ctx.Err() != nil { + return canceled(ctx) + } + // One extra byte detects a chunked response that runs past the requested range, without writing it. + next := int64(len(buf)) + if length-done < next { + next = length - done + 1 + } + n, rerr := resp.read(buf[:next], c.stallTimeout()) + if int64(n) > length-done { + return sizeError("区间大小和预期不符(收到的比 %d 字节多)", length) + } + if n > 0 { + written, werr := dst.Write(buf[:n]) + if werr != nil { + return werr + } + if written != n { + return io.ErrShortWrite + } + done += int64(n) } if rerr == io.EOF { break } if rerr != nil { if ctx.Err() != nil { - return nil, canceled(ctx) + return canceled(ctx) } if resp.fired.Load() { - return nil, timeoutError("超时:%s(收数据,已收 %d 字节),%s 秒内没有进展", what, done, seconds(c.stallTimeout())) + return timeoutError("超时:%s(收数据,已收 %d 字节),%s 秒内没有进展", what, done, seconds(c.stallTimeout())) } - return nil, &Error{Message: "收数据出错:" + NetError(rerr, "")} + return &Error{Message: "收数据出错:" + NetError(rerr, "")} } } - if done != int(length) { - return nil, &Error{Message: fmt.Sprintf("区间没收全:应为 %d 字节,实际 %d 字节", length, done)} + if ctx.Err() != nil { + return canceled(ctx) } - return buf[:length], nil + if done != length { + return &Error{Message: fmt.Sprintf("区间没收全:应为 %d 字节,实际 %d 字节", length, done)} + } + return nil } diff --git a/internal/download/range_stream_test.go b/internal/download/range_stream_test.go new file mode 100644 index 0000000..ab3e286 --- /dev/null +++ b/internal/download/range_stream_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package download + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + "time" + + "modavd/internal/errs" +) + +type rangeWriter struct { + count, max int + err error + cancel context.CancelFunc +} + +func (w *rangeWriter) Write(p []byte) (int, error) { + w.count += len(p) + w.max = max(w.max, len(p)) + if w.cancel != nil { + w.cancel() + } + if w.err != nil { + return 0, w.err + } + return len(p), nil +} + +func TestRangeToStreamsWithBoundedMemory(t *testing.T) { + const size = 12 << 20 + // A generated body has no size-dependent allocation to hide the client's allocation cost. + body := &watchedBody{Reader: io.LimitReader(zeroReader{}, size)} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 206, Header: http.Header{"Content-Range": {fmt.Sprintf("bytes 0-%d/%d", size-1, size)}}, ContentLength: size, Body: body}, nil + })}} + w := &rangeWriter{} + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + err := d.RangeTo(context.Background(), "https://example.invalid/pkg", 0, size, size, "test", w) + runtime.ReadMemStats(&after) + allocated := after.TotalAlloc - before.TotalAlloc + if err != nil || w.count != size || w.max > 64<<10 || !body.closed || allocated > 2<<20 { + t.Fatalf("stream: bytes %d, max chunk %d, allocated %d, closed %v, error %v", w.count, w.max, allocated, body.closed, err) + } + t.Logf("%d bytes transferred, max write %d, allocated %d", size, w.max, allocated) +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { clear(p); return len(p), nil } + +func TestRangeToProtocol(t *testing.T) { + for _, tc := range []struct { + name, cr, body, encoding string + status int + length int64 + kind Kind + want string + }{ + {"valid", "bytes 2-4/10", "cde", "", 206, 3, Network, "cde"}, + {"ignored", "", "abcdefghij", "", 200, 10, NoRange, ""}, + {"other range", "bytes 2-5/10", "cdef", "", 206, 4, NoRange, ""}, + {"wrong total", "bytes 2-4/11", "cde", "", 206, 3, NoRange, ""}, + {"wrong length", "bytes 2-4/10", "cd", "", 206, 2, Size, ""}, + {"encoding", "bytes 2-4/10", "cde", "gzip", 206, 3, Encoding, ""}, + {"short", "bytes 2-4/10", "cd", "", 206, -1, Network, "cd"}, + {"long", "bytes 2-4/10", "cdef", "", 206, -1, Size, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + body := &watchedBody{Reader: strings.NewReader(tc.body)} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Range") != "bytes=2-4" || req.Header.Get("Accept-Encoding") != "identity" { + t.Errorf("headers: %v", req.Header) + } + return &http.Response{StatusCode: tc.status, Header: http.Header{"Content-Range": {tc.cr}, "Content-Encoding": {tc.encoding}}, ContentLength: tc.length, Body: body}, nil + })}} + var dst bytes.Buffer + err := d.RangeTo(context.Background(), "https://example.invalid/pkg", 2, 3, 10, "test", &dst) + var de *Error + if tc.name == "valid" { + if err != nil { + t.Fatal(err) + } + } else if !errors.As(err, &de) || de.Kind != tc.kind { + t.Fatalf("want kind %d, got %v", tc.kind, err) + } + if dst.String() != tc.want || !body.closed { + t.Fatalf("output %q, closed %v", dst.String(), body.closed) + } + }) + } +} + +func TestRangeToLocalErrorAndCancel(t *testing.T) { + local := errors.New("disk full") + for _, cancelWrite := range []bool{false, true} { + ctx, cancel := context.WithCancel(context.Background()) + body := &watchedBody{Reader: strings.NewReader("abc")} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 206, Header: http.Header{"Content-Range": {"bytes 0-2/3"}}, ContentLength: 3, Body: body}, nil + })}} + w := &rangeWriter{err: local} + if cancelWrite { + w.err, w.cancel = nil, cancel + } + err := d.RangeTo(ctx, "https://example.invalid/pkg", 0, 3, 3, "test", w) + cancel() + if (!cancelWrite && !errors.Is(err, local)) || (cancelWrite && errs.CodeOf(err) != errs.Canceled) || !body.closed { + t.Fatalf("cancel %v: closed %v, error %v", cancelWrite, body.closed, err) + } + } +} + +func TestRangeToStallClosesResponse(t *testing.T) { + closed := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "bytes 0-5/6") + w.Header().Set("Content-Length", "6") + w.WriteHeader(206) + io.WriteString(w, "abc") + w.(http.Flusher).Flush() + <-r.Context().Done() + close(closed) + })) + defer srv.Close() + d := Client{HTTP: NewHTTPClient(0), StallTimeout: 30 * time.Millisecond} + err := d.RangeTo(context.Background(), srv.URL, 0, 6, 6, "test", io.Discard) + var de *Error + if !errors.As(err, &de) || de.Kind != Timeout { + t.Fatalf("want timeout, got %v", err) + } + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("response not canceled") + } +} + +func TestRangeToOverflow(t *testing.T) { + requests := 0 + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + requests++ + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(""))}, nil + })}} + for _, bounds := range [][3]int64{{math.MaxInt64 - 1, 5, math.MaxInt64}, {-1, 1, 10}, {0, 0, 10}} { + if err := d.RangeTo(context.Background(), "https://example.invalid/pkg", bounds[0], bounds[1], bounds[2], "test", io.Discard); err == nil { + t.Fatal("invalid range accepted") + } + } + if requests != 0 { + t.Fatal("invalid range sent a request") + } +} diff --git a/internal/update/caller_settings_test.go b/internal/update/caller_settings_test.go index 72ec9d3..b994ad8 100644 --- a/internal/update/caller_settings_test.go +++ b/internal/update/caller_settings_test.go @@ -116,8 +116,8 @@ func TestCallerSettingsOverride(t *testing.T) { t.Fatalf("status: %+v", st) } st, err := u.Install(context.Background()) - // every file changed between the two builds: GitHub's range path falls back to the whole package (second notice) - if err != nil || !st.Updated || len(st.Notices) != 2 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") || !strings.Contains(st.Notices[1], "变了的文件太多") { + // The normal GitHub range threshold adds no notice; the bad CDK still does. + if err != nil || !st.Updated || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") { t.Fatalf("install: %+v %v", st, err) } if len(seen) == 0 || seen[len(seen)-1]["cdk"] != cdk { diff --git a/internal/update/package_compat_windows_test.go b/internal/update/package_compat_windows_test.go new file mode 100644 index 0000000..8e01020 --- /dev/null +++ b/internal/update/package_compat_windows_test.go @@ -0,0 +1,223 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "testing" + + "modavd/internal/errs" +) + +func compatArchive(t *testing.T, files map[string]string, incremental, manifest bool, deleted []string) []byte { + t.Helper() + payload := merged(files) + if manifest { + payload[manifestName] = string(manifestFor(37, files)) + } + if incremental { + modified := make([]string, 0, len(payload)) + for name := range payload { + modified = append(modified, name) + } + sort.Strings(modified) + changes, err := json.Marshal(map[string][]string{"modified": modified, "deleted": deleted}) + if err != nil { + t.Fatal(err) + } + payload["changes.json"] = string(changes) + } + return zipOf(t, 37, payload, true) +} + +func stageCachedPackage(t *testing.T, data []byte, incremental bool, tag, lockTarget string, baseFiles map[string]string) (*Updater, string, error) { + t.Helper() + r := writeRoot(t, 36, baseFiles) + u := New(r, nil, Options{}) + archive := u.l.downloadAt("cached.zip") + if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(archive, data, 0o644); err != nil { + t.Fatal(err) + } + rel := &release{source: SourceMirrorChyan, tag: tag, version: Version{0, 37, 0}, assetName: "cached.zip", url: "https://unused.invalid/cached.zip", size: int64(len(data)), sha256: hashBytes(data), incremental: incremental, base: Version{0, 36, 0}} + var held syscall.Handle + defer func() { + if held != 0 { + syscall.CloseHandle(held) + } + }() + if lockTarget != "" { + u.Opt.Progress = func(p Progress) { + if p.Stage != "verifying" || held != 0 { + return + } + name, err := syscall.UTF16PtrFromString(u.l.pkgAt(lockTarget)) + if err != nil { + t.Fatal(err) + } + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + } + } + err := u.stage(context.Background(), rel, &Status{}) + return u, archive, err +} + +func TestRequiredPackageDirectoriesRejectAndDeleteCache(t *testing.T) { + for _, mode := range []string{"full", "incremental", "legacy"} { + for _, target := range []string{manifestName, "sdk/emulator/source.properties", "sdk/emulator/qemu/windows-x86_64/qemu-system-x86_64-headless.exe"} { + t.Run(mode+"/"+target, func(t *testing.T) { + files := pkgFiles(37, nil) + delete(files, target) + files[target+"/child"] = "directory supplied by the package" + incremental := mode == "incremental" + manifest := mode != "legacy" && target != manifestName + tag := "v0.37.0" + if mode == "legacy" { + tag = "v37.2.10-mas37" + } + u, archive, err := stageCachedPackage(t, compatArchive(t, files, incremental, manifest, []string{target}), incremental, tag, "", nil) + if mode == "legacy" && target == manifestName { + if err != nil || stagedMode(u) != "full" || !lexists(archive) { + t.Fatalf("optional legacy manifest directory changed behavior: %v", err) + } + } else if errs.CodeOf(err) != errs.UpdateRejected || lexists(archive) || phaseOf(u.l.readState()) != phaseIdle { + t.Fatalf("package directory: error %v, code %s, cached %v, phase %s", err, errs.CodeOf(err), lexists(archive), phaseOf(u.l.readState())) + } + }) + } + } +} + +func TestPackageReadErrorsRetainFullAndIncrementalCache(t *testing.T) { + for _, incremental := range []bool{false, true} { + for _, target := range []string{manifestName, "sdk/emulator/emulator.exe"} { + name := "full/" + target + if incremental { + name = "incremental/" + target + } + t.Run(name, func(t *testing.T) { + data := compatArchive(t, pkgFiles(37, nil), incremental, true, nil) + u, archive, err := stageCachedPackage(t, data, incremental, "v0.37.0", target, nil) + var pe *os.PathError + if !errors.As(err, &pe) || errs.CodeOf(err) == errs.UpdateRejected || !lexists(archive) || lexists(u.l.stagedMarker()) { + t.Fatalf("local read: %v, cached %v", err, lexists(archive)) + } + // The read-denying handle is released before cleanup retries the parent-directory rename. + if err := u.l.abortStaging("test read failure"); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestWholeAndLegacyPackageSuccess(t *testing.T) { + for _, legacy := range []bool{false, true} { + name, tag := "full", "v0.37.0" + if legacy { + name, tag = "legacy", "v37.2.10-mas37" + } + t.Run(name, func(t *testing.T) { + files := pkgFiles(37, nil) + u, _, err := stageCachedPackage(t, compatArchive(t, files, false, !legacy, nil), false, tag, "", nil) + if err != nil || stagedMode(u) != "full" { + t.Fatalf("stage: %v", err) + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestIncrementalPackageReusesAddsAndDeletes(t *testing.T) { + files := pkgFiles(37, map[string]string{"tools/keep.ps1": "kept", "tools/added.ps1": "added"}) + payload := merged(files) + delete(payload, "tools/keep.ps1") + payload[manifestName] = string(manifestFor(37, files)) + modified := []string{manifestName} + for name := range payload { + if name != manifestName && name != "tools/added.ps1" { + modified = append(modified, name) + } + } + sort.Strings(modified) + changes, err := json.Marshal(map[string][]string{"added": {"tools/added.ps1"}, "modified": modified, "deleted": {"tools/deleted.ps1"}}) + if err != nil { + t.Fatal(err) + } + payload["changes.json"] = string(changes) + u, _, err := stageCachedPackage(t, zipOf(t, 37, payload, true), true, "v0.37.0", "", map[string]string{"tools/keep.ps1": "kept", "tools/deleted.ps1": "old"}) + if err != nil || stagedMode(u) != "incremental" { + t.Fatalf("stage: %v", err) + } + for name, want := range files { + path := u.l.pkgAt(name) + if suffix, ok := strings.CutPrefix(name, "sdk/emulator/"); ok { + path = filepath.Join(u.l.stageEmulator(), filepath.FromSlash(suffix)) + } + body, err := os.ReadFile(path) + if err != nil || string(body) != want { + t.Fatalf("%s: %q %v", name, body, err) + } + } + if lexists(u.l.pkgAt("tools/deleted.ps1")) || !lexists(u.l.at("tools/deleted.ps1")) { + t.Fatal("incremental deletion or installed-root isolation failed") + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatal(err) + } +} + +func TestMirrorPackageDirectoryFallsBackToGitHub(t *testing.T) { + for _, incremental := range []bool{false, true} { + name, updateType := "full", "full" + if incremental { + name, updateType = "incremental", "incremental" + } + t.Run(name, func(t *testing.T) { + withSettings(t, SourceMirrorChyan, "test-cdk") + target := "sdk/emulator/source.properties" + badFiles := pkgFiles(37, nil) + delete(badFiles, target) + badFiles[target+"/child"] = "not a properties file" + bad := compatArchive(t, badFiles, incremental, true, []string{target}) + good := zipOf(t, 37, pkgFiles(37, nil), false) + f := newSources(t, 37, good) + f.mirror = func(map[string]string) (int, any) { + return 0, map[string]any{"version_name": "v0.37.0", "url": f.srv.URL + "/dl/bad.zip", "filesize": len(bad), "sha256": hashBytes(bad), "update_type": updateType} + } + log := &dlLog{} + serve := log.serve(good, nil) + f.dl = func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/bad.zip") { + http.ServeContent(w, r, "bad.zip", zipTime, bytes.NewReader(bad)) + return + } + serve(w, r) + } + u := f.updater(writeRoot(t, 36, nil)) + st, err := u.Prepare(context.Background()) + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || log.fullGets() != 1 || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "Mirror 酱") { + t.Fatalf("fallback: %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + }) + } +} diff --git a/internal/update/pkg.go b/internal/update/pkg.go index 0ca12e6..0e029dc 100644 --- a/internal/update/pkg.go +++ b/internal/update/pkg.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -167,6 +168,9 @@ func extractEntry(e zipEntry, dst string) error { } rc, err := e.f.Open() if err != nil { + if isDiskError(err) { + return err + } return packageError("包里的 %s 打不开: %v", e.rel, err) } defer rc.Close() @@ -174,10 +178,7 @@ func extractEntry(e zipEntry, dst string) error { if err != nil { return err } - _, cerr := io.Copy(out, rc) - if err := out.Close(); cerr == nil { - cerr = err - } + cerr := copyEntryContents(out, rc) if cerr != nil { os.Remove(dst) if os.IsPermission(cerr) || isDiskError(cerr) { @@ -188,9 +189,18 @@ func extractEntry(e zipEntry, dst string) error { return nil } +// copyEntryContents owns the output handle, including when reading the ZIP entry fails. +func copyEntryContents(out io.WriteCloser, source io.Reader) error { + _, err := io.Copy(out, source) + if closeErr := out.Close(); closeErr != nil { + return closeErr + } + return err +} + func isDiskError(err error) bool { - _, ok := err.(*os.PathError) - return ok + var pathErr *os.PathError + return errors.As(err, &pathErr) } // extractPackage unpacks a full package into pkg\; the entry count. @@ -421,16 +431,41 @@ func (l layout) buildPatchedPackage(r *root.Root, archive string, base Version) // ---- the manifest +// regularPackageFile distinguishes an absent or wrong-shaped package entry from a local metadata error. +func regularPackageFile(path string) (bool, error) { + st, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return st.Mode().IsRegular(), nil +} + +// readManifest is a best-effort installed-version probe. Staging callers use loadManifest to retain I/O errors. func readManifest(path string) (*jsonord.Obj, bool) { + m, ok, _ := loadManifest(path) + return m, ok +} + +func loadManifest(path string) (*jsonord.Obj, bool, error) { + regular, err := regularPackageFile(path) + if err != nil || !regular { + return nil, false, err + } b, err := root.ReadFileShared(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } if err != nil { - return nil, false + return nil, false, err } v, err := jsonord.Parse(trimBOM(b)) if err != nil || v.Kind() != jsonord.Object || v.Obj().Len() == 0 { - return nil, false + return nil, false, nil } - return v.Obj(), true + return v.Obj(), true, nil } func manifestString(m *jsonord.Obj, key string) string { @@ -475,9 +510,10 @@ func verifyTree(tree string, files map[string]fileSpec) ([]string, error) { want[strings.ToLower(rel)] = rel } var problems []string + var readErr error seen := map[string]bool{} err := walkFiles(tree, func(rel string, info os.FileInfo) { - if strings.EqualFold(rel, manifestName) { + if readErr != nil || strings.EqualFold(rel, manifestName) { return } key := strings.ToLower(rel) @@ -492,13 +528,21 @@ func verifyTree(tree string, files map[string]fileSpec) ([]string, error) { problems = append(problems, rel+" 大小不对") return } - if sum, err := hashFile(filepath.Join(tree, filepath.FromSlash(rel))); err != nil || sum != spec.sha256 { + sum, err := hashFile(filepath.Join(tree, filepath.FromSlash(rel))) + if err != nil { + readErr = err + return + } + if sum != spec.sha256 { problems = append(problems, rel+" 内容与清单不符") } }) if err != nil { return nil, err } + if readErr != nil { + return nil, readErr + } for key, rel := range want { if !seen[key] { problems = append(problems, "缺 "+rel) @@ -546,10 +590,9 @@ func checkPackageManifest(m *jsonord.Obj, target Version) error { var buildIDRe = regexp.MustCompile(`^mas-([0-9]+)$`) -// packageBuild is the emulator build the package in pkg\ says it carries: its manifest's "build", else (a legacy -// package without one) the target's minor number, as versions through 0.40 were numbered. -func (l layout) packageBuild(target Version) int { - if m, ok := readManifest(l.pkgAt(manifestName)); ok { +// manifestBuild is the emulator build from the already-read manifest, or the minor version for legacy packages. +func manifestBuild(m *jsonord.Obj, target Version) int { + if m != nil { if n, ok := m.Get("build").Int(); ok && n > 0 { return int(n) } @@ -561,12 +604,22 @@ func (l layout) packageBuild(target Version) int { // and its qemu is our own build (it knows the free-page-reporting balloon option). func checkEmulator(emu string, want int, target Version) error { p := root.CurrentPlatform() - qemu := filepath.Join(emu, "qemu", p.QemuDir, p.QEMU) - if !isFile(filepath.Join(emu, p.Emulator)) || !isFile(qemu) { - return packageError(`包里的 sdk\emulator 不完整(缺 emulator.exe 或 qemu)`) + for _, rel := range []string{p.Emulator, filepath.Join("qemu", p.QemuDir, p.QEMU), + "source.properties", filepath.Join("qemu", p.QemuDir, p.QEMUHeadless)} { + regular, err := regularPackageFile(filepath.Join(emu, rel)) + if err != nil { + return err + } + if !regular { + return packageError("包里的 sdk/emulator/%s 不存在或不是普通文件", filepath.ToSlash(rel)) + } } build := "读不到" - if b, err := os.ReadFile(filepath.Join(emu, "source.properties")); err == nil { + b, err := os.ReadFile(filepath.Join(emu, "source.properties")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil { id := "" for _, line := range strings.Split(string(b), "\n") { if k, v, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == "Pkg.BuildId" { @@ -580,26 +633,36 @@ func checkEmulator(emu string, want int, target Version) error { if build != fmt.Sprint(want) { return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), want) } - if !fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) { + ours, err := fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) + if err != nil { + return err + } + if !ours { return packageError("包里的模拟器不是 ModAVD 的自编版") } return nil } -// fileContains scans p in 4 MiB chunks (with overlap) for needle; false when unreadable. -func fileContains(p string, needle []byte) bool { +// fileContains scans p in 4 MiB chunks (with overlap) for needle. A missing file has no match; other I/O errors propagate. +func fileContains(p string, needle []byte) (bool, error) { f, err := os.Open(p) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } if err != nil { - return false + return false, err } defer f.Close() buf := make([]byte, 4<<20) carry := []byte{} for { n, err := f.Read(buf) + if err != nil && err != io.EOF { + return false, err + } chunk := append(carry, buf[:n]...) if bytes.Contains(chunk, needle) { - return true + return true, nil } if len(chunk) >= len(needle) { carry = append([]byte{}, chunk[len(chunk)-len(needle)+1:]...) @@ -607,7 +670,7 @@ func fileContains(p string, needle []byte) bool { carry = chunk } if err != nil { - return false + return false, nil } } } @@ -615,7 +678,10 @@ func fileContains(p string, needle []byte) bool { // verifyPackage is AUTO-MAS verify_package: "manifest" when pkg\ matches its manifest, "zip" for a legacy package // without one (only its zip CRC and download hash vouch for it). func (l layout) verifyPackage(target Version, tag string) (string, error) { - m, ok := readManifest(l.pkgAt(manifestName)) + m, ok, err := loadManifest(l.pkgAt(manifestName)) + if err != nil { + return "", err + } verified := "zip" switch { case !ok && requiresManifest(tag): @@ -642,7 +708,7 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { } verified = "manifest" } - if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { + if err := checkEmulator(l.pkgAt("sdk/emulator"), manifestBuild(m, target), target); err != nil { return "", err } return verified, nil @@ -650,7 +716,11 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { // buildStage moves pkg\sdk\emulator to .update\stage\emulator. func (l layout) buildStage(target Version) error { - if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { + m, _, err := loadManifest(l.pkgAt(manifestName)) + if err != nil { + return err + } + if err := checkEmulator(l.pkgAt("sdk/emulator"), manifestBuild(m, target), target); err != nil { return err } if err := removeTree(l.stage()); err != nil { diff --git a/internal/update/range.go b/internal/update/range.go index b63e886..aadc76b 100644 --- a/internal/update/range.go +++ b/internal/update/range.go @@ -6,11 +6,12 @@ package update // unpacking the whole package would leave it. The manifest check that follows is the same as for a full download. // // archive/zip reads the package through remoteZip, an io.ReaderAt that holds only the pieces fetched so far. Anything -// that goes wrong on the way (the server ignores Range, too much changed, a fetched entry fails its CRC, the result -// fails the manifest) is a *rangeUnavailable: the caller drops pkg\ and downloads the whole package instead. +// that makes the range result unusable (unsupported ranges, too much changed, a bad CRC or manifest) returns a +// *rangeUnavailable: the caller drops pkg\ and downloads the whole package instead. Local creation, write and close errors stop the update. import ( "archive/zip" + "bytes" "context" "errors" "fmt" @@ -44,19 +45,22 @@ const ( rangeMaxDirectory = 32 << 20 ) -// rangeUnavailable: this update cannot be done by range; download the whole package. The reason goes into the notices. -type rangeUnavailable struct{ reason string } +// rangeUnavailable: download the whole package. Only failed attempts, not normal threshold decisions, add a notice. +type rangeUnavailable struct { + reason string + normal bool // a threshold decision, not a failed range attempt +} func (e *rangeUnavailable) Error() string { return e.reason } func unavailable(format string, args ...any) *rangeUnavailable { - return &rangeUnavailable{fmt.Sprintf(format, args...)} + return &rangeUnavailable{reason: fmt.Sprintf(format, args...)} } // segment is a piece of the remote package that has been fetched. type segment struct { - start int64 - data []byte + start, length int64 + reader io.ReaderAt } // remoteZip is the remote package as an io.ReaderAt for archive/zip: reads are served from the fetched segments. While @@ -76,7 +80,7 @@ var errNotFetched = errors.New("读到了没取回的位置") func (r *remoteZip) find(pos int64) *segment { for k := range r.segs { s := &r.segs[k] - if s.start <= pos && pos < s.start+int64(len(s.data)) { + if s.start <= pos && pos < s.start+s.length { return s } } @@ -95,7 +99,7 @@ func (r *remoteZip) gapEnd(pos int64) int64 { } func (r *remoteZip) add(start int64, data []byte) { - r.segs = append(r.segs, segment{start, data}) + r.segs = append(r.segs, segment{start, int64(len(data)), bytes.NewReader(data)}) } func (r *remoteZip) drop(start int64) { @@ -133,7 +137,15 @@ func (r *remoteZip) ReadAt(p []byte, off int64) (int, error) { r.add(pos, data) continue } - n += copy(p[n:], s.data[pos-s.start:]) + want := min(int64(len(p)-n), s.length-(pos-s.start)) + got, err := s.reader.ReadAt(p[n:n+int(want)], pos-s.start) + n += got + if err != nil { + return n, err + } + if int64(got) != want { + return n, io.ErrUnexpectedEOF + } } return n, nil } @@ -178,21 +190,34 @@ func (l layout) sameAsLocal(e zipEntry) bool { return h.Sum32() == e.f.CRC32 } -// rangeRead reads one range of r's package, retried like a download (but not when the server cannot serve ranges). +// rangeRead is bounded by rangeMaxDirectory for metadata and rangeMaxGroup for small entry groups. func (u *Updater) rangeRead(ctx context.Context, r *release, off, n int64, what string) ([]byte, error) { d := download.Client{HTTP: u.client(), StallTimeout: u.stallTimeout} + var data []byte + err := u.rangeRetry(ctx, off, n, what, func() (err error) { + data, err = d.Range(ctx, r.url, off, n, r.size, what) + return err + }) + return data, err +} + +// rangeRetry retries only transport failures. Local writer, seek and truncate errors stop immediately. +func (u *Updater) rangeRetry(ctx context.Context, off, n int64, what string, fetch func() error) error { var last error for attempt := 1; attempt <= downloadAttempts; attempt++ { - b, err := d.Range(ctx, r.url, off, n, r.size, what) + err := fetch() if err == nil { - return b, nil + return nil } if ctx.Err() != nil { - return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } - last = err var de *download.Error - if errors.As(err, &de) && (de.Kind == download.NoRange || de.Kind == download.Size) { + if !errors.As(err, &de) { + return err + } + last = err + if de.Kind == download.NoRange || de.Kind == download.Size { break } u.Log.Warnf("%s 第 %d 次没取到: %v", what, attempt, err) @@ -200,11 +225,11 @@ func (u *Updater) rangeRead(ctx context.Context, r *release, off, n int64, what select { case <-time.After(time.Duration(attempt) * time.Second): case <-ctx.Done(): - return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } } } - return nil, unavailable("按区间取 %d-%d 没成(%v)", off, off+n-1, last) + return unavailable("按区间取 %d-%d 没成(%v)", off, off+n-1, last) } // planRange reads the package's central directory and compares it with the root: which entries to fetch, which to @@ -239,7 +264,7 @@ func (u *Updater) planRange(ctx context.Context, r *release) (*rangePlan, error) if ctx.Err() != nil { return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } - if u.l.sameAsLocal(e) { + if !strings.EqualFold(e.rel, manifestName) && u.l.sameAsLocal(e) { plan.copy = append(plan.copy, e) continue } @@ -251,8 +276,8 @@ func (u *Updater) planRange(ctx context.Context, r *release) (*rangePlan, error) plan.fetch = append(plan.fetch, rangeEntry{e, start, length}) } if fetched := plan.fetchBytes(); rangeTooMuch(len(plan.fetch), fetched, r.size) { - return nil, unavailable("变了的文件太多(%d 个、约 %s,超过整包的 %.0f%% 或 %d 个文件)", len(plan.fetch), mib(fetched), - rangeMaxFetchRatio*100, rangeMaxFetchEntries) + return nil, &rangeUnavailable{reason: fmt.Sprintf("变了的文件太多(%d 个、约 %s,超过整包的 %.0f%% 或 %d 个文件)", + len(plan.fetch), mib(fetched), rangeMaxFetchRatio*100, rangeMaxFetchEntries), normal: true} } return plan, nil } @@ -327,19 +352,10 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er var fetched int64 u.progress("downloading", label, 0, 0, total) for _, g := range groups { - data, err := u.rangeRead(ctx, r, g.start, g.length, r.assetName) - if err != nil { + if err := u.stageRangeGroup(ctx, r, g, plan.remote); err != nil { return 0, "", err } - plan.remote.add(g.start, data) - for _, e := range g.members { - if err := extractEntry(e.zipEntry, u.l.pkgAt(e.rel)); err != nil { - plan.remote.drop(g.start) - return 0, "", unavailable("取回的 %s 不对: %v", e.rel, errs.As(err).Message) - } - } - plan.remote.drop(g.start) - fetched += int64(len(data)) + fetched += g.length u.progress("downloading", label, min(float64(fetched)*100/float64(max(total, 1)), 100), min(fetched, total), total) } u.step("extracting", "正在拼新版(没变的文件从本地复制)") @@ -348,7 +364,7 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er return 0, "", errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } if err := copyFile(u.l.at(e.rel), u.l.pkgAt(e.rel)); err != nil { - return 0, "", unavailable("复制本地的 %s 没成: %v", e.rel, err) + return 0, "", err } if k%200 == 0 || k == len(plan.copy)-1 { u.progress("extracting", "正在拼新版(没变的文件从本地复制)", float64(k+1)*100/float64(len(plan.copy)), 0, 0) @@ -366,6 +382,60 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er return len(plan.fetch) + len(plan.copy), verified, nil } +// stageRangeGroup keeps small groups in memory. A large entry uses a private file under pkg so an interrupted +// staging attempt is cleaned by the existing staging recovery. Drop the ReaderAt segment before closing the file. +func (u *Updater) stageRangeGroup(ctx context.Context, r *release, g rangeGroup, remote *remoteZip) (err error) { + if g.length <= rangeMaxGroup { + data, err := u.rangeRead(ctx, r, g.start, g.length, r.assetName) + if err != nil { + return err + } + remote.add(g.start, data) + } else { + f, createErr := os.CreateTemp(u.l.pkg(), ".range-*") + if createErr != nil { + return createErr + } + defer func() { + // A local cleanup failure must also stop the update instead of starting a full download. + cerr := f.Close() + rerr := os.Remove(f.Name()) + if cerr != nil { + err = cerr + } + if rerr != nil { + err = rerr + } + }() + d := download.Client{HTTP: u.client(), StallTimeout: u.stallTimeout} + if err := u.rangeRetry(ctx, g.start, g.length, r.assetName, func() error { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + if err := f.Truncate(0); err != nil { + return err + } + return d.RangeTo(ctx, r.url, g.start, g.length, r.size, r.assetName, f) + }); err != nil { + return err + } + remote.segs = append(remote.segs, segment{g.start, g.length, f}) + } + defer remote.drop(g.start) + for _, e := range g.members { + if ctx.Err() != nil { + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + if err := extractEntry(e.zipEntry, u.l.pkgAt(e.rel)); err != nil { + if errs.CodeOf(err) != errs.UpdateRejected { + return err + } + return unavailable("取回的 %s 不对: %v", e.rel, errs.As(err).Message) + } + } + return nil +} + // rangeAllowed: GitHub's full package of a version that carries a manifest (the range path cannot check the package's // SHA-256, so the manifest has to vouch for every file), with no full download of it (finished or partial) to use. func (u *Updater) rangeAllowed(r *release) bool { diff --git a/internal/update/range_cleanup_windows_test.go b/internal/update/range_cleanup_windows_test.go new file mode 100644 index 0000000..2631704 --- /dev/null +++ b/internal/update/range_cleanup_windows_test.go @@ -0,0 +1,142 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "syscall" + "testing" +) + +type rangeEOFReader struct { + io.Reader + onEOF func() +} + +func (r *rangeEOFReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if err == io.EOF && r.onEOF != nil { + r.onEOF() + r.onEOF = nil + } + return n, err +} +func (*rangeEOFReader) Close() error { return nil } + +func TestRangeLargeGroupCleanupErrorsStopFallback(t *testing.T) { + // Store the fixture so the range exceeds 8 MiB without spending time compressing test padding. + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + w, err := zw.CreateHeader(&zip.FileHeader{Name: "payload.bin", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := io.CopyN(w, bytes.NewReader(bytes.Repeat([]byte{7}, 9<<20)), 9<<20); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + for _, failure := range []string{"close", "remove"} { + t.Run(failure, func(t *testing.T) { + r := writeRoot(t, 36, nil) + f := newSources(t, 37, archive.Bytes()) + u := f.updater(r) + if err := os.MkdirAll(u.l.pkg(), 0o755); err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len())) + if err != nil { + t.Fatal(err) + } + remote := &remoteZip{size: int64(archive.Len())} + var held syscall.Handle + defer func() { + if held != 0 { + syscall.CloseHandle(held) + } + }() + injected := false + zr.RegisterDecompressor(zip.Store, func(input io.Reader) io.ReadCloser { + return &rangeEOFReader{Reader: input, onEOF: func() { + // Inject the OS failure after extraction has read every byte, so only cleanup can report it. + for _, s := range remote.segs { + file, ok := s.reader.(*os.File) + if !ok { + continue + } + injected = true + if failure == "close" { + if err := file.Close(); err != nil { + t.Fatal(err) + } + } else { + name, err := syscall.UTF16PtrFromString(file.Name()) + if err != nil { + t.Fatal(err) + } + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, + nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + } + } + }} + }) + g := rangeGroup{length: int64(archive.Len()), members: []rangeEntry{{zipEntry: zipEntry{rel: "payload.bin", f: zr.File[0]}}}} + rel, err := u.githubLatest(context.Background()) + if err != nil { + t.Fatal(err) + } + err = u.stageRangeGroup(context.Background(), rel, g, remote) + var ru *rangeUnavailable + var pathErr *os.PathError + if !injected || err == nil || errors.As(err, &ru) || !errors.As(err, &pathErr) || len(remote.segs) != 0 { + t.Fatalf("cleanup %s: injected %v, error %v, remaining segments %d", failure, injected, err, len(remote.segs)) + } + if failure == "close" && !errors.Is(err, os.ErrClosed) { + t.Fatalf("want close error, got %v", err) + } + if held != 0 { + syscall.CloseHandle(held) + held = 0 + } + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil { + t.Fatal(err) + } + for _, name := range temps { + if err := os.Remove(name); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestRangeLargeGroupCreateErrorIsLocal(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + if err := os.MkdirAll(u.l.dir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(u.l.pkg(), []byte("blocks creation"), 0o644); err != nil { + t.Fatal(err) + } + err := u.stageRangeGroup(context.Background(), &release{}, rangeGroup{length: rangeMaxGroup + 1}, &remoteZip{}) + var ru *rangeUnavailable + var pathErr *os.PathError + if err == nil || errors.As(err, &ru) || !errors.As(err, &pathErr) { + t.Fatalf("create error: %v", err) + } +} diff --git a/internal/update/range_hardening_test.go b/internal/update/range_hardening_test.go new file mode 100644 index 0000000..30be873 --- /dev/null +++ b/internal/update/range_hardening_test.go @@ -0,0 +1,243 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "fmt" + "hash/crc32" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + + "modavd/internal/errs" +) + +func TestRangeManifestAlwaysRemote(t *testing.T) { + c := newRangeCase(t) + manifest := manifestFor(37, c.newFiles) + forged := forgeCRC(bytes.Repeat([]byte("x"), len(manifest)), crc32.ChecksumIEEE(manifest)) + if bytes.Equal(forged, manifest) || crc32.ChecksumIEEE(forged) != crc32.ChecksumIEEE(manifest) { + t.Fatal("invalid collision fixture") + } + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + u := f.updater(r) + if err := os.WriteFile(u.l.at(manifestName), forged, 0o644); err != nil { + t.Fatal(err) + } + rel, err := u.githubLatest(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, _, err = u.stageByRange(context.Background(), rel); err != nil { + t.Fatalf("remote manifest should allow staging despite the local CRC collision: %v", err) + } + got, err := os.ReadFile(u.l.pkgAt(manifestName)) + if err != nil || !bytes.Equal(got, manifest) { + t.Fatalf("manifest was not fetched from the package: %v", err) + } +} + +func TestRangeThresholdDoesNotAddNotice(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, nil) + f := newSources(t, 37, c.zip) + log := &dlLog{} + f.dl = log.serve(c.zip, nil) + u := f.updater(r) + st, err := u.Prepare(context.Background()) + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || len(st.Notices) != 0 || log.fullGets() != 1 { + t.Fatalf("normal full-package fallback: status %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + st, err = u.Install(context.Background()) + if err != nil || !st.Updated || strings.Contains(st.Message, "改下整包") { + t.Fatalf("normal fallback in final message: %+v %v", st, err) + } +} + +func TestRangeLocalExtractionErrorDoesNotDownloadWhole(t *testing.T) { + for _, operation := range []string{"mkdir", "open", "copy"} { + t.Run(operation, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + log := &dlLog{} + f.dl = log.serve(c.zip, nil) + u := f.updater(r) + injected := false + u.Opt.Progress = func(p Progress) { + if injected || p.Stage != "downloading" || p.Total == 0 { + return + } + injected = true + dst := u.l.pkgAt("sdk") + if operation == "mkdir" { + if err := os.WriteFile(dst, []byte("blocks the destination directory"), 0o644); err != nil { + t.Fatal(err) + } + } else { + name := "sdk/emulator/emulator.exe" + if operation == "copy" { + name = "sdk/emulator/lib/big0.dll" + } + if err := os.MkdirAll(u.l.pkgAt(name), 0o755); err != nil { + t.Fatal(err) + } + } + } + st, err := u.Prepare(context.Background()) + var unavailable *rangeUnavailable + if !injected || err == nil || errors.As(err, &unavailable) || log.fullGets() != 0 || len(st.Notices) != 0 || st.Phase == phaseStaged { + t.Fatalf("local %s error triggered a fallback: injected %v, status %+v, error %v, full GETs %d", operation, injected, st, err, log.fullGets()) + } + }) + } +} + +func TestRangeLargeEntryLifecycle(t *testing.T) { + const largePath = "sdk/emulator/lib/new-large.dll" + big := bigFiles(1, 4, 6<<20) + large := bigFiles(2, 1, 9<<20)["sdk/emulator/lib/big0.dll"] + files := pkgFiles(37, merged(big, map[string]string{largePath: large})) + archive := zipOf(t, 37, files, false) + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatal(err) + } + var dataOffset int64 + for _, f := range zr.File { + if f.Name == largePath { + dataOffset, err = f.DataOffset() + } + } + if err != nil || dataOffset == 0 { + t.Fatal("large entry not found", err) + } + for _, mode := range []string{"success", "retry", "cancel", "corrupt", "local-create", "local-open"} { + t.Run(mode, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, big) + f := newSources(t, 37, archive) + u := f.updater(r) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + log := &dlLog{} + var requests, tempSeen atomic.Int32 + rangeBody := archive + if mode == "corrupt" { + rangeBody = append([]byte(nil), archive...) + rangeBody[dataOffset+12345] ^= 0x55 + } + serve := log.serve(archive, rangeBody) + f.dl = func(w http.ResponseWriter, req *http.Request) { + var start, end int64 + _, parseErr := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end) + if parseErr != nil || end-start+1 <= rangeMaxGroup { + serve(w, req) + return + } + attempt := requests.Add(1) + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil || len(temps) != 1 { + t.Errorf("large transfer has no private file: %v %v", temps, err) + } else { + tempSeen.Add(1) + } + if (mode == "retry" && attempt == 1) || mode == "cancel" { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(archive))) + w.Header().Set("Content-Length", fmt.Sprint(end-start+1)) + w.WriteHeader(http.StatusPartialContent) + w.Write(bytes.Repeat([]byte{0x55}, 64<<10)) + w.(http.Flusher).Flush() + if mode == "cancel" { + cancel() + <-req.Context().Done() + } + return + } + if mode == "local-open" { + // Deny the next extracted output's open, after the large range was received. + if err := os.MkdirAll(u.l.pkgAt(largePath), 0o755); err != nil { + t.Error(err) + } + } + serve(w, req) + } + if mode == "local-create" { + u.Opt.Progress = func(p Progress) { + if p.Stage != "downloading" || p.Total == 0 || p.Done != 0 { + return + } + if err := os.Remove(u.l.pkg()); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(u.l.pkg(), []byte("cannot create a range file here"), 0o644); err != nil { + t.Fatal(err) + } + } + } + st, err := u.Prepare(ctx) + switch mode { + case "success", "retry", "corrupt": + wantMode, wantNotices, wantFull := "range", 0, 0 + if mode == "corrupt" { + wantMode, wantNotices, wantFull = "full", 1, 1 + } + if err != nil || st.Phase != phaseStaged || stagedMode(u) != wantMode || len(st.Notices) != wantNotices || log.fullGets() != wantFull { + t.Fatalf("prepare: %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + // buildStage has moved sdk/emulator into stage/emulator. + got, err := os.ReadFile(filepath.Join(u.l.stageEmulator(), "lib", "new-large.dll")) + if err != nil || !bytes.Equal(got, []byte(large)) { + t.Fatalf("large staged output differs: %v", err) + } + if mode == "retry" && requests.Load() != 2 { + t.Fatalf("want two large attempts, got %d", requests.Load()) + } + case "cancel": + if errs.CodeOf(err) != errs.Canceled || log.fullGets() != 0 || requests.Load() != 1 { + t.Fatalf("cancel: %v, large attempts %d, full GETs %d", err, requests.Load(), log.fullGets()) + } + default: + if err == nil || log.fullGets() != 0 || st.Phase == phaseStaged { + t.Fatalf("local failure: %+v %v, full GETs %d", st, err, log.fullGets()) + } + } + if mode != "local-create" && tempSeen.Load() == 0 { + t.Fatal("large entry never used a private temporary file") + } + // Also catches leaked Windows handles: the existing abort and temp cleanup must remove the file. + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil || len(temps) != 0 { + t.Fatalf("range file leaked: %v %v", temps, err) + } + }) + } +} + +func TestRangeRetryDoesNotRetryLocalFailures(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + for _, failure := range []error{io.ErrShortWrite, &os.PathError{Op: "write", Path: "range file", Err: syscall.Errno(112)}} { + attempts := 0 + err := u.rangeRetry(context.Background(), 0, 1, "test", func() error { attempts++; return failure }) + var ru *rangeUnavailable + if !errors.Is(err, failure) || attempts != 1 || errors.As(err, &ru) { + t.Fatalf("attempts %d, error %v", attempts, err) + } + } +} diff --git a/internal/update/range_local_errors_windows_test.go b/internal/update/range_local_errors_windows_test.go new file mode 100644 index 0000000..3d6d9d6 --- /dev/null +++ b/internal/update/range_local_errors_windows_test.go @@ -0,0 +1,177 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "modavd/internal/errs" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" +) + +func TestRangeVerificationReadErrorDoesNotDownloadWhole(t *testing.T) { + for _, target := range []string{manifestName, "sdk/emulator/emulator.exe"} { + t.Run(target, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + log := &dlLog{} + serve := log.serve(c.zip, nil) + u := f.updater(r) + var mu sync.Mutex + var held syscall.Handle + unlock := func() { + mu.Lock() + defer mu.Unlock() + if held != 0 { + syscall.CloseHandle(held) + held = 0 + } + } + defer unlock() + f.dl = func(w http.ResponseWriter, req *http.Request) { + if req.Header.Get("Range") == "" { + unlock() + } + serve(w, req) + } + injected := false + u.Opt.Progress = func(p Progress) { + if injected || p.Stage != "verifying" { + return + } + injected = true + if _, err := os.Stat(u.l.pkgAt(target)); err != nil { + t.Fatal(err) + } + name, err := syscall.UTF16PtrFromString(u.l.pkgAt(target)) + if err != nil { + t.Fatal(err) + } + mu.Lock() + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, + syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, + syscall.FILE_ATTRIBUTE_NORMAL, 0) + mu.Unlock() + if err != nil { + t.Fatal(err) + } + } + st, err := u.Prepare(context.Background()) + t.Logf("injected=%v error=%v code=%s mode=%s fullGets=%d notices=%v", injected, err, errs.CodeOf(err), stagedMode(u), log.fullGets(), st.Notices) + var pathErr *os.PathError + if !injected || !errors.As(err, &pathErr) || errs.CodeOf(err) == errs.UpdateRejected || log.fullGets() != 0 || len(st.Notices) != 0 { + t.Errorf("local verification read failure did not stop before whole download") + } + }) + } +} + +// entryFaultOutput exercises destination errors without changing global file operations. +type entryFaultOutput struct { + data bytes.Buffer + writeErr, closeErr error + closed int +} + +func (o *entryFaultOutput) Write(p []byte) (int, error) { + if o.writeErr != nil { + return 0, o.writeErr + } + return o.data.Write(p) +} +func (o *entryFaultOutput) Close() error { o.closed++; return o.closeErr } + +type entryReadError struct{ err error } + +func (r entryReadError) Read([]byte) (int, error) { return 0, r.err } + +func TestEntryCloseErrorTakesPrecedenceOverBadZIP(t *testing.T) { + disk := &os.PathError{Op: "close", Path: "output", Err: syscall.Errno(112)} + for _, tc := range []struct { + name string + readErr, writeErr, closeErr, want error + }{ + {"bad ZIP and close", zip.ErrChecksum, nil, disk, disk}, + {"bad ZIP alone", zip.ErrChecksum, nil, nil, zip.ErrChecksum}, + {"write error", nil, disk, nil, disk}, + {"close error", nil, nil, disk, disk}, + {"success", nil, nil, nil, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + out := &entryFaultOutput{writeErr: tc.writeErr, closeErr: tc.closeErr} + source := io.Reader(strings.NewReader("payload")) + if tc.readErr != nil { + source = io.MultiReader(source, entryReadError{tc.readErr}) + } + err := copyEntryContents(out, source) + if !errors.Is(err, tc.want) || out.closed != 1 { + t.Fatalf("error %v, want %v, closes %d", err, tc.want, out.closed) + } + }) + } +} + +func TestEmulatorVerificationReadErrorsStayLocal(t *testing.T) { + for _, target := range []string{"source.properties", "qemu/windows-x86_64/qemu-system-x86_64-headless.exe"} { + t.Run(target, func(t *testing.T) { + r := writeRoot(t, 37, nil) + path := filepath.Join(r.Dir, "sdk", "emulator", filepath.FromSlash(target)) + name, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + held, err := syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(held) + version, _ := ParseVersion("v0.37.0") + err = checkEmulator(filepath.Join(r.Dir, "sdk", "emulator"), 37, version) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || errs.CodeOf(err) == errs.UpdateRejected { + t.Fatalf("local emulator read: %v", err) + } + }) + } +} + +func TestStageBuildManifestReadErrorIsLocal(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + archive := filepath.Join(r.Dir, "package.zip") + if err := os.WriteFile(archive, zipOf(t, 37, pkgFiles(37, nil), false), 0o644); err != nil { + t.Fatal(err) + } + if _, err := u.l.extractPackage(archive, "", nil); err != nil { + t.Fatal(err) + } + path, err := syscall.UTF16PtrFromString(u.l.pkgAt(manifestName)) + if err != nil { + t.Fatal(err) + } + held, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(held) + version, _ := ParseVersion("v0.37.0") + err = u.l.buildStage(version) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || lexists(u.l.stageEmulator()) { + t.Fatalf("unreadable manifest was ignored when building stage: %v", err) + } +} diff --git a/internal/update/range_test.go b/internal/update/range_test.go index d36802e..306a2c6 100644 --- a/internal/update/range_test.go +++ b/internal/update/range_test.go @@ -290,8 +290,15 @@ func TestRangeFallsBack(t *testing.T) { u := f.updater(r) ctx := context.Background() st, err := u.Prepare(ctx) - if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || len(st.Notices) != 1 || - !strings.Contains(st.Notices[0], tc.notice) || !strings.Contains(st.Notices[0], "改下整包") { + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" { + t.Fatalf("prepare: %+v %v (mode %q)", st, err, stagedMode(u)) + } + wantNotices := 1 + if tc.name == "too much changed" { + wantNotices = 0 + } + if len(st.Notices) != wantNotices || (wantNotices > 0 && + (!strings.Contains(st.Notices[0], tc.notice) || !strings.Contains(st.Notices[0], "改下整包"))) { t.Fatalf("prepare: %+v %v (mode %q)", st, err, stagedMode(u)) } if log.fullGets() != 1 { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 216945f..9c6fb98 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -443,8 +443,8 @@ func TestMirrorChyan(t *testing.T) { t.Fatalf("check query %v", seen[0]) } st, err := u.Install(context.Background()) - // every file changed between the two builds: GitHub's range path falls back to the whole package (second notice) - if err != nil || !st.Updated || len(st.Notices) != 2 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") || !strings.Contains(st.Notices[1], "变了的文件太多") { + // The normal GitHub range threshold adds no notice; the bad CDK still does. + if err != nil || !st.Updated || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") { t.Fatalf("install with a bad CDK: %+v %v", st, err) } if last := seen[len(seen)-1]; last["cdk"] != "cdk-secret-1" || last["current_version"] != "v0.36.0" { diff --git a/internal/update/updater.go b/internal/update/updater.go index f5ab045..f1f9849 100644 --- a/internal/update/updater.go +++ b/internal/update/updater.go @@ -516,7 +516,9 @@ func (u *Updater) stage(ctx context.Context, r *release, st *Status) (err error) case errors.As(err, &ru): note := rangeNotice(err) u.Log.Infof("%s", note) - st.Notices = append(st.Notices, note) + if !ru.normal { + st.Notices = append(st.Notices, note) + } default: return err }