313 lines
8.4 KiB
Go
313 lines
8.4 KiB
Go
// Package bundle liest Hugin-Bundles: ZIP (oder entpackter Ordner) mit
|
|
// hugin-bundle.json, den Dateien fürs Repo unter files/ und denen lokaler Server
|
|
// unter local/ (→ contract/hugin-bundle.schema.json).
|
|
package bundle
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"hugin/cli/internal/paths"
|
|
)
|
|
|
|
const (
|
|
InstructionsFile = "hugin-bundle.json"
|
|
Format = 1
|
|
// Obergrenze je Datei im Bundle, gegen ZIP-Bomben
|
|
maxFile = 16 << 20
|
|
)
|
|
|
|
type Instructions struct {
|
|
Format int `json:"format"`
|
|
Generated string `json:"generated"`
|
|
Tools []string `json:"tools"`
|
|
Packages []Package `json:"packages"`
|
|
Operations []Operation `json:"operations"`
|
|
Hints []Hint `json:"hints"`
|
|
}
|
|
|
|
type Package struct {
|
|
ID string `json:"id"`
|
|
Version string `json:"version"`
|
|
Checksum string `json:"checksum"`
|
|
Kind string `json:"kind"`
|
|
Risk string `json:"risk"`
|
|
Compatibility string `json:"compatibility"`
|
|
Mandatory bool `json:"mandatory"`
|
|
}
|
|
|
|
// Operation fasst alle Arten zusammen; welche Felder gelten, hängt von Op ab.
|
|
type Operation struct {
|
|
Op string `json:"op"`
|
|
Package string `json:"package,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
Sha256 string `json:"sha256,omitempty"`
|
|
File string `json:"file,omitempty"`
|
|
Block string `json:"block,omitempty"`
|
|
Content *string `json:"content,omitempty"`
|
|
Key []string `json:"key,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
Value json.RawMessage `json:"value,omitempty"`
|
|
Create string `json:"create,omitempty"`
|
|
// install: lokaler Server außerhalb des Repos
|
|
Version string `json:"version,omitempty"`
|
|
Checksum string `json:"checksum,omitempty"`
|
|
Runtime string `json:"runtime,omitempty"`
|
|
Entry string `json:"entry,omitempty"`
|
|
Python string `json:"python,omitempty"`
|
|
Files []InstallFile `json:"files,omitempty"`
|
|
}
|
|
|
|
// InstallFile ist eine Datei eines lokalen Servers; Path gilt relativ zu seinem Ordner.
|
|
type InstallFile struct {
|
|
Path string `json:"path"`
|
|
Source string `json:"source"`
|
|
Sha256 string `json:"sha256"`
|
|
}
|
|
|
|
// Installs liefert die install-Operationen, also die lokalen Server des Bundles.
|
|
func (b *Bundle) Installs() []Operation {
|
|
var ops []Operation
|
|
for _, op := range b.Instructions.Operations {
|
|
if op.Op == "install" {
|
|
ops = append(ops, op)
|
|
}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
// ListItem sagt, ob Value in einer Liste stehen soll statt den Schlüssel zu ersetzen.
|
|
func (o Operation) ListItem() bool { return o.Mode == "list_item" }
|
|
|
|
type Hint struct {
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name,omitempty"`
|
|
Package string `json:"package,omitempty"`
|
|
Tool string `json:"tool,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
Successor string `json:"successor,omitempty"`
|
|
}
|
|
|
|
type Bundle struct {
|
|
Instructions Instructions
|
|
files map[string][]byte
|
|
}
|
|
|
|
// Empty ist ein Bundle ohne Pakete; angewendet entfernt es alles, was die Lock-Datei nennt.
|
|
func Empty() *Bundle {
|
|
return &Bundle{Instructions: Instructions{Format: Format}, files: map[string][]byte{}}
|
|
}
|
|
|
|
// File liefert den Inhalt einer Datei im Bundle (Pfad wie in source/create).
|
|
func (b *Bundle) File(source string) ([]byte, error) {
|
|
data, ok := b.files[source]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s fehlt im Bundle", source)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Read öffnet ein Bundle als ZIP-Datei oder entpackten Ordner und prüft es.
|
|
func Read(path string) (*Bundle, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var files map[string][]byte
|
|
if info.IsDir() {
|
|
files, err = fromFolder(path)
|
|
} else {
|
|
var raw []byte
|
|
if raw, err = os.ReadFile(path); err == nil {
|
|
files, err = fromZip(raw)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fromFiles(files)
|
|
}
|
|
|
|
// FromZip liest ein Bundle aus den Bytes einer ZIP-Datei (Antwort von POST /bundle).
|
|
func FromZip(raw []byte) (*Bundle, error) {
|
|
files, err := fromZip(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fromFiles(files)
|
|
}
|
|
|
|
func fromZip(raw []byte) (map[string][]byte, error) {
|
|
z, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Bundle ist kein ZIP: %w", err)
|
|
}
|
|
files := map[string][]byte{}
|
|
for _, f := range z.File {
|
|
if strings.HasSuffix(f.Name, "/") {
|
|
continue
|
|
}
|
|
if f.UncompressedSize64 > maxFile {
|
|
return nil, fmt.Errorf("%s ist zu groß", f.Name)
|
|
}
|
|
r, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(r, maxFile+1))
|
|
r.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files[f.Name] = data
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func fromFolder(folder string) (map[string][]byte, error) {
|
|
files := map[string][]byte{}
|
|
err := filepath.WalkDir(folder, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(folder, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(p)
|
|
files[filepath.ToSlash(rel)] = data
|
|
return err
|
|
})
|
|
return files, err
|
|
}
|
|
|
|
func fromFiles(files map[string][]byte) (*Bundle, error) {
|
|
raw, ok := files[InstructionsFile]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s fehlt im Bundle", InstructionsFile)
|
|
}
|
|
var a Instructions
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", InstructionsFile, err)
|
|
}
|
|
b := &Bundle{Instructions: a, files: files}
|
|
return b, b.validate()
|
|
}
|
|
|
|
func (b *Bundle) validate() error {
|
|
if b.Instructions.Format != Format {
|
|
return fmt.Errorf("Bundle-Format %d wird nicht unterstützt (erwartet %d); hugin aktualisieren",
|
|
b.Instructions.Format, Format)
|
|
}
|
|
var errs []error
|
|
for i, op := range b.Instructions.Operations {
|
|
if err := b.validateOp(op); err != nil {
|
|
errs = append(errs, fmt.Errorf("Operation %d (%s): %w", i+1, op.Op, err))
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (b *Bundle) validateOp(op Operation) error {
|
|
switch op.Op {
|
|
case "file":
|
|
if err := paths.Validate(op.Path); err != nil {
|
|
return err
|
|
}
|
|
return b.validateSource(op.Source, op.Sha256)
|
|
case "remove":
|
|
return paths.Validate(op.Path)
|
|
case "block":
|
|
if op.Content == nil || op.Block == "" {
|
|
return errors.New("block und inhalt sind Pflicht")
|
|
}
|
|
if op.Create != "" {
|
|
if err := b.validateSource(op.Create, ""); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return paths.Validate(op.File)
|
|
case "json", "json_remove":
|
|
if len(op.Key) == 0 {
|
|
return errors.New("key fehlt")
|
|
}
|
|
if op.Op == "json" && len(op.Value) == 0 {
|
|
return errors.New("value fehlt")
|
|
}
|
|
if op.Create != "" {
|
|
if err := b.validateSource(op.Create, ""); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return paths.Validate(op.File)
|
|
case "install":
|
|
return b.validateInstall(op)
|
|
}
|
|
return fmt.Errorf("unbekannte Operation %q; hugin aktualisieren", op.Op)
|
|
}
|
|
|
|
var (
|
|
packageId = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
|
semver = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
|
)
|
|
|
|
func (b *Bundle) validateInstall(op Operation) error {
|
|
// Paket und Version werden Ordnernamen außerhalb des Repos: streng prüfen
|
|
if !packageId.MatchString(op.Package) || !semver.MatchString(op.Version) {
|
|
return fmt.Errorf("ungültiges Paket %q oder Version %q", op.Package, op.Version)
|
|
}
|
|
if op.Entry == "" || op.Checksum == "" {
|
|
return errors.New("entry und checksum sind Pflicht")
|
|
}
|
|
prefix := "local/" + op.Package + "/" + op.Version + "/"
|
|
for _, f := range op.Files {
|
|
if err := paths.Validate(f.Path); err != nil {
|
|
return err
|
|
}
|
|
if f.Source != prefix+f.Path {
|
|
return fmt.Errorf("source %q passt nicht zu %s%s", f.Source, prefix, f.Path)
|
|
}
|
|
data, err := b.File(f.Source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if Sha256(data) != f.Sha256 {
|
|
return fmt.Errorf("%s: Prüfsumme stimmt nicht", f.Source)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Bundle) validateSource(source, sha string) error {
|
|
if !strings.HasPrefix(source, "files/") {
|
|
return fmt.Errorf("source %q liegt nicht unter files/", source)
|
|
}
|
|
if err := paths.Validate(source); err != nil {
|
|
return err
|
|
}
|
|
data, err := b.File(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sha != "" && Sha256(data) != sha {
|
|
return fmt.Errorf("%s: Prüfsumme stimmt nicht", source)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func Sha256(data []byte) string {
|
|
total := sha256.Sum256(data)
|
|
return "sha256:" + hex.EncodeToString(total[:])
|
|
}
|