Download
166 lines 4 KiBGoModified 2026-10-18 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
package render

import (
	"bytes"
	"compress/gzip"
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"sync"
)

// tempFile is written beside its destination and renamed over it, so a
// reader never sees it half written.
type tempFile struct {
	*os.File
	dst  string
	done bool
}

// createTemp starts a tempFile for dst, creating parent directories as
// needed.
func createTemp(dst string) (*tempFile, error) {
	dir := filepath.Dir(dst)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, err
	}
	f, err := os.CreateTemp(dir, "."+filepath.Base(dst)+".tmp-*")
	if err != nil {
		return nil, err
	}
	return &tempFile{File: f, dst: dst}, nil
}

// finish makes the file mode 0644 (CreateTemp's is 0600) and closes it,
// ready for commit.
func (t *tempFile) finish() error {
	if err := t.Chmod(0o644); err != nil {
		return err
	}
	return t.Close()
}

// commit moves the finished file into place.
func (t *tempFile) commit() error {
	if err := os.Rename(t.Name(), t.dst); err != nil {
		return err
	}
	t.done = true
	return nil
}

// discard closes and removes the file, unless commit has moved it into
// place.
func (t *tempFile) discard() {
	if !t.done {
		_ = t.Close()
		_ = os.Remove(t.Name())
	}
}

// writeFileAtomic writes data to path, mode 0644, via a temp file +
// rename, creating parent directories as needed.
func writeFileAtomic(path string, data []byte) error {
	t, err := createTemp(path)
	if err != nil {
		return err
	}
	defer t.discard()
	if _, err := t.Write(data); err != nil {
		return err
	}
	if err := t.finish(); err != nil {
		return err
	}
	return t.commit()
}

// gzipWriters recycles gzip writers: each holds most of a megabyte of
// compressor state, and a render compresses every page it writes.
var gzipWriters = sync.Pool{New: func() any { return gzip.NewWriter(nil) }}

// gzipBytes compresses data with the default level.
func gzipBytes(data []byte) ([]byte, error) {
	var buf bytes.Buffer
	buf.Grow(len(data)/4 + 64)
	zw := gzipWriters.Get().(*gzip.Writer)
	defer gzipWriters.Put(zw)
	zw.Reset(&buf)
	if _, err := zw.Write(data); err != nil {
		return nil, err
	}
	if err := zw.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

// writePair writes path and, when gz is set, path.gz. Order matters for
// httpd's gzip-static: unlink the old .gz first so a reader never gets a
// stale compressed copy next to a fresh uncompressed one.
func writePair(path string, data []byte, gz bool) error {
	if err := os.Remove(path + ".gz"); err != nil && !errors.Is(err, fs.ErrNotExist) {
		return err
	}
	if err := writeFileAtomic(path, data); err != nil {
		return err
	}
	if !gz {
		return nil
	}
	z, err := gzipBytes(data)
	if err != nil {
		return err
	}
	return writeFileAtomic(path+".gz", z)
}

// removeOutputs deletes each path and its .gz twin, ignoring missing files.
func removeOutputs(paths ...string) error {
	for _, p := range paths {
		for _, q := range []string{p, p + ".gz"} {
			if err := os.Remove(q); err != nil && !errors.Is(err, fs.ErrNotExist) {
				return err
			}
		}
	}
	return nil
}

// listDir returns the paths of dir's entries whose names match, in name
// order; a missing dir has none. Unlike filepath.Glob, it never reads dir
// itself as a pattern, so glob characters in a configured path are safe.
func listDir(dir string, match func(name string) bool) ([]string, error) {
	ents, err := os.ReadDir(dir)
	if errors.Is(err, fs.ErrNotExist) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	var paths []string
	for _, e := range ents {
		if match(e.Name()) {
			paths = append(paths, filepath.Join(dir, e.Name()))
		}
	}
	return paths, nil
}

// pruneEmptyDirs removes dir and its ancestors while they are empty, never
// going above stop.
func pruneEmptyDirs(dir, stop string) {
	stop = filepath.Clean(stop)
	for {
		dir = filepath.Clean(dir)
		if dir == stop || !strings.HasPrefix(dir, stop+string(filepath.Separator)) {
			return
		}
		if err := os.Remove(dir); err != nil {
			return // not empty or gone
		}
		dir = filepath.Dir(dir)
	}
}