hugin/cli/internal/bundle/bundle.go
2026-10-27 08:33:21 +01:00

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[:])
}