diff --git a/modules/packages/nuget/metadata.go b/modules/packages/nuget/metadata.go index b32151cdf..264b4612f 100644 --- a/modules/packages/nuget/metadata.go +++ b/modules/packages/nuget/metadata.go @@ -48,10 +48,11 @@ const maxNuspecFileSize = 3 * 1024 * 1024 // Package represents a Nuget package type Package struct { - PackageType PackageType - ID string - Version string - Metadata *Metadata + PackageType PackageType + ID string + Version string + Metadata *Metadata + NuspecContent *bytes.Buffer } // Metadata represents the metadata of a Nuget package @@ -71,50 +72,34 @@ type Dependency struct { Version string `json:"version"` } -type nuspecPackageType struct { - Name string `xml:"name,attr"` -} - -type nuspecPackageTypes struct { - PackageType []nuspecPackageType `xml:"packageType"` -} - -type nuspecRepository struct { - URL string `xml:"url,attr,omitempty"` - Type string `xml:"type,attr,omitempty"` -} -type nuspecDependency struct { - ID string `xml:"id,attr"` - Version string `xml:"version,attr"` - Exclude string `xml:"exclude,attr,omitempty"` -} - -type nuspecGroup struct { - TargetFramework string `xml:"targetFramework,attr"` - Dependency []nuspecDependency `xml:"dependency"` -} - -type nuspecDependencies struct { - Group []nuspecGroup `xml:"group"` -} - -type nuspeceMetadata struct { - ID string `xml:"id"` - Version string `xml:"version"` - Authors string `xml:"authors"` - RequireLicenseAcceptance bool `xml:"requireLicenseAcceptance,omitempty"` - ProjectURL string `xml:"projectUrl,omitempty"` - Description string `xml:"description"` - ReleaseNotes string `xml:"releaseNotes,omitempty"` - PackageTypes *nuspecPackageTypes `xml:"packageTypes,omitempty"` - Repository *nuspecRepository `xml:"repository,omitempty"` - Dependencies *nuspecDependencies `xml:"dependencies,omitempty"` -} - type nuspecPackage struct { - XMLName xml.Name `xml:"package"` - Xmlns string `xml:"xmlns,attr"` - Metadata nuspeceMetadata `xml:"metadata"` + Metadata struct { + ID string `xml:"id"` + Version string `xml:"version"` + Authors string `xml:"authors"` + RequireLicenseAcceptance bool `xml:"requireLicenseAcceptance"` + ProjectURL string `xml:"projectUrl"` + Description string `xml:"description"` + ReleaseNotes string `xml:"releaseNotes"` + PackageTypes struct { + PackageType []struct { + Name string `xml:"name,attr"` + } `xml:"packageType"` + } `xml:"packageTypes"` + Repository struct { + URL string `xml:"url,attr"` + } `xml:"repository"` + Dependencies struct { + Group []struct { + TargetFramework string `xml:"targetFramework,attr"` + Dependency []struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + Exclude string `xml:"exclude,attr"` + } `xml:"dependency"` + } `xml:"group"` + } `xml:"dependencies"` + } `xml:"metadata"` } // ParsePackageMetaData parses the metadata of a Nuget package file @@ -146,8 +131,9 @@ func ParsePackageMetaData(r io.ReaderAt, size int64) (*Package, error) { // ParseNuspecMetaData parses a Nuspec file to retrieve the metadata of a Nuget package func ParseNuspecMetaData(r io.Reader) (*Package, error) { + var nuspecBuf bytes.Buffer var p nuspecPackage - if err := xml.NewDecoder(r).Decode(&p); err != nil { + if err := xml.NewDecoder(io.TeeReader(r, &nuspecBuf)).Decode(&p); err != nil { return nil, err } @@ -165,12 +151,10 @@ func ParseNuspecMetaData(r io.Reader) (*Package, error) { } packageType := DependencyPackage - if p.Metadata.PackageTypes != nil { - for _, pt := range p.Metadata.PackageTypes.PackageType { - if pt.Name == "SymbolsPackage" { - packageType = SymbolsPackage - break - } + for _, pt := range p.Metadata.PackageTypes.PackageType { + if pt.Name == "SymbolsPackage" { + packageType = SymbolsPackage + break } } @@ -179,34 +163,32 @@ func ParseNuspecMetaData(r io.Reader) (*Package, error) { ReleaseNotes: p.Metadata.ReleaseNotes, Authors: p.Metadata.Authors, ProjectURL: p.Metadata.ProjectURL, + RepositoryURL: p.Metadata.Repository.URL, RequireLicenseAcceptance: p.Metadata.RequireLicenseAcceptance, Dependencies: make(map[string][]Dependency), } - if p.Metadata.Repository != nil { - m.RepositoryURL = p.Metadata.Repository.URL - } - if p.Metadata.Dependencies != nil { - for _, group := range p.Metadata.Dependencies.Group { - deps := make([]Dependency, 0, len(group.Dependency)) - for _, dep := range group.Dependency { - if dep.ID == "" || dep.Version == "" { - continue - } - deps = append(deps, Dependency{ - ID: dep.ID, - Version: dep.Version, - }) - } - if len(deps) > 0 { - m.Dependencies[group.TargetFramework] = deps + + for _, group := range p.Metadata.Dependencies.Group { + deps := make([]Dependency, 0, len(group.Dependency)) + for _, dep := range group.Dependency { + if dep.ID == "" || dep.Version == "" { + continue } + deps = append(deps, Dependency{ + ID: dep.ID, + Version: dep.Version, + }) + } + if len(deps) > 0 { + m.Dependencies[group.TargetFramework] = deps } } return &Package{ - PackageType: packageType, - ID: p.Metadata.ID, - Version: toNormalizedVersion(v), - Metadata: m, + PackageType: packageType, + ID: p.Metadata.ID, + Version: toNormalizedVersion(v), + Metadata: m, + NuspecContent: &nuspecBuf, }, nil } @@ -225,51 +207,3 @@ func toNormalizedVersion(v *version.Version) string { } return buf.String() } - -// returning any here because we use a private type and we don't need the type for xml marshalling -func GenerateNuspec(pd *Package) any { - m := nuspeceMetadata{ - ID: pd.ID, - Version: pd.Version, - Authors: pd.Metadata.Authors, - Description: pd.Metadata.Description, - ProjectURL: pd.Metadata.ProjectURL, - RequireLicenseAcceptance: pd.Metadata.RequireLicenseAcceptance, - } - - if pd.Metadata.RepositoryURL != "" { - m.Repository = &nuspecRepository{ - URL: pd.Metadata.RepositoryURL, - } - } - - groups := len(pd.Metadata.Dependencies) - if groups > 0 { - m.Dependencies = &nuspecDependencies{ - Group: make([]nuspecGroup, 0, groups), - } - - for tgf, deps := range pd.Metadata.Dependencies { - if len(deps) == 0 { - continue - } - gDeps := make([]nuspecDependency, 0, len(deps)) - for _, dep := range deps { - gDeps = append(gDeps, nuspecDependency{ - ID: dep.ID, - Version: dep.Version, - }) - } - - m.Dependencies.Group = append(m.Dependencies.Group, nuspecGroup{ - TargetFramework: tgf, - Dependency: gDeps, - }) - } - } - - return &nuspecPackage{ - Xmlns: "http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd", - Metadata: m, - } -} diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go index 0eb817c1a..09156ece6 100644 --- a/routers/api/packages/nuget/nuget.go +++ b/routers/api/packages/nuget/nuget.go @@ -395,49 +395,28 @@ func DownloadPackageFile(ctx *context.Context) { packageVersion := ctx.Params("version") filename := ctx.Params("filename") - if filename == fmt.Sprintf("%s.nuspec", packageName) { - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion) - if err != nil { + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNuGet, + Name: packageName, + Version: packageVersion, + }, + &packages_service.PackageFileInfo{ + Filename: filename, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { apiError(ctx, http.StatusNotFound, err) return } - - pd, err := packages_model.GetPackageDescriptor(ctx, pv) - if err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } - pkg := &nuget_module.Package{ - ID: pd.Package.Name, - Version: packageVersion, - Metadata: pd.Metadata.(*nuget_module.Metadata), - } - - xmlResponse(ctx, http.StatusOK, nuget_module.GenerateNuspec(pkg)) - } else { - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( - ctx, - &packages_service.PackageInfo{ - Owner: ctx.Package.Owner, - PackageType: packages_model.TypeNuGet, - Name: packageName, - Version: packageVersion, - }, - &packages_service.PackageFileInfo{ - Filename: filename, - }, - ) - if err != nil { - if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { - apiError(ctx, http.StatusNotFound, err) - return - } - apiError(ctx, http.StatusInternalServerError, err) - return - } - - helper.ServePackageFile(ctx, s, u, pf) + apiError(ctx, http.StatusInternalServerError, err) + return } + + helper.ServePackageFile(ctx, s, u, pf) } // UploadPackage creates a new package with the metadata contained in the uploaded nupgk file @@ -453,7 +432,7 @@ func UploadPackage(ctx *context.Context) { return } - _, _, err := packages_service.CreatePackageAndAddFile( + pv, _, err := packages_service.CreatePackageAndAddFile( ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ @@ -487,6 +466,33 @@ func UploadPackage(ctx *context.Context) { return } + nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len()) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer nuspecBuf.Close() + + _, err = packages_service.AddFileToPackageVersionInternal( + ctx, + pv, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", np.ID)), + }, + Data: nuspecBuf, + }, + ) + if err != nil { + switch err { + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + ctx.Status(http.StatusCreated) } diff --git a/services/doctor/packages_nuget.go b/services/doctor/packages_nuget.go new file mode 100644 index 000000000..8c0a2d856 --- /dev/null +++ b/services/doctor/packages_nuget.go @@ -0,0 +1,161 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "context" + "fmt" + "slices" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + nuget_module "code.gitea.io/gitea/modules/packages/nuget" + packages_service "code.gitea.io/gitea/services/packages" + + "xorm.io/builder" +) + +func init() { + Register(&Check{ + Title: "Extract Nuget Nuspec Files to content store", + Name: "packages-nuget-nuspec", + IsDefault: false, + Run: PackagesNugetNuspecCheck, + Priority: 15, + InitStorage: true, + }) +} + +func PackagesNugetNuspecCheck(ctx context.Context, logger log.Logger, autofix bool) error { + found := 0 + fixed := 0 + errors := 0 + + err := db.Iterate(ctx, builder.Eq{"package.type": packages.TypeNuGet, "package.is_internal": false}, func(ctx context.Context, pkg *packages.Package) error { + logger.Info("Processing package %s", pkg.Name) + + pvs, _, err := packages.SearchVersions(ctx, &packages.PackageSearchOptions{ + Type: packages.TypeNuGet, + PackageID: pkg.ID, + }) + if err != nil { + // Should never happen + logger.Error("Failed to search for versions for package %s: %v", pkg.Name, err) + return err + } + + logger.Info("Found %d versions for package %s", len(pvs), pkg.Name) + + for _, pv := range pvs { + + pfs, err := packages.GetFilesByVersionID(ctx, pv.ID) + if err != nil { + logger.Error("Failed to get files for package version %s %s: %v", pkg.Name, pv.Version, err) + errors++ + continue + } + + if slices.ContainsFunc(pfs, func(pf *packages.PackageFile) bool { return strings.HasSuffix(pf.LowerName, ".nuspec") }) { + logger.Debug("Nuspec file already exists for %s %s", pkg.Name, pv.Version) + continue + } + + nupkgIdx := slices.IndexFunc(pfs, func(pf *packages.PackageFile) bool { return pf.IsLead }) + + if nupkgIdx < 0 { + logger.Error("Missing nupkg file for %s %s", pkg.Name, pv.Version) + errors++ + continue + } + + pf := pfs[nupkgIdx] + + logger.Warn("Missing nuspec file found for %s %s", pkg.Name, pv.Version) + found++ + + if !autofix { + continue + } + + s, _, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + logger.Error("Failed to get nupkg file stream for %s %s: %v", pkg.Name, pv.Version, err) + errors++ + continue + } + defer s.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(s) + if err != nil { + logger.Error("Failed to create hashed buffer for nupkg from reader for %s %s: %v", pkg.Name, pv.Version, err) + errors++ + continue + } + defer buf.Close() + + np, err := nuget_module.ParsePackageMetaData(buf, buf.Size()) + if err != nil { + logger.Error("Failed to parse package metadata for %s %s: %v", pkg.Name, pv.Version, err) + errors++ + continue + } + + nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len()) + if err != nil { + logger.Error("Failed to create hashed buffer for nuspec from reader for %s %s: %v", pkg.Name, pv.Version, err) + errors++ + continue + } + defer nuspecBuf.Close() + + _, err = packages_service.AddFileToPackageVersionInternal( + ctx, + pv, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s.nuspec", pkg.LowerName), + }, + Data: nuspecBuf, + IsLead: false, + }, + ) + if err != nil { + logger.Error("Failed to add nuspec file for %s %s: %v", pkg.Name, pv.Version, err) + errors++ + continue + } + + fixed++ + } + + return nil + }) + if err != nil { + logger.Error("Failed to iterate over users: %v", err) + return err + } + + if autofix { + if fixed > 0 { + logger.Info("Fixed %d package versions by extracting nuspec files", fixed) + } else { + logger.Info("No package versions with missing nuspec files found") + } + } else { + if found > 0 { + logger.Info("Found %d package versions with missing nuspec files", found) + } else { + logger.Info("No package versions with missing nuspec files found") + } + } + + if errors > 0 { + return fmt.Errorf("failed to fix %d nuspec files", errors) + } + + return nil +} diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go index eb6769301..991f37fe7 100644 --- a/tests/integration/api_packages_nuget_test.go +++ b/tests/integration/api_packages_nuget_test.go @@ -112,6 +112,20 @@ func TestPackageNuGet(t *testing.T) { return &buf } + nuspec := ` + + + ` + packageName + ` + ` + packageVersion + ` + ` + packageAuthors + ` + ` + packageDescription + ` + + + + + + + ` content, _ := io.ReadAll(createPackage(packageName, packageVersion)) url := fmt.Sprintf("/api/packages/%s/nuget", user.Name) @@ -224,7 +238,7 @@ func TestPackageNuGet(t *testing.T) { pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet) assert.NoError(t, err) - assert.Len(t, pvs, 1) + assert.Len(t, pvs, 1, "Should have one version") pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) assert.NoError(t, err) @@ -235,7 +249,7 @@ func TestPackageNuGet(t *testing.T) { pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) assert.NoError(t, err) - assert.Len(t, pfs, 1) + assert.Len(t, pfs, 2, "Should have 2 files: nuget and nuspec") assert.Equal(t, fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion), pfs[0].Name) assert.True(t, pfs[0].IsLead) @@ -302,16 +316,27 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) assert.NoError(t, err) - assert.Len(t, pfs, 3) + assert.Len(t, pfs, 4, "Should have 4 files: nupkg, snupkg, nuspec and pdb") for _, pf := range pfs { switch pf.Name { case fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion): + assert.True(t, pf.IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + assert.NoError(t, err) + assert.Equal(t, int64(414), pb.Size) case fmt.Sprintf("%s.%s.snupkg", packageName, packageVersion): assert.False(t, pf.IsLead) pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) assert.NoError(t, err) assert.Equal(t, int64(616), pb.Size) + case fmt.Sprintf("%s.nuspec", packageName): + assert.False(t, pf.IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + assert.NoError(t, err) + assert.Equal(t, int64(453), pb.Size) case symbolFilename: assert.False(t, pf.IsLead) @@ -357,15 +382,6 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) AddBasicAuth(user.Name) resp = MakeRequest(t, req, http.StatusOK) - nuspec := `` + "\n" + - `` + - `` + packageName + `` + packageVersion + `` + packageAuthors + `` + packageDescription + `` + - `` + - // https://github.com/golang/go/issues/21399 go can't generate self-closing tags - `` + - `` + - `` - assert.Equal(t, nuspec, resp.Body.String()) checkDownloadCount(1) diff --git a/tests/integration/doctor_packages_nuget_test.go b/tests/integration/doctor_packages_nuget_test.go new file mode 100644 index 000000000..29e4f6055 --- /dev/null +++ b/tests/integration/doctor_packages_nuget_test.go @@ -0,0 +1,121 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + doctor "code.gitea.io/gitea/services/doctor" + packages_service "code.gitea.io/gitea/services/packages" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestDoctorPackagesNuget(t *testing.T) { + defer tests.PrepareTestEnv(t, 1)() + // use local storage for tests because minio is too flaky + defer test.MockVariableValue(&setting.Packages.Storage.Type, setting.LocalStorageType)() + + logger := log.GetLogger("doctor") + + ctx := db.DefaultContext + + packageName := "test.package" + packageVersion := "1.0.3" + packageAuthors := "KN4CK3R" + packageDescription := "Gitea Test Package" + + createPackage := func(id, version string) io.Reader { + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + w, _ := archive.Create("package.nuspec") + w.Write([]byte(` + + + ` + id + ` + ` + version + ` + ` + packageAuthors + ` + ` + packageDescription + ` + + + + + + + `)) + archive.Close() + return &buf + } + + pkg := createPackage(packageName, packageVersion) + + pkgBuf, err := packages_module.CreateHashedBufferFromReader(pkg) + assert.NoError(t, err, "Error creating hashed buffer from nupkg") + defer pkgBuf.Close() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.NoError(t, err, "Error getting user by ID 2") + + t.Run("PackagesNugetNuspecCheck", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + pi := &packages_service.PackageInfo{ + Owner: doer, + PackageType: packages_model.TypeNuGet, + Name: packageName, + Version: packageVersion, + } + _, _, err := packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: *pi, + SemverCompatible: true, + Creator: doer, + Metadata: nil, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion)), + }, + Creator: doer, + Data: pkgBuf, + IsLead: true, + }, + ) + assert.NoError(t, err, "Error creating package and adding file") + + assert.NoError(t, doctor.PackagesNugetNuspecCheck(ctx, logger, true), "Doctor check failed") + + s, _, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: doer, + PackageType: packages_model.TypeNuGet, + Name: packageName, + Version: packageVersion, + }, + &packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", packageName)), + }, + ) + + assert.NoError(t, err, "Error getting nuspec file stream by package name and version") + defer s.Close() + + assert.Equal(t, fmt.Sprintf("%s.nuspec", packageName), pf.Name, "Not a nuspec") + }) +}