package runtimebundle import ( "archive/tar" "compress/gzip" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "sort" "strings" toml "github.com/pelletier/go-toml" ) type Manifest struct { Version string `toml:"version"` URL string `toml:"url"` SHA256 string `toml:"sha256"` BundleRoot string `toml:"bundle_root"` RequiredPaths []string `toml:"required_paths"` } func LoadManifest(path string) (Manifest, error) { data, err := os.ReadFile(path) if err != nil { return Manifest{}, err } var manifest Manifest if err := toml.Unmarshal(data, &manifest); err != nil { return Manifest{}, err } manifest.BundleRoot = strings.TrimSpace(manifest.BundleRoot) manifest.URL = strings.TrimSpace(manifest.URL) manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256)) for i, required := range manifest.RequiredPaths { manifest.RequiredPaths[i] = filepath.Clean(strings.TrimSpace(required)) } sort.Strings(manifest.RequiredPaths) if len(manifest.RequiredPaths) == 0 { return Manifest{}, fmt.Errorf("runtime bundle manifest %s has no required_paths", path) } return manifest, nil } func Bootstrap(ctx context.Context, manifest Manifest, manifestPath, outDir string) error { if manifest.URL == "" { return fmt.Errorf("runtime bundle manifest %s has no url; publish a runtime bundle and update the manifest", manifestPath) } if manifest.SHA256 == "" { return fmt.Errorf("runtime bundle manifest %s has no sha256", manifestPath) } manifestDir := filepath.Dir(manifestPath) parentDir := filepath.Dir(outDir) if err := os.MkdirAll(parentDir, 0o755); err != nil { return err } workDir, err := os.MkdirTemp(parentDir, ".runtime-bundle-*") if err != nil { return err } defer os.RemoveAll(workDir) archivePath := filepath.Join(workDir, "bundle.tar.gz") if err := downloadArchive(ctx, resolveSource(manifestDir, manifest.URL), archivePath); err != nil { return err } sum, err := fileSHA256(archivePath) if err != nil { return err } if sum != manifest.SHA256 { return fmt.Errorf("runtime bundle checksum mismatch: got %s want %s", sum, manifest.SHA256) } extractDir := filepath.Join(workDir, "extract") if err := extractTarGz(archivePath, extractDir); err != nil { return err } bundleDir := extractDir if manifest.BundleRoot != "" { bundleDir = filepath.Join(extractDir, manifest.BundleRoot) } if err := ValidateBundle(bundleDir, manifest.RequiredPaths); err != nil { return err } stageDir := filepath.Join(workDir, "stage") if err := os.Rename(bundleDir, stageDir); err != nil { return err } if err := os.RemoveAll(outDir); err != nil { return err } if err := os.Rename(stageDir, outDir); err != nil { return err } return nil } func ValidateBundle(bundleDir string, requiredPaths []string) error { for _, rel := range requiredPaths { if rel == "." || strings.HasPrefix(rel, "..") { return fmt.Errorf("invalid required bundle path: %s", rel) } if _, err := os.Stat(filepath.Join(bundleDir, rel)); err != nil { return fmt.Errorf("runtime bundle missing %s", rel) } } return nil } func Package(runtimeDir, outArchive string, manifest Manifest) (string, error) { if err := ValidateBundle(runtimeDir, manifest.RequiredPaths); err != nil { return "", err } if err := os.MkdirAll(filepath.Dir(outArchive), 0o755); err != nil { return "", err } file, err := os.Create(outArchive) if err != nil { return "", err } defer file.Close() hash := sha256.New() multi := io.MultiWriter(file, hash) gz := gzip.NewWriter(multi) defer gz.Close() tw := tar.NewWriter(gz) defer tw.Close() for _, rel := range manifest.RequiredPaths { if err := addPathToArchive(tw, runtimeDir, manifest.BundleRoot, rel); err != nil { return "", err } } if err := tw.Close(); err != nil { return "", err } if err := gz.Close(); err != nil { return "", err } return hex.EncodeToString(hash.Sum(nil)), nil } func addPathToArchive(tw *tar.Writer, runtimeDir, bundleRoot, rel string) error { srcPath := filepath.Join(runtimeDir, rel) info, err := os.Lstat(srcPath) if err != nil { return err } archiveName := rel if bundleRoot != "" { archiveName = filepath.Join(bundleRoot, rel) } if info.IsDir() { header, err := tar.FileInfoHeader(info, "") if err != nil { return err } header.Name = filepath.ToSlash(archiveName) + "/" if err := tw.WriteHeader(header); err != nil { return err } entries, err := os.ReadDir(srcPath) if err != nil { return err } for _, entry := range entries { childRel := filepath.Join(rel, entry.Name()) if err := addPathToArchive(tw, runtimeDir, bundleRoot, childRel); err != nil { return err } } return nil } header, err := tar.FileInfoHeader(info, "") if err != nil { return err } header.Name = filepath.ToSlash(archiveName) if err := tw.WriteHeader(header); err != nil { return err } file, err := os.Open(srcPath) if err != nil { return err } defer file.Close() _, err = io.Copy(tw, file) return err } func resolveSource(manifestDir, source string) string { parsed, err := url.Parse(source) if err == nil && parsed.Scheme != "" { return source } if filepath.IsAbs(source) { return source } return filepath.Join(manifestDir, source) } func downloadArchive(ctx context.Context, source, dst string) error { switch { case strings.HasPrefix(source, "http://"), strings.HasPrefix(source, "https://"): req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download runtime bundle: %s", resp.Status) } return writeFileFromReader(dst, resp.Body) case strings.HasPrefix(source, "file://"): parsed, err := url.Parse(source) if err != nil { return err } return copyFile(parsed.Path, dst) default: return copyFile(source, dst) } } func writeFileFromReader(dst string, reader io.Reader) error { file, err := os.Create(dst) if err != nil { return err } defer file.Close() _, err = io.Copy(file, reader) return err } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() return writeFileFromReader(dst, in) } func extractTarGz(archivePath, outDir string) error { if err := os.MkdirAll(outDir, 0o755); err != nil { return err } file, err := os.Open(archivePath) if err != nil { return err } defer file.Close() gz, err := gzip.NewReader(file) if err != nil { return err } defer gz.Close() tr := tar.NewReader(gz) for { header, err := tr.Next() if err == io.EOF { return nil } if err != nil { return err } name := filepath.Clean(header.Name) if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) { return fmt.Errorf("invalid archive entry: %s", header.Name) } target := filepath.Join(outDir, name) switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { return err } case tar.TypeReg, tar.TypeRegA: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } file, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) if err != nil { return err } if _, err := io.Copy(file, tr); err != nil { file.Close() return err } if err := file.Close(); err != nil { return err } default: return fmt.Errorf("unsupported archive entry type: %s", header.Name) } } } func fileSHA256(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "", err } return hex.EncodeToString(hash.Sum(nil)), nil }