code.gitea.io/gitea@v1.22.3/modules/packages/cran/metadata.go (about)

     1  // Copyright 2023 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package cran
     5  
     6  import (
     7  	"archive/tar"
     8  	"archive/zip"
     9  	"bufio"
    10  	"compress/gzip"
    11  	"io"
    12  	"path"
    13  	"regexp"
    14  	"strings"
    15  
    16  	"code.gitea.io/gitea/modules/util"
    17  )
    18  
    19  const (
    20  	PropertyType     = "cran.type"
    21  	PropertyPlatform = "cran.platform"
    22  	PropertyRVersion = "cran.rvserion"
    23  
    24  	TypeSource = "source"
    25  	TypeBinary = "binary"
    26  )
    27  
    28  var (
    29  	ErrMissingDescriptionFile = util.NewInvalidArgumentErrorf("DESCRIPTION file is missing")
    30  	ErrInvalidName            = util.NewInvalidArgumentErrorf("package name is invalid")
    31  	ErrInvalidVersion         = util.NewInvalidArgumentErrorf("package version is invalid")
    32  )
    33  
    34  var (
    35  	fieldPattern         = regexp.MustCompile(`\A\S+:`)
    36  	namePattern          = regexp.MustCompile(`\A[a-zA-Z][a-zA-Z0-9\.]*[a-zA-Z0-9]\z`)
    37  	versionPattern       = regexp.MustCompile(`\A[0-9]+(?:[.\-][0-9]+){1,3}\z`)
    38  	authorReplacePattern = regexp.MustCompile(`[\[\(].+?[\]\)]`)
    39  )
    40  
    41  // Package represents a CRAN package
    42  type Package struct {
    43  	Name          string
    44  	Version       string
    45  	FileExtension string
    46  	Metadata      *Metadata
    47  }
    48  
    49  // Metadata represents the metadata of a CRAN package
    50  type Metadata struct {
    51  	Title            string   `json:"title,omitempty"`
    52  	Description      string   `json:"description,omitempty"`
    53  	ProjectURL       []string `json:"project_url,omitempty"`
    54  	License          string   `json:"license,omitempty"`
    55  	Authors          []string `json:"authors,omitempty"`
    56  	Depends          []string `json:"depends,omitempty"`
    57  	Imports          []string `json:"imports,omitempty"`
    58  	Suggests         []string `json:"suggests,omitempty"`
    59  	LinkingTo        []string `json:"linking_to,omitempty"`
    60  	NeedsCompilation bool     `json:"needs_compilation"`
    61  }
    62  
    63  type ReaderReaderAt interface {
    64  	io.Reader
    65  	io.ReaderAt
    66  }
    67  
    68  // ParsePackage reads the package metadata from a CRAN package
    69  // .zip and .tar.gz/.tgz files are supported.
    70  func ParsePackage(r ReaderReaderAt, size int64) (*Package, error) {
    71  	magicBytes := make([]byte, 2)
    72  	if _, err := r.ReadAt(magicBytes, 0); err != nil {
    73  		return nil, err
    74  	}
    75  
    76  	if magicBytes[0] == 0x1F && magicBytes[1] == 0x8B {
    77  		return parsePackageTarGz(r)
    78  	}
    79  	return parsePackageZip(r, size)
    80  }
    81  
    82  func parsePackageTarGz(r io.Reader) (*Package, error) {
    83  	gzr, err := gzip.NewReader(r)
    84  	if err != nil {
    85  		return nil, err
    86  	}
    87  	defer gzr.Close()
    88  
    89  	tr := tar.NewReader(gzr)
    90  	for {
    91  		hd, err := tr.Next()
    92  		if err == io.EOF {
    93  			break
    94  		}
    95  		if err != nil {
    96  			return nil, err
    97  		}
    98  
    99  		if hd.Typeflag != tar.TypeReg {
   100  			continue
   101  		}
   102  
   103  		if strings.Count(hd.Name, "/") > 1 {
   104  			continue
   105  		}
   106  
   107  		if path.Base(hd.Name) == "DESCRIPTION" {
   108  			p, err := ParseDescription(tr)
   109  			if p != nil {
   110  				p.FileExtension = ".tar.gz"
   111  			}
   112  			return p, err
   113  		}
   114  	}
   115  
   116  	return nil, ErrMissingDescriptionFile
   117  }
   118  
   119  func parsePackageZip(r io.ReaderAt, size int64) (*Package, error) {
   120  	zr, err := zip.NewReader(r, size)
   121  	if err != nil {
   122  		return nil, err
   123  	}
   124  
   125  	for _, file := range zr.File {
   126  		if strings.Count(file.Name, "/") > 1 {
   127  			continue
   128  		}
   129  
   130  		if path.Base(file.Name) == "DESCRIPTION" {
   131  			f, err := zr.Open(file.Name)
   132  			if err != nil {
   133  				return nil, err
   134  			}
   135  			defer f.Close()
   136  
   137  			p, err := ParseDescription(f)
   138  			if p != nil {
   139  				p.FileExtension = ".zip"
   140  			}
   141  			return p, err
   142  		}
   143  	}
   144  
   145  	return nil, ErrMissingDescriptionFile
   146  }
   147  
   148  // ParseDescription parses a DESCRIPTION file to retrieve the metadata of a CRAN package
   149  func ParseDescription(r io.Reader) (*Package, error) {
   150  	p := &Package{
   151  		Metadata: &Metadata{},
   152  	}
   153  
   154  	scanner := bufio.NewScanner(r)
   155  
   156  	var b strings.Builder
   157  	for scanner.Scan() {
   158  		line := strings.TrimSpace(scanner.Text())
   159  		if line == "" {
   160  			continue
   161  		}
   162  		if !fieldPattern.MatchString(line) {
   163  			b.WriteRune(' ')
   164  			b.WriteString(line)
   165  			continue
   166  		}
   167  
   168  		if err := setField(p, b.String()); err != nil {
   169  			return nil, err
   170  		}
   171  
   172  		b.Reset()
   173  		b.WriteString(line)
   174  	}
   175  
   176  	if err := setField(p, b.String()); err != nil {
   177  		return nil, err
   178  	}
   179  
   180  	if err := scanner.Err(); err != nil {
   181  		return nil, err
   182  	}
   183  
   184  	return p, nil
   185  }
   186  
   187  func setField(p *Package, data string) error {
   188  	const listDelimiter = ", "
   189  
   190  	if data == "" {
   191  		return nil
   192  	}
   193  
   194  	parts := strings.SplitN(data, ":", 2)
   195  	if len(parts) != 2 {
   196  		return nil
   197  	}
   198  
   199  	name := strings.TrimSpace(parts[0])
   200  	value := strings.TrimSpace(parts[1])
   201  
   202  	switch name {
   203  	case "Package":
   204  		if !namePattern.MatchString(value) {
   205  			return ErrInvalidName
   206  		}
   207  		p.Name = value
   208  	case "Version":
   209  		if !versionPattern.MatchString(value) {
   210  			return ErrInvalidVersion
   211  		}
   212  		p.Version = value
   213  	case "Title":
   214  		p.Metadata.Title = value
   215  	case "Description":
   216  		p.Metadata.Description = value
   217  	case "URL":
   218  		p.Metadata.ProjectURL = splitAndTrim(value, listDelimiter)
   219  	case "License":
   220  		p.Metadata.License = value
   221  	case "Author":
   222  		p.Metadata.Authors = splitAndTrim(authorReplacePattern.ReplaceAllString(value, ""), listDelimiter)
   223  	case "Depends":
   224  		p.Metadata.Depends = splitAndTrim(value, listDelimiter)
   225  	case "Imports":
   226  		p.Metadata.Imports = splitAndTrim(value, listDelimiter)
   227  	case "Suggests":
   228  		p.Metadata.Suggests = splitAndTrim(value, listDelimiter)
   229  	case "LinkingTo":
   230  		p.Metadata.LinkingTo = splitAndTrim(value, listDelimiter)
   231  	case "NeedsCompilation":
   232  		p.Metadata.NeedsCompilation = value == "yes"
   233  	}
   234  
   235  	return nil
   236  }
   237  
   238  func splitAndTrim(s, sep string) []string {
   239  	items := strings.Split(s, sep)
   240  	for i := range items {
   241  		items[i] = strings.TrimSpace(items[i])
   242  	}
   243  	return items
   244  }