diff options
Diffstat (limited to '')
-rw-r--r-- | modules/packages/swift/metadata.go | 214 | ||||
-rw-r--r-- | modules/packages/swift/metadata_test.go | 145 |
2 files changed, 359 insertions, 0 deletions
diff --git a/modules/packages/swift/metadata.go b/modules/packages/swift/metadata.go new file mode 100644 index 0000000..24c4262 --- /dev/null +++ b/modules/packages/swift/metadata.go @@ -0,0 +1,214 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swift + +import ( + "archive/zip" + "fmt" + "io" + "path" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" + + "github.com/hashicorp/go-version" +) + +var ( + ErrMissingManifestFile = util.NewInvalidArgumentErrorf("Package.swift file is missing") + ErrManifestFileTooLarge = util.NewInvalidArgumentErrorf("Package.swift file is too large") + ErrInvalidManifestVersion = util.NewInvalidArgumentErrorf("manifest version is invalid") + + manifestPattern = regexp.MustCompile(`\APackage(?:@swift-(\d+(?:\.\d+)?(?:\.\d+)?))?\.swift\z`) + toolsVersionPattern = regexp.MustCompile(`\A// swift-tools-version:(\d+(?:\.\d+)?(?:\.\d+)?)`) +) + +const ( + maxManifestFileSize = 128 * 1024 + + PropertyScope = "swift.scope" + PropertyName = "swift.name" + PropertyRepositoryURL = "swift.repository_url" +) + +// Package represents a Swift package +type Package struct { + RepositoryURLs []string + Metadata *Metadata +} + +// Metadata represents the metadata of a Swift package +type Metadata struct { + Description string `json:"description,omitempty"` + Keywords []string `json:"keywords,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + License string `json:"license,omitempty"` + Author Person `json:"author,omitempty"` + Manifests map[string]*Manifest `json:"manifests,omitempty"` +} + +// Manifest represents a Package.swift file +type Manifest struct { + Content string `json:"content"` + ToolsVersion string `json:"tools_version,omitempty"` +} + +// https://schema.org/SoftwareSourceCode +type SoftwareSourceCode struct { + Context []string `json:"@context"` + Type string `json:"@type"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Keywords []string `json:"keywords,omitempty"` + CodeRepository string `json:"codeRepository,omitempty"` + License string `json:"license,omitempty"` + Author Person `json:"author"` + ProgrammingLanguage ProgrammingLanguage `json:"programmingLanguage"` + RepositoryURLs []string `json:"repositoryURLs,omitempty"` +} + +// https://schema.org/ProgrammingLanguage +type ProgrammingLanguage struct { + Type string `json:"@type"` + Name string `json:"name"` + URL string `json:"url"` +} + +// https://schema.org/Person +type Person struct { + Type string `json:"@type,omitempty"` + GivenName string `json:"givenName,omitempty"` + MiddleName string `json:"middleName,omitempty"` + FamilyName string `json:"familyName,omitempty"` +} + +func (p Person) String() string { + var sb strings.Builder + if p.GivenName != "" { + sb.WriteString(p.GivenName) + } + if p.MiddleName != "" { + if sb.Len() > 0 { + sb.WriteRune(' ') + } + sb.WriteString(p.MiddleName) + } + if p.FamilyName != "" { + if sb.Len() > 0 { + sb.WriteRune(' ') + } + sb.WriteString(p.FamilyName) + } + return sb.String() +} + +// ParsePackage parses the Swift package upload +func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) { + zr, err := zip.NewReader(sr, size) + if err != nil { + return nil, err + } + + p := &Package{ + Metadata: &Metadata{ + Manifests: make(map[string]*Manifest), + }, + } + + for _, file := range zr.File { + manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name)) + if len(manifestMatch) == 0 { + continue + } + + if file.UncompressedSize64 > maxManifestFileSize { + return nil, ErrManifestFileTooLarge + } + + f, err := zr.Open(file.Name) + if err != nil { + return nil, err + } + + content, err := io.ReadAll(f) + + if err := f.Close(); err != nil { + return nil, err + } + + if err != nil { + return nil, err + } + + swiftVersion := "" + if len(manifestMatch) == 2 && manifestMatch[1] != "" { + v, err := version.NewSemver(manifestMatch[1]) + if err != nil { + return nil, ErrInvalidManifestVersion + } + swiftVersion = TrimmedVersionString(v) + } + + manifest := &Manifest{ + Content: string(content), + } + + toolsMatch := toolsVersionPattern.FindStringSubmatch(manifest.Content) + if len(toolsMatch) == 2 { + v, err := version.NewSemver(toolsMatch[1]) + if err != nil { + return nil, ErrInvalidManifestVersion + } + + manifest.ToolsVersion = TrimmedVersionString(v) + } + + p.Metadata.Manifests[swiftVersion] = manifest + } + + if _, found := p.Metadata.Manifests[""]; !found { + return nil, ErrMissingManifestFile + } + + if mr != nil { + var ssc *SoftwareSourceCode + if err := json.NewDecoder(mr).Decode(&ssc); err != nil { + return nil, err + } + + p.Metadata.Description = ssc.Description + p.Metadata.Keywords = ssc.Keywords + p.Metadata.License = ssc.License + p.Metadata.Author = Person{ + GivenName: ssc.Author.GivenName, + MiddleName: ssc.Author.MiddleName, + FamilyName: ssc.Author.FamilyName, + } + + p.Metadata.RepositoryURL = ssc.CodeRepository + if !validation.IsValidURL(p.Metadata.RepositoryURL) { + p.Metadata.RepositoryURL = "" + } + + p.RepositoryURLs = ssc.RepositoryURLs + } + + return p, nil +} + +// TrimmedVersionString returns the version string without the patch segment if it is zero +func TrimmedVersionString(v *version.Version) string { + segments := v.Segments64() + + var b strings.Builder + fmt.Fprintf(&b, "%d.%d", segments[0], segments[1]) + if segments[2] != 0 { + fmt.Fprintf(&b, ".%d", segments[2]) + } + return b.String() +} diff --git a/modules/packages/swift/metadata_test.go b/modules/packages/swift/metadata_test.go new file mode 100644 index 0000000..b223d8c --- /dev/null +++ b/modules/packages/swift/metadata_test.go @@ -0,0 +1,145 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swift + +import ( + "archive/zip" + "bytes" + "strings" + "testing" + + "github.com/hashicorp/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + packageName = "gitea" + packageVersion = "1.0.1" + packageDescription = "Package Description" + packageRepositoryURL = "https://gitea.io/gitea/gitea" + packageAuthor = "KN4CK3R" + packageLicense = "MIT" +) + +func TestParsePackage(t *testing.T) { + createArchive := func(files map[string][]byte) *bytes.Reader { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for filename, content := range files { + w, _ := zw.Create(filename) + w.Write(content) + } + zw.Close() + return bytes.NewReader(buf.Bytes()) + } + + t.Run("MissingManifestFile", func(t *testing.T) { + data := createArchive(map[string][]byte{"dummy.txt": {}}) + + p, err := ParsePackage(data, data.Size(), nil) + assert.Nil(t, p) + require.ErrorIs(t, err, ErrMissingManifestFile) + }) + + t.Run("ManifestFileTooLarge", func(t *testing.T) { + data := createArchive(map[string][]byte{ + "Package.swift": make([]byte, maxManifestFileSize+1), + }) + + p, err := ParsePackage(data, data.Size(), nil) + assert.Nil(t, p) + require.ErrorIs(t, err, ErrManifestFileTooLarge) + }) + + t.Run("WithoutMetadata", func(t *testing.T) { + content1 := "// swift-tools-version:5.7\n//\n// Package.swift" + content2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift" + + data := createArchive(map[string][]byte{ + "Package.swift": []byte(content1), + "Package@swift-5.5.swift": []byte(content2), + }) + + p, err := ParsePackage(data, data.Size(), nil) + assert.NotNil(t, p) + require.NoError(t, err) + + assert.NotNil(t, p.Metadata) + assert.Empty(t, p.RepositoryURLs) + assert.Len(t, p.Metadata.Manifests, 2) + m := p.Metadata.Manifests[""] + assert.Equal(t, "5.7", m.ToolsVersion) + assert.Equal(t, content1, m.Content) + m = p.Metadata.Manifests["5.5"] + assert.Equal(t, "5.6", m.ToolsVersion) + assert.Equal(t, content2, m.Content) + }) + + t.Run("WithMetadata", func(t *testing.T) { + data := createArchive(map[string][]byte{ + "Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"), + }) + + p, err := ParsePackage( + data, + data.Size(), + strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","keywords":["swift","package"],"license":"`+packageLicense+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`), + ) + assert.NotNil(t, p) + require.NoError(t, err) + + assert.NotNil(t, p.Metadata) + assert.Len(t, p.Metadata.Manifests, 1) + m := p.Metadata.Manifests[""] + assert.Equal(t, "5.7", m.ToolsVersion) + + assert.Equal(t, packageDescription, p.Metadata.Description) + assert.ElementsMatch(t, []string{"swift", "package"}, p.Metadata.Keywords) + assert.Equal(t, packageLicense, p.Metadata.License) + assert.Equal(t, packageAuthor, p.Metadata.Author.GivenName) + assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL) + assert.ElementsMatch(t, []string{packageRepositoryURL}, p.RepositoryURLs) + }) +} + +func TestTrimmedVersionString(t *testing.T) { + cases := []struct { + Version *version.Version + Expected string + }{ + { + Version: version.Must(version.NewVersion("1")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.0")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.1")), + Expected: "1.0.1", + }, + { + Version: version.Must(version.NewVersion("1.0+meta")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.0+meta")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.1+meta")), + Expected: "1.0.1", + }, + } + + for _, c := range cases { + assert.Equal(t, c.Expected, TrimmedVersionString(c.Version)) + } +} |