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}