diff --git a/internal/httpapi/packages.go b/internal/httpapi/packages.go index f3942eb..fc34048 100644 --- a/internal/httpapi/packages.go +++ b/internal/httpapi/packages.go @@ -228,6 +228,7 @@ func (s *Server) download(writer http.ResponseWriter, request *http.Request, nam version, query.Get("platform"), query.Get("architecture"), + query.Get("channel"), query.Get("current_version"), ) if err != nil || len(item.Targets) == 0 { diff --git a/internal/httpapi/targets_test.go b/internal/httpapi/targets_test.go index 67493c1..56ba10e 100644 --- a/internal/httpapi/targets_test.go +++ b/internal/httpapi/targets_test.go @@ -120,6 +120,14 @@ func TestTargetDownloadChannelAndDeltaSelection(t *testing.T) { uploadTargetPackageOK(t, server.URL, map[string]string{ "name": "channel-beta", "version": "1.0.0", "platform": "linux", "architecture": "amd64", "channel": "beta", }, "beta.pkg", []byte("beta")) + stableDownload, err := http.Get(server.URL + "/api/v1/packages/channel-beta/1.0.0/download?platform=linux&architecture=amd64&channel=stable") + if err != nil { + t.Fatal(err) + } + stableDownload.Body.Close() + if stableDownload.StatusCode != http.StatusNotFound { + t.Fatalf("稳定通道不应下载 beta 包:%d", stableDownload.StatusCode) + } list := getTargetPackage(t, server.URL+"/api/v1/packages?platform=linux&architecture=amd64&channel=stable") defer list.Body.Close() var payload struct { diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 235248c..2a1ed7d 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -230,7 +230,7 @@ func (r *Registry) Get(name, version string) (Package, bool) { } func (r *Registry) OpenPackage(name, version string) (*os.File, Package, error) { - return r.OpenPackageFor(name, version, "", "", "") + return r.OpenPackageFor(name, version, "", "", "", "") } func (r *Registry) Subscribe() (<-chan Update, func()) { diff --git a/internal/registry/targets.go b/internal/registry/targets.go index dc89de1..fd894dc 100644 --- a/internal/registry/targets.go +++ b/internal/registry/targets.go @@ -256,8 +256,8 @@ func (r *Registry) GetFor(name, version, platform, architecture, channel, curren return SelectTarget(item, platform, architecture, currentVersion) } -func (r *Registry) OpenPackageFor(name, version, platform, architecture, currentVersion string) (*os.File, Package, error) { - item, ok := r.GetFor(name, version, platform, architecture, "", currentVersion) +func (r *Registry) OpenPackageFor(name, version, platform, architecture, channel, currentVersion string) (*os.File, Package, error) { + item, ok := r.GetFor(name, version, platform, architecture, channel, currentVersion) if !ok || len(item.Targets) == 0 { return nil, Package{}, os.ErrNotExist } diff --git a/internal/registry/targets_test.go b/internal/registry/targets_test.go index b22abff..2947506 100644 --- a/internal/registry/targets_test.go +++ b/internal/registry/targets_test.go @@ -108,7 +108,7 @@ func TestLegacyReleaseCanBeLoaded(t *testing.T) { if !ok || item.Platform != "any" || item.Architecture != "universal" { t.Fatalf("\u65e7\u6570\u636e\u517c\u5bb9\u5931\u8d25: %+v", item) } - file, _, err := store.OpenPackageFor("legacy-demo", "1.0.0", "linux", "amd64", "") + file, _, err := store.OpenPackageFor("legacy-demo", "1.0.0", "linux", "amd64", "", "") if err != nil { t.Fatal(err) } diff --git a/pkg/client/platform.go b/pkg/client/platform.go index 1d15505..0ae68a4 100644 --- a/pkg/client/platform.go +++ b/pkg/client/platform.go @@ -54,7 +54,7 @@ func (c *Client) ListVersionsFor(ctx context.Context, name string, options Query Name string `json:"name"` Versions []Package `json:"versions"` } - path := "/api/v1/packages/" + url.PathEscape(name) + "/versions" + path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name) + "/versions" if err := c.getJSON(ctx, withQuery(path, options.values()), &response); err != nil { return nil, err } @@ -97,32 +97,41 @@ func (c *Client) DownloadFor(ctx context.Context, name, version, destination str if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { return Package{}, fmt.Errorf("\u521b\u5efa\u4fdd\u5b58\u76ee\u5f55\u5931\u8d25: %w", err) } - file, err := os.Create(destination) + temporary, err := os.CreateTemp(filepath.Dir(destination), ".scout-download-*") if err != nil { - return Package{}, fmt.Errorf("\u521b\u5efa\u4fdd\u5b58\u6587\u4ef6\u5931\u8d25: %w", err) + return Package{}, fmt.Errorf("\u521b\u5efa\u4e0b\u8f7d\u4e34\u65f6\u6587\u4ef6\u5931\u8d25: %w", err) } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) hash := sha256.New() - _, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body) - closeErr := file.Close() + size, copyErr := io.Copy(io.MultiWriter(temporary, hash), response.Body) + closeErr := temporary.Close() if copyErr != nil { return Package{}, fmt.Errorf("\u4fdd\u5b58\u5305\u6587\u4ef6\u5931\u8d25: %w", copyErr) } if closeErr != nil { - return Package{}, fmt.Errorf("\u5173\u95ed\u4fdd\u5b58\u6587\u4ef6\u5931\u8d25: %w", closeErr) + return Package{}, fmt.Errorf("\u5173\u95ed\u4e0b\u8f7d\u4e34\u65f6\u6587\u4ef6\u5931\u8d25: %w", closeErr) + } + if response.ContentLength >= 0 && size != response.ContentLength { + return Package{}, fmt.Errorf("\u5305\u6587\u4ef6\u5927\u5c0f\u6821\u9a8c\u5931\u8d25: \u671f\u671b %d\uff0c\u5b9e\u9645 %d", response.ContentLength, size) } digest := hex.EncodeToString(hash.Sum(nil)) expected := response.Header.Get("X-Scout-SHA256") if expected != "" && !strings.EqualFold(expected, digest) { return Package{}, fmt.Errorf("\u5305\u6587\u4ef6\u6821\u9a8c\u5931\u8d25: \u671f\u671b %s\uff0c\u5b9e\u9645 %s", expected, digest) } + if err := copyDownloadedFile(temporaryName, destination); err != nil { + return Package{}, err + } target := Target{ Platform: response.Header.Get("X-Scout-Platform"), Architecture: response.Header.Get("X-Scout-Architecture"), PackageType: response.Header.Get("X-Scout-Package-Type"), - Size: response.ContentLength, + Size: size, SHA256: digest, } return Package{ + Project: c.project, Name: name, Version: version, Platform: target.Platform, diff --git a/pkg/client/projects_test.go b/pkg/client/projects_test.go index 1843bb6..17213c2 100644 --- a/pkg/client/projects_test.go +++ b/pkg/client/projects_test.go @@ -4,6 +4,8 @@ import ( "context" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" ) @@ -30,6 +32,56 @@ func TestProjectClientUsesProjectScopedPaths(t *testing.T) { } } +func TestProjectClientUsesProjectScopedVersionPaths(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/v1/projects/ssl/packages/demo/versions" { + t.Fatalf("项目版本请求路径错误:%s", request.URL.Path) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"name":"demo","versions":[{"name":"demo","version":"1.0.0","project":"ssl"}]}`)) + })) + defer server.Close() + + api, err := NewProject(server.URL, "ssl") + if err != nil { + t.Fatal(err) + } + versions, err := api.ListVersionsFor(context.Background(), "demo", QueryOptions{}) + if err != nil { + t.Fatal(err) + } + if len(versions) != 1 || versions[0].Project != "ssl" { + t.Fatalf("项目版本响应错误:%+v", versions) + } +} + +func TestProjectDownloadKeepsDestinationWhenChecksumFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Length", "7") + writer.Header().Set("X-Scout-SHA256", "invalid") + _, _ = writer.Write([]byte("payload")) + })) + defer server.Close() + + api, err := NewProject(server.URL, "ssl") + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "package.pkg") + if err := os.WriteFile(destination, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := api.DownloadFor(context.Background(), "demo", "1.0.0", destination, QueryOptions{}); err == nil { + t.Fatal("错误校验值应该导致下载失败") + } + data, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(data) != "original" { + t.Fatalf("项目下载校验失败不应覆盖原文件:%q", data) + } +} func TestNewProjectRejectsUnsafeProjectID(t *testing.T) { if _, err := NewProject("http://127.0.0.1", "../ssl"); err == nil { t.Fatal("不安全项目标识应该失败")