134 lines
3.6 KiB
Go
134 lines
3.6 KiB
Go
// Package genfs shares guarded filesystem operations between project generators.
|
|
package genfs
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
func SafePath(root, name string) error {
|
|
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
|
return fmt.Errorf("generation: unsafe output path %q", name)
|
|
}
|
|
path := root
|
|
parts := strings.Split(name, "/")
|
|
for i, part := range parts {
|
|
path = filepath.Join(path, part)
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("generation: refusing symlink %s", path)
|
|
}
|
|
if i < len(parts)-1 && !info.IsDir() {
|
|
return fmt.Errorf("generation: expected directory %s", path)
|
|
}
|
|
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
|
return fmt.Errorf("generation: expected regular file %s", path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func AtomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
|
target := filepath.Join(root, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.CreateTemp(filepath.Dir(target), ".omniGen-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(f.Name())
|
|
if err = f.Chmod(mode); err == nil {
|
|
_, err = f.Write(data)
|
|
}
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
err = errors.Join(err, f.Close())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(f.Name(), target)
|
|
}
|
|
|
|
// Change records the preflight snapshot used for conflict detection and rollback.
|
|
type Change struct {
|
|
Name string
|
|
Before, After []byte
|
|
Existed, Remove bool
|
|
Mode os.FileMode
|
|
}
|
|
|
|
// Apply replaces individual files atomically and rolls back ordinary write failures.
|
|
// Callers serialize generators with a project lock. This is not crash-atomic across files.
|
|
func Apply(root string, changes []Change) error {
|
|
var applied []Change
|
|
for _, c := range changes {
|
|
err := SafePath(root, c.Name)
|
|
if err == nil {
|
|
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.Name)))
|
|
if c.Existed {
|
|
if e != nil || !bytes.Equal(current, c.Before) {
|
|
err = fmt.Errorf("file changed during generation: %s", c.Name)
|
|
}
|
|
} else if !errors.Is(e, os.ErrNotExist) {
|
|
err = fmt.Errorf("file appeared during generation: %s", c.Name)
|
|
}
|
|
}
|
|
if err == nil {
|
|
if c.Remove {
|
|
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.Name)))
|
|
} else {
|
|
err = AtomicWrite(root, c.Name, c.After, c.Mode)
|
|
}
|
|
}
|
|
if err != nil {
|
|
for i := len(applied) - 1; i >= 0; i-- {
|
|
previous := applied[i]
|
|
if e := SafePath(root, previous.Name); e != nil {
|
|
err = errors.Join(err, e)
|
|
continue
|
|
}
|
|
if previous.Existed {
|
|
err = errors.Join(err, AtomicWrite(root, previous.Name, previous.Before, previous.Mode))
|
|
} else {
|
|
err = errors.Join(err, os.Remove(filepath.Join(root, filepath.FromSlash(previous.Name))))
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
applied = append(applied, c)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Canonical resolves existing parent symlinks even for an output not yet created.
|
|
func Canonical(path string) string {
|
|
path, _ = filepath.Abs(path)
|
|
candidate := path
|
|
var tail []string
|
|
for {
|
|
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
|
for i := len(tail) - 1; i >= 0; i-- {
|
|
resolved = filepath.Join(resolved, tail[i])
|
|
}
|
|
return resolved
|
|
}
|
|
parent := filepath.Dir(candidate)
|
|
if parent == candidate {
|
|
return path
|
|
}
|
|
tail = append(tail, filepath.Base(candidate))
|
|
candidate = parent
|
|
}
|
|
}
|