package static import ( "fmt" "os" "path/filepath" "strings" "github.com/alexei/tinyforge/internal/staticsite/deno" ) // prepareDenoBuild assembles the Deno container build context: api/ // becomes the routes directory, every other file lands under public/, // and a generated router.ts + Dockerfile finishes the context. // // Ported verbatim from internal/staticsite/manager.go so the legacy and // plugin-native paths produce byte-identical containers from the same // repo content. Fall back to copy when os.Rename hits EXDEV (cross- // device) — the build dir and context dir live under the same temp // root in production but tests may straddle filesystems. func prepareDenoBuild(srcDir, contextDir string) error { apiSrc := filepath.Join(srcDir, "api") apiDst := filepath.Join(contextDir, "api") if err := os.Rename(apiSrc, apiDst); err != nil { return fmt.Errorf("move api dir: %w", err) } publicDir := filepath.Join(contextDir, "public") if err := os.Rename(srcDir, publicDir); err != nil { // EXDEV (cross-device) — fall back to copy. if err := copyDir(srcDir, publicDir); err != nil { return fmt.Errorf("copy public dir: %w", err) } } routes, err := deno.ScanRoutes(apiDst) if err != nil { return fmt.Errorf("scan routes: %w", err) } routerSrc, err := deno.GenerateRouter(routes) if err != nil { return fmt.Errorf("generate router: %w", err) } if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil { return fmt.Errorf("write router.ts: %w", err) } dockerfile := deno.GenerateDockerfile() if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { return fmt.Errorf("write Dockerfile: %w", err) } return nil } // prepareStaticBuild assembles the nginx container build context: copy // every file in srcDir into contextDir and emit the static Dockerfile. func prepareStaticBuild(srcDir, contextDir string) error { if err := copyDir(srcDir, contextDir); err != nil { return fmt.Errorf("copy files: %w", err) } dockerfile := deno.GenerateStaticDockerfile() if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { return fmt.Errorf("write Dockerfile: %w", err) } return nil } // copyDir recursively copies a directory tree, preserving file modes. // // Defense in depth against attacker-controlled provider responses: // uses Lstat (via filepath.WalkDir + d.Type()) so symlinks are // rejected outright instead of dereferenced — a hostile repo could // otherwise drop a symlink that copyDir would chase outside the // build context (or through a dangling chain at build-time). Also // rejects entries whose resolved destination escapes dst. func copyDir(src, dst string) error { cleanSrc := filepath.Clean(src) cleanDst := filepath.Clean(dst) return filepath.WalkDir(cleanSrc, func(path string, d os.DirEntry, err error) error { if err != nil { return err } // Reject anything that isn't a regular file or directory. // In particular: symlinks, devices, sockets, named pipes — // none of which belong in a static-site build context. if !d.IsDir() && !d.Type().IsRegular() { return fmt.Errorf("refusing to copy non-regular entry %s (mode %s)", path, d.Type()) } relPath, err := filepath.Rel(cleanSrc, path) if err != nil { return err } dstPath := filepath.Join(cleanDst, relPath) // Belt-and-braces: filepath.Rel + Join shouldn't ever produce // an escaping path, but if a future refactor introduces one // (e.g. allowing non-cleaned roots), surface it loudly here // rather than silently writing outside the build context. if !strings.HasPrefix(dstPath, cleanDst+string(os.PathSeparator)) && dstPath != cleanDst { return fmt.Errorf("refusing to write outside build context: %s", dstPath) } if d.IsDir() { return os.MkdirAll(dstPath, 0o755) } info, err := d.Info() if err != nil { return err } data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(dstPath, data, info.Mode()) }) } // verifyDownloadInsideRoot walks root and rejects any entry that has // resolved to a symlink or whose resolved path escapes root. Used as a // post-download guard against attacker-controlled tree responses from // the Git provider — even though the providers themselves should // never write outside their destination, this is the second line of // defense and runs before the build context copy so a malicious // download is contained. func verifyDownloadInsideRoot(root string) error { cleanRoot := filepath.Clean(root) return filepath.WalkDir(cleanRoot, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && !d.Type().IsRegular() { return fmt.Errorf("downloaded tree contains non-regular entry %s (mode %s)", path, d.Type()) } clean := filepath.Clean(path) if clean != cleanRoot && !strings.HasPrefix(clean, cleanRoot+string(os.PathSeparator)) { return fmt.Errorf("downloaded entry escapes root: %s", clean) } return nil }) }