188 lines
5.1 KiB
Go
188 lines
5.1 KiB
Go
package bundle
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
func WriteBundle(opts WriteBundleOptions) (Manifest, error) {
|
|
root, err := cleanBundleRoot(opts.Root)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
if opts.ID == "" {
|
|
return Manifest{}, fmt.Errorf("id is required")
|
|
}
|
|
if len(opts.Files) == 0 {
|
|
return Manifest{}, fmt.Errorf("files is required")
|
|
}
|
|
|
|
parent := filepath.Dir(root)
|
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
|
return Manifest{}, fmt.Errorf("create bundle parent: %w", err)
|
|
}
|
|
if !opts.Overwrite {
|
|
if _, err := os.Lstat(root); err == nil {
|
|
return Manifest{}, fmt.Errorf("bundle root %q already exists", root)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return Manifest{}, fmt.Errorf("stat bundle root: %w", err)
|
|
}
|
|
}
|
|
|
|
tempRoot, err := os.MkdirTemp(parent, "."+filepath.Base(root)+"-*.tmp")
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("create bundle temp root: %w", err)
|
|
}
|
|
removeTemp := true
|
|
defer func() {
|
|
if removeTemp {
|
|
_ = os.RemoveAll(tempRoot)
|
|
}
|
|
}()
|
|
|
|
paths, err := copyBundleFiles(tempRoot, opts.Files)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
manifest, err := BuildManifest(BuildOptions{
|
|
Root: tempRoot,
|
|
ID: opts.ID,
|
|
Created: opts.Created,
|
|
Files: paths,
|
|
})
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
if err := WriteManifest(tempRoot, manifest, WriteManifestOptions{}); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
if err := ValidateBundle(tempRoot, manifest); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
|
|
if err := promoteBundleRoot(tempRoot, root, opts.Overwrite); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
removeTemp = false
|
|
return manifest, nil
|
|
}
|
|
|
|
func cleanBundleRoot(root string) (string, error) {
|
|
if root == "" {
|
|
return "", fmt.Errorf("root is required")
|
|
}
|
|
return filepath.Clean(root), nil
|
|
}
|
|
|
|
func copyBundleFiles(root string, files []BundleFile) ([]string, error) {
|
|
paths := make([]string, 0, len(files))
|
|
seen := make(map[string]struct{}, len(files))
|
|
for index, file := range files {
|
|
if file.SourcePath == "" {
|
|
return nil, fmt.Errorf("files[%d].source_path is required", index)
|
|
}
|
|
if err := ValidateSourcePath(file.Path); err != nil {
|
|
return nil, fmt.Errorf("files[%d].path: %w", index, err)
|
|
}
|
|
if _, exists := seen[file.Path]; exists {
|
|
return nil, fmt.Errorf("files[%d].path duplicates %q", index, file.Path)
|
|
}
|
|
seen[file.Path] = struct{}{}
|
|
if err := copyBundleFile(root, file); err != nil {
|
|
return nil, fmt.Errorf("files[%d]: %w", index, err)
|
|
}
|
|
paths = append(paths, file.Path)
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func copyBundleFile(root string, file BundleFile) error {
|
|
info, err := os.Lstat(file.SourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("stat source %q: %w", file.SourcePath, err)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return fmt.Errorf("source %q must be a regular file", file.SourcePath)
|
|
}
|
|
destination := filepath.Join(root, filepath.FromSlash(file.Path))
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
|
return fmt.Errorf("create destination directory: %w", err)
|
|
}
|
|
|
|
source, err := os.Open(file.SourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("open source %q: %w", file.SourcePath, err)
|
|
}
|
|
defer source.Close()
|
|
|
|
mode := info.Mode().Perm()
|
|
if mode == 0 {
|
|
mode = 0o600
|
|
}
|
|
target, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
return fmt.Errorf("create destination %q: %w", file.Path, err)
|
|
}
|
|
if _, err := io.Copy(target, source); err != nil {
|
|
_ = target.Close()
|
|
return fmt.Errorf("copy to destination %q: %w", file.Path, err)
|
|
}
|
|
if err := target.Close(); err != nil {
|
|
return fmt.Errorf("close destination %q: %w", file.Path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func promoteBundleRoot(tempRoot, root string, overwrite bool) error {
|
|
if !overwrite {
|
|
if _, err := os.Lstat(root); err == nil {
|
|
return fmt.Errorf("bundle root %q already exists", root)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("stat bundle root: %w", err)
|
|
}
|
|
if err := os.Rename(tempRoot, root); err != nil {
|
|
return fmt.Errorf("promote bundle root: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
|
|
if err := os.Rename(tempRoot, root); err != nil {
|
|
return fmt.Errorf("promote bundle root: %w", err)
|
|
}
|
|
return nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("stat bundle root: %w", err)
|
|
}
|
|
|
|
backupRoot, err := reserveSiblingPath(filepath.Dir(root), "."+filepath.Base(root)+"-backup-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(root, backupRoot); err != nil {
|
|
return fmt.Errorf("move existing bundle root: %w", err)
|
|
}
|
|
if err := os.Rename(tempRoot, root); err != nil {
|
|
restoreErr := os.Rename(backupRoot, root)
|
|
if restoreErr != nil {
|
|
return fmt.Errorf("promote bundle root: %w; restore existing bundle root: %v", err, restoreErr)
|
|
}
|
|
return fmt.Errorf("promote bundle root: %w", err)
|
|
}
|
|
_ = os.RemoveAll(backupRoot)
|
|
return nil
|
|
}
|
|
|
|
func reserveSiblingPath(parent, pattern string) (string, error) {
|
|
path, err := os.MkdirTemp(parent, pattern)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reserve backup path: %w", err)
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return "", fmt.Errorf("reserve backup path: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|