Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions genapp/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,35 @@
// [WithSkipFormatFunc] to decide differently, or [WithSkipFormat] to write every target unformatted,
// which is worth doing when a template is misbehaving and the parse error hides the output.
//
// # Writing outside the output path
//
// A generator names its targets after the models and operations of a spec, so the spec decides what
// [GoGenApp.RenderFile] writes. RenderFile therefore refuses an absolute target, and one that climbs
// out of the output path with "..", whether or not the caller asks to be confined.
//
// Writes go through [os.Root], which checks each symbolic link as it walks the path. RenderFile
// removes a link standing at the target instead of following it, so the file it pointed at keeps its
// content, and refuses a link on the way to the target. Renaming replaces a name rather than the
// file behind it, so another hard link to the target keeps its own content. A directory, a device,
// a socket or a named pipe at the target is refused rather than overwritten.
//
// [WithRoot] widens the boundary from the output path to a directory above it, for a generator
// writing into several directories of one tree:
//
// app, err := genapp.New(
// genapp.WithTemplates(templates),
// genapp.WithOutputPath("./gen/models"),
// genapp.WithRoot("./gen"),
// )
//
// Two things sit outside this. [GoGenApp.TidyModule] runs the go command, which writes go.mod and
// go.sum itself, and no root reaches into another process. Reads run unconfined too:
// [GoGenApp.PackagePath] and [GoGenApp.EnclosingModule] walk up from the output path looking for a
// go.mod, and that go.mod usually sits above any root worth setting.
//
// [os.Root] confines path resolution and no more. It does not stop traversal of a bind mount, a
// /proc special file or a device file.
//
// # Where the code lands
//
// A generator has to write the imports that reach the code it produces, and that means knowing the
Expand Down
63 changes: 40 additions & 23 deletions genapp/genapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,38 @@ func (g *GoGenApp) Render(w io.Writer, name string, data any) error {
// reports a line and a column, and reading them means reading the source they came from; that
// source would otherwise be gone.
func (g *GoGenApp) RenderFile(target, name string, data any) error {
cleaned, err := checkedTarget(target)
if err != nil {
return err
}

rendered := shared.BorrowBuffer()
defer shared.RedeemBuffer(rendered)

if err := g.execute(rendered, name, data); err != nil {
return err
}

path := filepath.Join(g.outputPath, filepath.FromSlash(target))
if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil {
return fmt.Errorf("cannot create the directory for %q: %w: %w", target, err, ErrGenApp)
root, down, err := g.openRoot()
if err != nil {
return err
}
defer func() { _ = root.Close() }()

written := destination{
root: root,
rel: filepath.Join(down, filepath.FromSlash(cleaned)),
target: target,
displayed: filepath.Join(g.outputPath, filepath.FromSlash(cleaned)),
}

if dir := written.dir(); dir != "." {
if err := root.MkdirAll(dir, dirPerm); err != nil {
return fmt.Errorf("cannot create the directory for %q: %w: %w", target, err, ErrGenApp)
}
}

return g.writeFile(path, target, name, rendered)
return g.writeFile(written, name, rendered)
}

// execute renders one template into the buffer it is given.
Expand Down Expand Up @@ -147,8 +166,8 @@ const unformattedSuffix = ".unformatted"
//
// The returned error carries the formatting failure and adds the path. It attaches no second
// [ErrGenApp]: the cause already carries one.
func dumpUnformatted(file *os.File, path string, rendered *bytes.Buffer, cause error) error {
dumped := path + unformattedSuffix
func dumpUnformatted(d destination, file *os.File, temporary string, rendered *bytes.Buffer, cause error) error {
dumped := d.sibling(unformattedSuffix)

written := func() error {
if err := file.Truncate(0); err != nil {
Expand All @@ -171,14 +190,16 @@ func dumpUnformatted(file *os.File, path string, rendered *bytes.Buffer, cause e
return err
}

return os.Rename(file.Name(), dumped)
return commit(dumped, temporary)
}()

if written != nil {
return fmt.Errorf("could not keep the unformatted output at %q (%w): %w", dumped, written, cause)
return fmt.Errorf(
"could not keep the unformatted output at %q (%w): %w", dumped.displayed, written, cause,
)
}

return fmt.Errorf("the unformatted output is kept at %q: %w", dumped, cause)
return fmt.Errorf("the unformatted output is kept at %q: %w", dumped.displayed, cause)
}

// format writes the formatted render, and hands the imports report to the caller's sink.
Expand All @@ -200,10 +221,10 @@ func (g *GoGenApp) format(w io.Writer, name string, rendered *bytes.Buffer) erro
}

// writeFile writes the target through a temporary file in the same directory, then renames.
func (g *GoGenApp) writeFile(path, target, name string, rendered *bytes.Buffer) (err error) {
temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
func (g *GoGenApp) writeFile(d destination, name string, rendered *bytes.Buffer) (err error) {
temporary, temporaryRel, err := createTemp(d.root, d.dir(), filepath.Base(d.rel))
if err != nil {
return fmt.Errorf("cannot create a temporary file for %q: %w: %w", target, err, ErrGenApp)
return fmt.Errorf("cannot create a temporary file for %q: %w: %w", d.target, err, ErrGenApp)
}

keep := false
Expand All @@ -214,31 +235,27 @@ func (g *GoGenApp) writeFile(path, target, name string, rendered *bytes.Buffer)
}

_ = temporary.Close()
_ = os.Remove(temporary.Name())
_ = d.root.Remove(temporaryRel)
}()

if g.skipsFormat(target) {
if g.skipsFormat(d.target) {
if _, err = temporary.Write(rendered.Bytes()); err != nil {
return fmt.Errorf("cannot write %q: %w: %w", target, err, ErrGenApp)
return fmt.Errorf("cannot write %q: %w: %w", d.target, err, ErrGenApp)
}
} else if formatErr := g.format(temporary, name, rendered); formatErr != nil {
keep = true
err = dumpUnformatted(temporary, path, rendered, formatErr)
err = dumpUnformatted(d, temporary, temporaryRel, rendered, formatErr)

return err
}

if err = temporary.Chmod(filePerm); err != nil {
return fmt.Errorf("cannot set the mode of %q: %w: %w", target, err, ErrGenApp)
return fmt.Errorf("cannot set the mode of %q: %w: %w", d.target, err, ErrGenApp)
}

if err = temporary.Close(); err != nil {
return fmt.Errorf("cannot close %q: %w: %w", target, err, ErrGenApp)
return fmt.Errorf("cannot close %q: %w: %w", d.target, err, ErrGenApp)
}

if err = os.Rename(temporary.Name(), path); err != nil {
return fmt.Errorf("cannot write %q: %w: %w", target, err, ErrGenApp)
}

return nil
return commit(d, temporaryRel)
}
73 changes: 58 additions & 15 deletions genapp/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"

"golang.org/x/mod/modfile"
)

// goModFile is the name the go command gives a module definition.
// goModFile is "go.mod". The go command reads a module definition from a file of that name.
const goModFile = "go.mod"

// InitModule writes a go.mod in the output path, as "go mod init" would.
Expand All @@ -35,41 +34,85 @@ func (g *GoGenApp) InitModule(opts ...ModOption) error {
return err
}

path := filepath.Join(g.outputPath, goModFile)

if err := g.checkModuleAbsent(path, o); err != nil {
content, err := buildModFile(o)
if err != nil {
return err
}

content, err := buildModFile(o)
root, down, err := g.openRoot()
if err != nil {
return err
}
defer func() { _ = root.Close() }()

written := destination{
root: root,
rel: filepath.Join(down, goModFile),
target: filepath.Join(g.outputPath, goModFile),
displayed: filepath.Join(g.outputPath, goModFile),
}

if err := checkModuleAbsent(written, o); err != nil {
return err
}

if down != "." {
if err := root.MkdirAll(down, dirPerm); err != nil {
return fmt.Errorf("cannot create the module directory %q: %w: %w", g.outputPath, err, ErrGenApp)
}
}

return writeModFile(written, content)
}

if err := os.MkdirAll(g.outputPath, dirPerm); err != nil {
return fmt.Errorf("cannot create the module directory %q: %w: %w", g.outputPath, err, ErrGenApp)
// writeModFile writes a go.mod through a temporary file, then renames.
//
// It writes through a temporary file and renames, as [GoGenApp.RenderFile] does, so a go.mod
// appears whole or not at all, and a symbolic link standing at the path is removed rather than
// written through.
func writeModFile(d destination, content []byte) (err error) {
temporary, temporaryRel, err := createTemp(d.root, d.dir(), goModFile)
if err != nil {
return fmt.Errorf("cannot create a temporary file for %q: %w: %w", d.target, err, ErrGenApp)
}

if err := os.WriteFile(path, content, filePerm); err != nil {
return fmt.Errorf("cannot write %q: %w: %w", path, err, ErrGenApp)
defer func() {
if err == nil {
return
}

_ = temporary.Close()
_ = d.root.Remove(temporaryRel)
}()

if _, err = temporary.Write(content); err != nil {
return fmt.Errorf("cannot write %q: %w: %w", d.target, err, ErrGenApp)
}

return nil
if err = temporary.Close(); err != nil {
return fmt.Errorf("cannot close %q: %w: %w", d.target, err, ErrGenApp)
}

return commit(d, temporaryRel)
}

// checkModuleAbsent reports an existing go.mod, unless the caller asked to replace it.
func (g *GoGenApp) checkModuleAbsent(path string, o modOptions) error {
//
// It reads the entry with [os.Root.Lstat] rather than Stat, so a symbolic link at the path counts
// as something already there. Stat follows the link and calls a dangling one absent, and InitModule
// would then write over a link the caller never put there.
func checkModuleAbsent(d destination, o modOptions) error {
if o.replace {
return nil
}

switch _, err := os.Stat(path); {
switch _, err := d.root.Lstat(d.rel); {
case err == nil:
return fmt.Errorf("%q exists, see WithReplaceExisting: %w: %w", path, fs.ErrExist, ErrGenApp)
return fmt.Errorf("%q exists, see WithReplaceExisting: %w: %w", d.target, fs.ErrExist, ErrGenApp)
case errors.Is(err, fs.ErrNotExist):
return nil
default:
return fmt.Errorf("cannot read %q: %w: %w", path, err, ErrGenApp)
return fmt.Errorf("cannot read %q: %w: %w", d.target, err, ErrGenApp)
}
}

Expand Down
37 changes: 37 additions & 0 deletions genapp/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type (
options struct {
templates *repo.Repository
outputPath string
root string
formatOptions []formatting.Option
importsReporter func(string, *formatting.ImportsReport)
skipFormat bool
Expand Down Expand Up @@ -56,6 +57,42 @@ func WithOutputPath(path string) Option {
}
}

// WithRoot confines every file a [GoGenApp] writes to dir.
//
// The output path must sit at or below dir, and dir must exist. The caller declares the root, so
// WithRoot reports a missing one instead of creating it. Nothing outside dir is written, whether a
// target climbs out with "..", names an absolute path, or reaches a symbolic link pointing away.
// [os.Root] checks each link as it walks the path, where a prefix test on the name alone would miss
// a link halfway down.
//
// app, err := genapp.New(
// genapp.WithTemplates(templates),
// genapp.WithOutputPath("./gen/models"),
// genapp.WithRoot("./gen"),
// )
//
// Use it when a spec supplies the target names, such as its operation and model names.
//
// Without it, writes still stay under the output path and the checks on a target still run.
// WithRoot widens the boundary past the output path, for a generator writing into several
// directories of one tree.
//
// It covers this package's writes and no more. [GoGenApp.TidyModule] runs the go command, which
// writes go.mod and go.sum itself, and no root reaches into another process. Reads run unconfined
// too: [GoGenApp.PackagePath] and [GoGenApp.EnclosingModule] walk up from the output path looking
// for a go.mod, and that go.mod usually sits above any root worth setting.
//
// [os.Root] confines path resolution and no more. It does not stop traversal of a bind mount, a
// /proc special file or a device file, so point WithRoot at a directory that holds only generated
// output.
func WithRoot(dir string) Option {
return func(o options) options {
o.root = dir

return o
}
}

// WithFormatOptions configures the formatter.
//
// Grouping, gofumpt and the rest are settled by
Expand Down
Loading
Loading