2023-05-22 04:57:49 +02:00
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cran
import (
"archive/tar"
"archive/zip"
"bufio"
"compress/gzip"
"io"
"path"
"regexp"
"strings"
"code.gitea.io/gitea/modules/util"
)
const (
PropertyType = "cran.type"
PropertyPlatform = "cran.platform"
PropertyRVersion = "cran.rvserion"
TypeSource = "source"
TypeBinary = "binary"
)
var (
ErrMissingDescriptionFile = util . NewInvalidArgumentErrorf ( "DESCRIPTION file is missing" )
ErrInvalidName = util . NewInvalidArgumentErrorf ( "package name is invalid" )
ErrInvalidVersion = util . NewInvalidArgumentErrorf ( "package version is invalid" )
)
var (
fieldPattern = regexp . MustCompile ( ` \A\S+: ` )
namePattern = regexp . MustCompile ( ` \A[a-zA-Z][a-zA-Z0-9\.]*[a-zA-Z0-9]\z ` )
versionPattern = regexp . MustCompile ( ` \A[0-9]+(?:[.\-][0-9]+) { 1,3}\z ` )
authorReplacePattern = regexp . MustCompile ( ` [\[\(].+?[\]\)] ` )
)
// Package represents a CRAN package
type Package struct {
Name string
Version string
FileExtension string
Metadata * Metadata
}
// Metadata represents the metadata of a CRAN package
type Metadata struct {
Title string ` json:"title,omitempty" `
Description string ` json:"description,omitempty" `
ProjectURL [ ] string ` json:"project_url,omitempty" `
License string ` json:"license,omitempty" `
Authors [ ] string ` json:"authors,omitempty" `
Depends [ ] string ` json:"depends,omitempty" `
Imports [ ] string ` json:"imports,omitempty" `
Suggests [ ] string ` json:"suggests,omitempty" `
LinkingTo [ ] string ` json:"linking_to,omitempty" `
NeedsCompilation bool ` json:"needs_compilation" `
}
type ReaderReaderAt interface {
io . Reader
io . ReaderAt
}
// ParsePackage reads the package metadata from a CRAN package
// .zip and .tar.gz/.tgz files are supported.
func ParsePackage ( r ReaderReaderAt , size int64 ) ( * Package , error ) {
magicBytes := make ( [ ] byte , 2 )
if _ , err := r . ReadAt ( magicBytes , 0 ) ; err != nil {
return nil , err
}
if magicBytes [ 0 ] == 0x1F && magicBytes [ 1 ] == 0x8B {
return parsePackageTarGz ( r )
}
return parsePackageZip ( r , size )
}
func parsePackageTarGz ( r io . Reader ) ( * Package , error ) {
gzr , err := gzip . NewReader ( r )
if err != nil {
return nil , err
}
defer gzr . Close ( )
tr := tar . NewReader ( gzr )
for {
hd , err := tr . Next ( )
if err == io . EOF {
break
}
if err != nil {
return nil , err
}
if hd . Typeflag != tar . TypeReg {
continue
}
if strings . Count ( hd . Name , "/" ) > 1 {
continue
}
if path . Base ( hd . Name ) == "DESCRIPTION" {
p , err := ParseDescription ( tr )
if p != nil {
p . FileExtension = ".tar.gz"
}
return p , err
}
}
return nil , ErrMissingDescriptionFile
}
func parsePackageZip ( r io . ReaderAt , size int64 ) ( * Package , error ) {
zr , err := zip . NewReader ( r , size )
if err != nil {
return nil , err
}
for _ , file := range zr . File {
if strings . Count ( file . Name , "/" ) > 1 {
continue
}
if path . Base ( file . Name ) == "DESCRIPTION" {
f , err := zr . Open ( file . Name )
if err != nil {
return nil , err
}
defer f . Close ( )
p , err := ParseDescription ( f )
if p != nil {
p . FileExtension = ".zip"
}
return p , err
}
}
return nil , ErrMissingDescriptionFile
}
// ParseDescription parses a DESCRIPTION file to retrieve the metadata of a CRAN package
func ParseDescription ( r io . Reader ) ( * Package , error ) {
p := & Package {
Metadata : & Metadata { } ,
}
scanner := bufio . NewScanner ( r )
var b strings . Builder
for scanner . Scan ( ) {
line := strings . TrimSpace ( scanner . Text ( ) )
if line == "" {
continue
}
if ! fieldPattern . MatchString ( line ) {
b . WriteRune ( ' ' )
b . WriteString ( line )
continue
}
if err := setField ( p , b . String ( ) ) ; err != nil {
return nil , err
}
b . Reset ( )
b . WriteString ( line )
}
if err := setField ( p , b . String ( ) ) ; err != nil {
return nil , err
}
if err := scanner . Err ( ) ; err != nil {
return nil , err
}
return p , nil
}
func setField ( p * Package , data string ) error {
if data == "" {
return nil
}
parts := strings . SplitN ( data , ":" , 2 )
if len ( parts ) != 2 {
return nil
}
name := strings . TrimSpace ( parts [ 0 ] )
value := strings . TrimSpace ( parts [ 1 ] )
switch name {
case "Package" :
if ! namePattern . MatchString ( value ) {
return ErrInvalidName
}
p . Name = value
case "Version" :
if ! versionPattern . MatchString ( value ) {
return ErrInvalidVersion
}
p . Version = value
case "Title" :
p . Metadata . Title = value
case "Description" :
p . Metadata . Description = value
case "URL" :
2024-06-11 20:47:45 +02:00
p . Metadata . ProjectURL = splitAndTrim ( value )
2023-05-22 04:57:49 +02:00
case "License" :
p . Metadata . License = value
case "Author" :
2024-06-11 20:47:45 +02:00
p . Metadata . Authors = splitAndTrim ( authorReplacePattern . ReplaceAllString ( value , "" ) )
2023-05-22 04:57:49 +02:00
case "Depends" :
2024-06-11 20:47:45 +02:00
p . Metadata . Depends = splitAndTrim ( value )
2023-05-22 04:57:49 +02:00
case "Imports" :
2024-06-11 20:47:45 +02:00
p . Metadata . Imports = splitAndTrim ( value )
2023-05-22 04:57:49 +02:00
case "Suggests" :
2024-06-11 20:47:45 +02:00
p . Metadata . Suggests = splitAndTrim ( value )
2023-05-22 04:57:49 +02:00
case "LinkingTo" :
2024-06-11 20:47:45 +02:00
p . Metadata . LinkingTo = splitAndTrim ( value )
2023-05-22 04:57:49 +02:00
case "NeedsCompilation" :
p . Metadata . NeedsCompilation = value == "yes"
}
return nil
}
2024-06-11 20:47:45 +02:00
func splitAndTrim ( s string ) [ ] string {
items := strings . Split ( s , ", " )
2023-05-22 04:57:49 +02:00
for i := range items {
items [ i ] = strings . TrimSpace ( items [ i ] )
}
return items
}