templates.go

  1// Package templates provides embedded project templates for shelley.
  2package templates
  3
  4import (
  5	"archive/tar"
  6	"compress/gzip"
  7	"embed"
  8	"fmt"
  9	"io"
 10	"os"
 11	"path/filepath"
 12	"strings"
 13)
 14
 15//go:embed *.tar.gz
 16var FS embed.FS
 17
 18// List returns the names of all available templates.
 19func List() ([]string, error) {
 20	entries, err := FS.ReadDir(".")
 21	if err != nil {
 22		return nil, fmt.Errorf("read templates dir: %w", err)
 23	}
 24	var names []string
 25	for _, e := range entries {
 26		if e.IsDir() {
 27			continue
 28		}
 29		name := e.Name()
 30		if strings.HasSuffix(name, ".tar.gz") {
 31			names = append(names, strings.TrimSuffix(name, ".tar.gz"))
 32		}
 33	}
 34	return names, nil
 35}
 36
 37// Unpack extracts the named template to the given directory.
 38// The directory must exist and should be empty.
 39func Unpack(templateName, destDir string) error {
 40	tarPath := templateName + ".tar.gz"
 41	f, err := FS.Open(tarPath)
 42	if err != nil {
 43		return fmt.Errorf("open template %q: %w", templateName, err)
 44	}
 45	defer f.Close()
 46
 47	gz, err := gzip.NewReader(f)
 48	if err != nil {
 49		return fmt.Errorf("gzip reader: %w", err)
 50	}
 51	defer gz.Close()
 52
 53	tr := tar.NewReader(gz)
 54	for {
 55		hdr, err := tr.Next()
 56		if err == io.EOF {
 57			break
 58		}
 59		if err != nil {
 60			return fmt.Errorf("read tar: %w", err)
 61		}
 62
 63		// Sanitize path to prevent directory traversal
 64		cleanName := filepath.Clean(hdr.Name)
 65		if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
 66			return fmt.Errorf("invalid path in archive: %s", hdr.Name)
 67		}
 68
 69		target := filepath.Join(destDir, cleanName)
 70
 71		switch hdr.Typeflag {
 72		case tar.TypeDir:
 73			if err := os.MkdirAll(target, 0o755); err != nil {
 74				return fmt.Errorf("mkdir %s: %w", target, err)
 75			}
 76		case tar.TypeReg:
 77			// Ensure parent directory exists
 78			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
 79				return fmt.Errorf("mkdir for %s: %w", target, err)
 80			}
 81			// Create the file
 82			mode := os.FileMode(hdr.Mode)
 83			if mode == 0 {
 84				mode = 0o644
 85			}
 86			out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
 87			if err != nil {
 88				return fmt.Errorf("create %s: %w", target, err)
 89			}
 90			if _, err := io.Copy(out, tr); err != nil {
 91				out.Close()
 92				return fmt.Errorf("write %s: %w", target, err)
 93			}
 94			out.Close()
 95		case tar.TypeSymlink:
 96			// Validate symlink target
 97			linkTarget := hdr.Linkname
 98			if filepath.IsAbs(linkTarget) {
 99				return fmt.Errorf("absolute symlink not allowed: %s -> %s", hdr.Name, linkTarget)
100			}
101			// Ensure parent directory exists
102			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
103				return fmt.Errorf("mkdir for symlink %s: %w", target, err)
104			}
105			if err := os.Symlink(linkTarget, target); err != nil {
106				return fmt.Errorf("symlink %s: %w", target, err)
107			}
108		}
109	}
110	return nil
111}