mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
286 lines
7.1 KiB
Go
286 lines
7.1 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package composer
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/bzip2"
|
|
"compress/gzip"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/git/modules/json"
|
|
"github.com/hanzoai/git/modules/util"
|
|
"github.com/hanzoai/git/modules/validation"
|
|
|
|
"github.com/hashicorp/go-version"
|
|
)
|
|
|
|
// TypeProperty is the name of the property for Composer package types
|
|
const TypeProperty = "composer.type"
|
|
|
|
var (
|
|
// ErrMissingComposerFile indicates a missing composer.json file
|
|
ErrMissingComposerFile = util.NewInvalidArgumentErrorf("composer.json file is missing")
|
|
// ErrInvalidName indicates an invalid package name
|
|
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
|
|
// ErrInvalidVersion indicates an invalid package version
|
|
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
|
)
|
|
|
|
// PackageInfo represents Composer package info
|
|
type PackageInfo struct {
|
|
Filename string
|
|
|
|
Name string
|
|
Version string
|
|
Type string
|
|
Metadata *Metadata
|
|
}
|
|
|
|
// https://getcomposer.org/doc/04-schema.md
|
|
|
|
// Metadata represents the metadata of a Composer package
|
|
type Metadata struct {
|
|
Description string `json:"description,omitempty"`
|
|
Readme string `json:"readme,omitempty"`
|
|
Keywords []string `json:"keywords,omitempty"`
|
|
Comments Comments `json:"_comment,omitempty"`
|
|
Homepage string `json:"homepage,omitempty"`
|
|
License Licenses `json:"license,omitempty"`
|
|
Authors []Author `json:"authors,omitempty"`
|
|
Bin []string `json:"bin,omitempty"`
|
|
Autoload map[string]any `json:"autoload,omitempty"`
|
|
AutoloadDev map[string]any `json:"autoload-dev,omitempty"`
|
|
Extra map[string]any `json:"extra,omitempty"`
|
|
Require map[string]string `json:"require,omitempty"`
|
|
RequireDev map[string]string `json:"require-dev,omitempty"`
|
|
Suggest map[string]string `json:"suggest,omitempty"`
|
|
Provide map[string]string `json:"provide,omitempty"`
|
|
}
|
|
|
|
// Licenses represents the licenses of a Composer package
|
|
type Licenses []string
|
|
|
|
// UnmarshalJSON reads from a string or array
|
|
func (l *Licenses) UnmarshalJSON(data []byte) error {
|
|
switch data[0] {
|
|
case '"':
|
|
var value string
|
|
if err := json.Unmarshal(data, &value); err != nil {
|
|
return err
|
|
}
|
|
*l = Licenses{value}
|
|
case '[':
|
|
values := make([]string, 0, 5)
|
|
if err := json.Unmarshal(data, &values); err != nil {
|
|
return err
|
|
}
|
|
*l = values
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Comments represents the comments of a Composer package
|
|
type Comments []string
|
|
|
|
// UnmarshalJSON reads from a string or array
|
|
func (c *Comments) UnmarshalJSON(data []byte) error {
|
|
switch data[0] {
|
|
case '"':
|
|
var value string
|
|
if err := json.Unmarshal(data, &value); err != nil {
|
|
return err
|
|
}
|
|
*c = Comments{value}
|
|
case '[':
|
|
values := make([]string, 0, 5)
|
|
if err := json.Unmarshal(data, &values); err != nil {
|
|
return err
|
|
}
|
|
*c = values
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Author represents an author
|
|
type Author struct {
|
|
Name string `json:"name,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
Homepage string `json:"homepage,omitempty"`
|
|
}
|
|
|
|
var nameMatch = regexp.MustCompile(`\A[a-z0-9]([_\.-]?[a-z0-9]+)*/[a-z0-9](([_\.]?|-{0,2})[a-z0-9]+)*\z`)
|
|
|
|
type ReadSeekAt interface {
|
|
io.Reader
|
|
io.ReaderAt
|
|
io.Seeker
|
|
Size() int64
|
|
}
|
|
|
|
func readPackageFileZip(r ReadSeekAt, filename string, limit int) ([]byte, error) {
|
|
archive, err := zip.NewReader(r, r.Size())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, file := range archive.File {
|
|
filePath := path.Clean(file.Name)
|
|
if util.AsciiEqualFold(filePath, filename) {
|
|
f, err := archive.Open(file.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
return util.ReadWithLimit(f, limit)
|
|
}
|
|
}
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
|
|
func readPackageFileTar(r io.Reader, filename string, limit int) ([]byte, error) {
|
|
tarReader := tar.NewReader(r)
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
filePath := path.Clean(header.Name)
|
|
if util.AsciiEqualFold(filePath, filename) {
|
|
return util.ReadWithLimit(tarReader, limit)
|
|
}
|
|
}
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
|
|
const (
|
|
pkgExtZip = ".zip"
|
|
pkgExtTarGz = ".tar.gz"
|
|
pkgExtTarBz2 = ".tar.bz2"
|
|
)
|
|
|
|
func detectPackageExtName(r ReadSeekAt) (string, error) {
|
|
headBytes := make([]byte, 4)
|
|
_, err := r.ReadAt(headBytes, 0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, err = r.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch {
|
|
case headBytes[0] == 'P' && headBytes[1] == 'K':
|
|
return pkgExtZip, nil
|
|
case string(headBytes[:3]) == "BZh":
|
|
return pkgExtTarBz2, nil
|
|
case headBytes[0] == 0x1f && headBytes[1] == 0x8b:
|
|
return pkgExtTarGz, nil
|
|
}
|
|
return "", util.NewInvalidArgumentErrorf("not a valid package file")
|
|
}
|
|
|
|
func readPackageFile(pkgExt string, r ReadSeekAt, filename string, limit int) ([]byte, error) {
|
|
_, err := r.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch pkgExt {
|
|
case pkgExtZip:
|
|
return readPackageFileZip(r, filename, limit)
|
|
case pkgExtTarBz2:
|
|
bzip2Reader := bzip2.NewReader(r)
|
|
return readPackageFileTar(bzip2Reader, filename, limit)
|
|
case pkgExtTarGz:
|
|
gzReader, err := gzip.NewReader(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return readPackageFileTar(gzReader, filename, limit)
|
|
}
|
|
return nil, util.NewInvalidArgumentErrorf("not a valid package file")
|
|
}
|
|
|
|
// ParsePackage parses the metadata of a Composer package file
|
|
func ParsePackage(r ReadSeekAt, optVersion ...string) (*PackageInfo, error) {
|
|
pkgExt, err := detectPackageExtName(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dataComposerJSON, err := readPackageFile(pkgExt, r, "composer.json", 10*1024*1024)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, ErrMissingComposerFile
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var cj struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Type string `json:"type"`
|
|
Metadata
|
|
}
|
|
if err := json.Unmarshal(dataComposerJSON, &cj); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !nameMatch.MatchString(cj.Name) {
|
|
return nil, ErrInvalidName
|
|
}
|
|
|
|
if cj.Version == "" {
|
|
cj.Version = util.OptionalArg(optVersion)
|
|
}
|
|
if cj.Version != "" {
|
|
if _, err := version.NewSemver(cj.Version); err != nil {
|
|
return nil, ErrInvalidVersion
|
|
}
|
|
}
|
|
|
|
if !validation.IsValidURL(cj.Homepage) {
|
|
cj.Homepage = ""
|
|
}
|
|
|
|
if cj.Type == "" {
|
|
cj.Type = "library"
|
|
}
|
|
|
|
if cj.Readme == "" {
|
|
cj.Readme = "README.md"
|
|
}
|
|
dataReadmeMd, _ := readPackageFile(pkgExt, r, cj.Readme, 10*1024)
|
|
|
|
// FIXME: legacy problem, the "Readme" field is abused, it should always be the path to the readme file
|
|
if len(dataReadmeMd) == 0 {
|
|
cj.Readme = ""
|
|
} else {
|
|
cj.Readme = string(dataReadmeMd)
|
|
}
|
|
|
|
// FIXME: legacy format: strings.ToLower(fmt.Sprintf("%s.%s.zip", strings.ReplaceAll(cp.Name, "/", "-"), cp.Version)), doesn't read good
|
|
pkgFilename := strings.ReplaceAll(cj.Name, "/", "-")
|
|
if cj.Version != "" {
|
|
pkgFilename += "." + cj.Version
|
|
}
|
|
pkgFilename += pkgExt
|
|
return &PackageInfo{
|
|
Filename: pkgFilename,
|
|
Name: cj.Name,
|
|
Version: cj.Version,
|
|
Type: cj.Type,
|
|
Metadata: &cj.Metadata,
|
|
}, nil
|
|
}
|