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