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 }