check / check (push) Failing after 2s
age encrypt -o and age decrypt -o always renamed a new file over the named path, which replaced a symlink, a named pipe or a device such as /dev/null with a regular file and made -o /dev/stdout fail. The path is now looked at without following a final symlink: a missing path or a regular file is replaced by rename as before, a symlink gets the same treatment for what it points at, and anything else is written to directly, without catching signals. A symlink that points at nothing is refused. -o /dev/stdout and -o /dev/stderr write to the tool's own streams, so a file they are redirected to is never replaced. The README says so, and that a replaced file has mode 0600. Model: opus-5-5
376 lines
9.7 KiB
Go
376 lines
9.7 KiB
Go
// Package age groups the commands that derive age identities and
|
|
// encrypt and decrypt with them.
|
|
package age
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
|
|
"github.com/spf13/cobra"
|
|
"sneak.berlin/go/keyfunc/internal/agekey"
|
|
"sneak.berlin/go/keyfunc/internal/cli/options"
|
|
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
|
"sneak.berlin/go/keyfunc/internal/derive"
|
|
)
|
|
|
|
// ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP has been
|
|
// received by the time the work writing the file --output names ends.
|
|
var ErrInterrupted = errors.New(
|
|
"interrupted by a signal; the output file was left as it was",
|
|
)
|
|
|
|
// Command returns the age command and everything under it.
|
|
func Command() *cobra.Command {
|
|
group := &cobra.Command{
|
|
Use: "age",
|
|
Short: "derive age identities and encrypt and decrypt with them",
|
|
}
|
|
|
|
group.AddCommand(public(), private(), encrypt(), decrypt())
|
|
|
|
return group
|
|
}
|
|
|
|
// public returns the command that prints the recipient.
|
|
func public() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "pub",
|
|
Short: "print the recipient, the age1... public key",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
key, err := derived(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return write(cmd, key.Recipient())
|
|
},
|
|
}
|
|
}
|
|
|
|
// private returns the command that prints the identity.
|
|
func private() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "priv",
|
|
Short: "print the identity, the AGE-SECRET-KEY-1... line",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
key, err := derived(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return write(cmd, key.Identity())
|
|
},
|
|
}
|
|
}
|
|
|
|
// encrypt returns the command that encrypts a file or standard input.
|
|
func encrypt() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "encrypt [file]",
|
|
Short: "encrypt to the derived recipient and any others given",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runEncrypt,
|
|
}
|
|
|
|
cmd.Flags().StringArray(
|
|
"to", nil,
|
|
"another recipient to encrypt to, as well as the derived one",
|
|
)
|
|
cmd.Flags().Bool(
|
|
"armor", false,
|
|
"write the text form instead of the binary one",
|
|
)
|
|
addOutput(cmd)
|
|
|
|
return cmd
|
|
}
|
|
|
|
// decrypt returns the command that decrypts a file or standard input.
|
|
func decrypt() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "decrypt [file]",
|
|
Short: "decrypt with the derived identity",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runDecrypt,
|
|
}
|
|
|
|
addOutput(cmd)
|
|
|
|
return cmd
|
|
}
|
|
|
|
// runEncrypt encrypts to the derived recipient and any others given.
|
|
func runEncrypt(cmd *cobra.Command, args []string) error {
|
|
key, err := derived(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
to, err := cmd.Flags().GetStringArray("to")
|
|
if err != nil {
|
|
return fmt.Errorf("reading the recipients: %w", err)
|
|
}
|
|
|
|
armored, err := cmd.Flags().GetBool("armor")
|
|
if err != nil {
|
|
return fmt.Errorf("reading the armor flag: %w", err)
|
|
}
|
|
|
|
return through(cmd, args, func(dst io.Writer, src io.Reader) error {
|
|
return key.Encrypt(dst, src, to, armored)
|
|
})
|
|
}
|
|
|
|
// runDecrypt decrypts with the derived identity.
|
|
func runDecrypt(cmd *cobra.Command, args []string) error {
|
|
key, err := derived(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return through(cmd, args, key.Decrypt)
|
|
}
|
|
|
|
// through opens the input the arguments ask for and hands it to the
|
|
// work, with the file --output names to write to, or the command's own
|
|
// output when it names none or names /dev/stdout, and the command's own
|
|
// error output when it names /dev/stderr. Those two are the streams the
|
|
// tool already has, so whatever they are redirected to is written as
|
|
// the redirect says, never replaced.
|
|
func through(
|
|
cmd *cobra.Command, args []string,
|
|
work func(io.Writer, io.Reader) error,
|
|
) error {
|
|
src, closeSrc, err := input(cmd, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer closeSrc()
|
|
|
|
name, err := cmd.Flags().GetString("output")
|
|
if err != nil {
|
|
return fmt.Errorf("reading the output file: %w", err)
|
|
}
|
|
|
|
switch name {
|
|
case "", "/dev/stdout":
|
|
return work(cmd.OutOrStdout(), src)
|
|
case "/dev/stderr":
|
|
return work(cmd.ErrOrStderr(), src)
|
|
default:
|
|
return output(name, src, work)
|
|
}
|
|
}
|
|
|
|
// input returns what to read from: the named file, or the command's
|
|
// own input when no file is named. The second result closes a file
|
|
// that was opened and does nothing otherwise.
|
|
func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
|
|
if len(args) == 0 {
|
|
return cmd.InOrStdin(), func() {}, nil
|
|
}
|
|
|
|
file, err := os.Open(args[0])
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("opening %s: %w", args[0], err)
|
|
}
|
|
|
|
return file, func() { _ = file.Close() }, nil
|
|
}
|
|
|
|
// output has the work write to the named path, going by what is there
|
|
// without following a final symlink:
|
|
//
|
|
// - nothing, or a regular file: replace writes a new file beside it
|
|
// and renames that over it;
|
|
// - a symlink: the same for what it points at, so that the link keeps
|
|
// pointing where it did; one that points at nothing is refused;
|
|
// - anything else, such as a named pipe or a device like /dev/null:
|
|
// direct writes to it, since a rename would put a regular file in
|
|
// its place.
|
|
func output(
|
|
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
|
) error {
|
|
info, err := os.Lstat(name)
|
|
|
|
switch {
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
return replace(name, src, work)
|
|
case err != nil:
|
|
return fmt.Errorf("looking at %s: %w", name, err)
|
|
case info.Mode().IsRegular():
|
|
return replace(name, src, work)
|
|
case info.Mode().Type() == fs.ModeSymlink:
|
|
// os.Stat follows the link as opening it would. /dev/fd/1
|
|
// needs that: it reaches a pipe or a terminal through a link
|
|
// that names no path.
|
|
info, err = os.Stat(name)
|
|
if err != nil {
|
|
return fmt.Errorf("following %s: %w", name, err)
|
|
}
|
|
|
|
if !info.Mode().IsRegular() {
|
|
return direct(name, src, work)
|
|
}
|
|
|
|
target, err := filepath.EvalSymlinks(name)
|
|
if err != nil {
|
|
return fmt.Errorf("following %s: %w", name, err)
|
|
}
|
|
|
|
return replace(target, src, work)
|
|
default:
|
|
return direct(name, src, work)
|
|
}
|
|
}
|
|
|
|
// direct has the work write straight to the named path, which is there
|
|
// and is not a regular file. No signal is caught, so one ends the tool
|
|
// as it ends any other command.
|
|
func direct(
|
|
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
|
) error {
|
|
file, err := os.OpenFile(name, os.O_WRONLY, 0) //nolint:gosec // the -o path
|
|
if err != nil {
|
|
return fmt.Errorf("opening %s: %w", name, err)
|
|
}
|
|
|
|
failed := work(file, src)
|
|
closeErr := file.Close()
|
|
|
|
if failed != nil {
|
|
return failed
|
|
}
|
|
|
|
if closeErr != nil {
|
|
return fmt.Errorf("finishing %s: %w", name, closeErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// replace has the work write a new file beside the named one, and puts
|
|
// the new file in the named file's place only when the work succeeded,
|
|
// so a file that is already there survives a run that failed.
|
|
//
|
|
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context
|
|
// does. One the tool has received by the time the work ends wins: the
|
|
// new file is removed and ErrInterrupted returned, at once if the work
|
|
// is still running, without waiting for it, since it may be blocked
|
|
// reading its input.
|
|
func replace(
|
|
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
|
) error {
|
|
// received is registered before the context, so it gets every
|
|
// signal the context gets.
|
|
received := make(chan os.Signal, 1)
|
|
signals.Notify(received)
|
|
|
|
defer signal.Stop(received)
|
|
|
|
// The context goes on catching the signals until the file is in
|
|
// place or removed, so that a later one cannot end the tool with
|
|
// the new file left beside the named one.
|
|
interrupted, stop := signals.Context(context.Background())
|
|
defer stop()
|
|
|
|
// The file is made in the same directory so that putting it in
|
|
// place is a rename and never a copy, and it is readable only by
|
|
// its owner, which is the mode it keeps once renamed.
|
|
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
|
|
if err != nil {
|
|
return fmt.Errorf("creating a file beside %s: %w", name, err)
|
|
}
|
|
|
|
worked := make(chan error, 1)
|
|
|
|
go func() { worked <- work(file, src) }()
|
|
|
|
select {
|
|
case failed := <-worked:
|
|
// Stop returns only once every signal the tool has received
|
|
// has been handed over, so an empty received means none came.
|
|
signal.Stop(received)
|
|
|
|
if len(received) == 0 {
|
|
return finish(file, name, failed)
|
|
}
|
|
case <-interrupted.Done():
|
|
}
|
|
|
|
return finish(file, name, ErrInterrupted)
|
|
}
|
|
|
|
// finish closes the new file and puts it in the named file's place, or
|
|
// throws it away when the work failed. It returns the error the caller
|
|
// should report.
|
|
func finish(file *os.File, name string, failed error) error {
|
|
closeErr := file.Close()
|
|
|
|
if failed != nil || closeErr != nil {
|
|
_ = os.Remove(file.Name())
|
|
|
|
if failed != nil {
|
|
return failed
|
|
}
|
|
|
|
return fmt.Errorf("finishing %s: %w", name, closeErr)
|
|
}
|
|
|
|
err := os.Rename(file.Name(), name)
|
|
if err != nil {
|
|
_ = os.Remove(file.Name())
|
|
|
|
return fmt.Errorf("putting %s in place: %w", name, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addOutput gives a command its output file flag.
|
|
func addOutput(cmd *cobra.Command) {
|
|
cmd.Flags().StringP(
|
|
"output", "o", "",
|
|
"write to this file instead of standard output",
|
|
)
|
|
}
|
|
|
|
// write sends one line to wherever the command's output goes.
|
|
func write(cmd *cobra.Command, line string) error {
|
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), line)
|
|
if err != nil {
|
|
return fmt.Errorf("writing the key: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// derived returns the age key for this run.
|
|
func derived(cmd *cobra.Command) (*agekey.Key, error) {
|
|
index, err := options.Index(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
words, err := options.Mnemonic(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
material, err := derive.Bytes(words, agekey.Application, index)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return agekey.New(material)
|
|
}
|