Skip to content

Commit e486d77

Browse files
committed
feat(nuget): support nuspec manifest download
1 parent 5dabc67 commit e486d77

File tree

5 files changed

+365
-14
lines changed

5 files changed

+365
-14
lines changed

modules/packages/nuget/metadata.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ const maxNuspecFileSize = 3 * 1024 * 1024
4848

4949
// Package represents a Nuget package
5050
type Package struct {
51-
PackageType PackageType
52-
ID string
53-
Version string
54-
Metadata *Metadata
51+
PackageType PackageType
52+
ID string
53+
Version string
54+
Metadata *Metadata
55+
NuspecContent *bytes.Buffer
5556
}
5657

5758
// Metadata represents the metadata of a Nuget package
@@ -130,8 +131,9 @@ func ParsePackageMetaData(r io.ReaderAt, size int64) (*Package, error) {
130131

131132
// ParseNuspecMetaData parses a Nuspec file to retrieve the metadata of a Nuget package
132133
func ParseNuspecMetaData(r io.Reader) (*Package, error) {
134+
var nuspecBuf bytes.Buffer
133135
var p nuspecPackage
134-
if err := xml.NewDecoder(r).Decode(&p); err != nil {
136+
if err := xml.NewDecoder(io.TeeReader(r, &nuspecBuf)).Decode(&p); err != nil {
135137
return nil, err
136138
}
137139

@@ -182,10 +184,11 @@ func ParseNuspecMetaData(r io.Reader) (*Package, error) {
182184
}
183185
}
184186
return &Package{
185-
PackageType: packageType,
186-
ID: p.Metadata.ID,
187-
Version: toNormalizedVersion(v),
188-
Metadata: m,
187+
PackageType: packageType,
188+
ID: p.Metadata.ID,
189+
Version: toNormalizedVersion(v),
190+
Metadata: m,
191+
NuspecContent: &nuspecBuf,
189192
}, nil
190193
}
191194

routers/api/packages/nuget/nuget.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,8 @@ func EnumeratePackageVersionsV3(ctx *context.Context) {
388388
ctx.JSON(http.StatusOK, resp)
389389
}
390390

391-
// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
391+
// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-manifest-nuspec
392+
// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
392393
func DownloadPackageFile(ctx *context.Context) {
393394
packageName := ctx.Params("id")
394395
packageVersion := ctx.Params("version")
@@ -431,7 +432,7 @@ func UploadPackage(ctx *context.Context) {
431432
return
432433
}
433434

434-
_, _, err := packages_service.CreatePackageAndAddFile(
435+
pv, _, err := packages_service.CreatePackageAndAddFile(
435436
ctx,
436437
&packages_service.PackageCreationInfo{
437438
PackageInfo: packages_service.PackageInfo{
@@ -465,6 +466,35 @@ func UploadPackage(ctx *context.Context) {
465466
return
466467
}
467468

469+
nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len())
470+
if err != nil {
471+
apiError(ctx, http.StatusInternalServerError, err)
472+
return
473+
}
474+
defer nuspecBuf.Close()
475+
476+
_, err = packages_service.AddFileToPackageVersionInternal(
477+
ctx,
478+
pv,
479+
&packages_service.PackageFileCreationInfo{
480+
PackageFileInfo: packages_service.PackageFileInfo{
481+
Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", np.ID)),
482+
},
483+
Creator: ctx.Doer,
484+
Data: nuspecBuf,
485+
IsLead: false,
486+
},
487+
)
488+
if err != nil {
489+
switch err {
490+
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
491+
apiError(ctx, http.StatusForbidden, err)
492+
default:
493+
apiError(ctx, http.StatusInternalServerError, err)
494+
}
495+
return
496+
}
497+
468498
ctx.Status(http.StatusCreated)
469499
}
470500

services/doctor/packages_nuget.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package doctor
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"slices"
10+
"strings"
11+
12+
"code.gitea.io/gitea/models/db"
13+
"code.gitea.io/gitea/models/packages"
14+
"code.gitea.io/gitea/models/user"
15+
"code.gitea.io/gitea/modules/log"
16+
packages_module "code.gitea.io/gitea/modules/packages"
17+
nuget_module "code.gitea.io/gitea/modules/packages/nuget"
18+
packages_service "code.gitea.io/gitea/services/packages"
19+
)
20+
21+
func init() {
22+
Register(&Check{
23+
Title: "Extract Nuget Nuspec Files to content store",
24+
Name: "packages-nuget-nuspec",
25+
IsDefault: false,
26+
Run: PackagesNugetNuspecCheck,
27+
Priority: 15,
28+
})
29+
}
30+
31+
// getAllUsers returns a slice of all users and organizations found in DB.
32+
func getAllUsers(ctx context.Context) ([]*user.User, error) {
33+
users := make([]*user.User, 0)
34+
return users, db.GetEngine(ctx).OrderBy("id").Find(&users)
35+
}
36+
37+
func PackagesNugetNuspecCheck(ctx context.Context, logger log.Logger, autofix bool) error {
38+
users, err := getAllUsers(ctx)
39+
userMap := make(map[int64]*user.User, len(users))
40+
41+
for _, u := range users {
42+
userMap[u.ID] = u
43+
}
44+
45+
if err != nil {
46+
logger.Error("Failed to get users: %v", err)
47+
return err
48+
}
49+
50+
logger.Info("Found %d users", len(users))
51+
52+
fixed := 0
53+
errors := 0
54+
55+
for _, user := range users {
56+
pkgs, err := packages.GetPackagesByType(ctx, user.ID, packages.TypeNuGet)
57+
if err != nil {
58+
logger.Error("Failed to get NuGet packages for owner %s: %v", user.Name, err)
59+
continue
60+
}
61+
62+
logger.Info("Found %d NuGet packages for owner %s", len(pkgs), user.Name)
63+
64+
for _, pkg := range pkgs {
65+
pvs, _, err := packages.SearchVersions(ctx, &packages.PackageSearchOptions{
66+
Type: packages.TypeNuGet,
67+
PackageID: pkg.ID,
68+
})
69+
if err != nil {
70+
// Should never happen
71+
logger.Error("Failed to search for versions for package %s: %v", pkg.Name, err)
72+
continue
73+
}
74+
75+
logger.Info("Found %d versions for package %s", pkg.Name, user.Name)
76+
77+
for _, pv := range pvs {
78+
79+
pfs, err := packages.GetFilesByVersionID(ctx, pv.ID)
80+
if err != nil {
81+
logger.Error("Failed to get files for package version %s %d: %v", pkg.Name, pv.Version, err)
82+
errors++
83+
continue
84+
}
85+
86+
if slices.IndexFunc(pfs, func(pf *packages.PackageFile) bool { return strings.HasSuffix(pf.LowerName, ".nuspec") }) >= 0 {
87+
logger.Debug("Nuspec file already exists for %s %d", pkg.Name, pv.Version)
88+
continue
89+
}
90+
91+
nuspecIdx := slices.IndexFunc(pfs, func(pf *packages.PackageFile) bool { return pf.IsLead })
92+
93+
if nuspecIdx < 0 {
94+
logger.Error("Missing nupkg file for %s %d", pkg.Name, pv.Version)
95+
errors++
96+
continue
97+
}
98+
99+
pf := pfs[nuspecIdx]
100+
101+
creator, ok := userMap[pv.CreatorID]
102+
if !ok {
103+
logger.Warn("Failed to find creator for %s %d", pkg.Name, pv.Version)
104+
creator = user
105+
}
106+
107+
logger.Info("Missing nuspec file found for %s %d", pkg.Name, pv.Version)
108+
fixed++
109+
110+
if autofix {
111+
s, _, _, err := packages_service.GetPackageFileStream(ctx, pf)
112+
if err != nil {
113+
logger.Error("Failed to get file stream for %s %d: %v", pkg.Name, pv.Version, err)
114+
errors++
115+
continue
116+
}
117+
defer s.Close()
118+
119+
buf, err := packages_module.CreateHashedBufferFromReader(s)
120+
if err != nil {
121+
logger.Error("Failed to create hashed buffer for nupkg from reader for %s %d: %v", pkg.Name, pv.Version, err)
122+
errors++
123+
continue
124+
}
125+
defer buf.Close()
126+
127+
np, err := nuget_module.ParsePackageMetaData(buf, buf.Size())
128+
if err != nil {
129+
logger.Error("Failed to parse package metadata for %s %d: %v", pkg.Name, pv.Version, err)
130+
errors++
131+
continue
132+
}
133+
134+
nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len())
135+
if err != nil {
136+
logger.Error("Failed to create hashed buffer for nuspec from reader for %s %d: %v", pkg.Name, pv.Version, err)
137+
errors++
138+
continue
139+
}
140+
defer nuspecBuf.Close()
141+
142+
_, err = packages_service.AddFileToPackageVersionInternal(
143+
ctx,
144+
pv,
145+
&packages_service.PackageFileCreationInfo{
146+
PackageFileInfo: packages_service.PackageFileInfo{
147+
Filename: fmt.Sprintf("%s.nuspec", pkg.LowerName),
148+
},
149+
Creator: creator,
150+
Data: nuspecBuf,
151+
IsLead: false,
152+
},
153+
)
154+
if err != nil {
155+
logger.Error("Failed to add nuspec file for %s %d: %v", pkg.Name, pv.Version, err)
156+
errors++
157+
}
158+
}
159+
}
160+
}
161+
}
162+
163+
if fixed > 0 {
164+
logger.Info("Fixed %d NuGet packages by extracting nuspec files", fixed)
165+
}
166+
if errors > 0 {
167+
logger.Info("Failed to fix %d nuspec files", errors)
168+
}
169+
170+
return nil
171+
}

tests/integration/api_packages_nuget_test.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ func TestPackageNuGet(t *testing.T) {
112112
return &buf
113113
}
114114

115+
nuspec := `<?xml version="1.0" encoding="utf-8"?>
116+
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
117+
<metadata>
118+
<id>` + packageName + `</id>
119+
<version>` + packageVersion + `</version>
120+
<authors>` + packageAuthors + `</authors>
121+
<description>` + packageDescription + `</description>
122+
<dependencies>
123+
<group targetFramework=".NETStandard2.0">
124+
<dependency id="Microsoft.CSharp" version="4.5.0" />
125+
</group>
126+
</dependencies>
127+
</metadata>
128+
</package>`
115129
content, _ := io.ReadAll(createPackage(packageName, packageVersion))
116130

117131
url := fmt.Sprintf("/api/packages/%s/nuget", user.Name)
@@ -224,7 +238,7 @@ func TestPackageNuGet(t *testing.T) {
224238

225239
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet)
226240
assert.NoError(t, err)
227-
assert.Len(t, pvs, 1)
241+
assert.Len(t, pvs, 1, "Should have one version")
228242

229243
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
230244
assert.NoError(t, err)
@@ -235,7 +249,7 @@ func TestPackageNuGet(t *testing.T) {
235249

236250
pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
237251
assert.NoError(t, err)
238-
assert.Len(t, pfs, 1)
252+
assert.Len(t, pfs, 2, "Should have 2 files: nuget and nuspec")
239253
assert.Equal(t, fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion), pfs[0].Name)
240254
assert.True(t, pfs[0].IsLead)
241255

@@ -302,16 +316,27 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
302316

303317
pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
304318
assert.NoError(t, err)
305-
assert.Len(t, pfs, 3)
319+
assert.Len(t, pfs, 4, "Should have 4 files: nupkg, snupkg, nuspec and pdb")
306320
for _, pf := range pfs {
307321
switch pf.Name {
308322
case fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion):
323+
assert.True(t, pf.IsLead)
324+
325+
pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
326+
assert.NoError(t, err)
327+
assert.Equal(t, int64(414), pb.Size)
309328
case fmt.Sprintf("%s.%s.snupkg", packageName, packageVersion):
310329
assert.False(t, pf.IsLead)
311330

312331
pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
313332
assert.NoError(t, err)
314333
assert.Equal(t, int64(616), pb.Size)
334+
case fmt.Sprintf("%s.nuspec", packageName):
335+
assert.False(t, pf.IsLead)
336+
337+
pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
338+
assert.NoError(t, err)
339+
assert.Equal(t, int64(453), pb.Size)
315340
case symbolFilename:
316341
assert.False(t, pf.IsLead)
317342

@@ -353,6 +378,12 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
353378

354379
assert.Equal(t, content, resp.Body.Bytes())
355380

381+
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.nuspec", url, packageName, packageVersion, packageName)).
382+
AddBasicAuth(user.Name)
383+
resp = MakeRequest(t, req, http.StatusOK)
384+
385+
assert.Equal(t, nuspec, resp.Body.String())
386+
356387
checkDownloadCount(1)
357388

358389
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.snupkg", url, packageName, packageVersion, packageName, packageVersion)).

0 commit comments

Comments
 (0)