termstatus: add stdin and inject into backup command

This commit is contained in:
Michael Eischer
2025-09-18 22:17:21 +02:00
parent ca5b0c0249
commit 96af35555a
14 changed files with 73 additions and 41 deletions

View File

@@ -182,7 +182,7 @@ func filterExisting(items []string, warnf func(msg string, args ...interface{}))
// If filename is empty, readPatternsFromFile returns an empty slice. // If filename is empty, readPatternsFromFile returns an empty slice.
// If filename is a dash (-), readPatternsFromFile will read the lines from the // If filename is a dash (-), readPatternsFromFile will read the lines from the
// standard input. // standard input.
func readLines(filename string) ([]string, error) { func readLines(filename string, stdin io.ReadCloser) ([]string, error) {
if filename == "" { if filename == "" {
return nil, nil return nil, nil
} }
@@ -193,7 +193,7 @@ func readLines(filename string) ([]string, error) {
) )
if filename == "-" { if filename == "-" {
data, err = io.ReadAll(os.Stdin) data, err = io.ReadAll(stdin)
} else { } else {
data, err = textfile.Read(filename) data, err = textfile.Read(filename)
} }
@@ -218,8 +218,8 @@ func readLines(filename string) ([]string, error) {
// readFilenamesFromFileRaw reads a list of filenames from the given file, // readFilenamesFromFileRaw reads a list of filenames from the given file,
// or stdin if filename is "-". Each filename is terminated by a zero byte, // or stdin if filename is "-". Each filename is terminated by a zero byte,
// which is stripped off. // which is stripped off.
func readFilenamesFromFileRaw(filename string) (names []string, err error) { func readFilenamesFromFileRaw(filename string, stdin io.ReadCloser) (names []string, err error) {
f := os.Stdin var f io.ReadCloser = stdin
if filename != "-" { if filename != "-" {
if f, err = os.Open(filename); err != nil { if f, err = os.Open(filename); err != nil {
return nil, err return nil, err
@@ -378,13 +378,13 @@ func collectRejectFuncs(opts BackupOptions, targets []string, fs fs.FS, warnf fu
} }
// collectTargets returns a list of target files/dirs from several sources. // collectTargets returns a list of target files/dirs from several sources.
func collectTargets(opts BackupOptions, args []string, warnf func(msg string, args ...interface{})) (targets []string, err error) { func collectTargets(opts BackupOptions, args []string, warnf func(msg string, args ...interface{}), stdin io.ReadCloser) (targets []string, err error) {
if opts.Stdin || opts.StdinCommand { if opts.Stdin || opts.StdinCommand {
return nil, nil return nil, nil
} }
for _, file := range opts.FilesFrom { for _, file := range opts.FilesFrom {
fromfile, err := readLines(file) fromfile, err := readLines(file, stdin)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -409,7 +409,7 @@ func collectTargets(opts BackupOptions, args []string, warnf func(msg string, ar
} }
for _, file := range opts.FilesFromVerbatim { for _, file := range opts.FilesFromVerbatim {
fromfile, err := readLines(file) fromfile, err := readLines(file, stdin)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -422,7 +422,7 @@ func collectTargets(opts BackupOptions, args []string, warnf func(msg string, ar
} }
for _, file := range opts.FilesFromRaw { for _, file := range opts.FilesFromRaw {
fromfile, err := readFilenamesFromFileRaw(file) fromfile, err := readFilenamesFromFileRaw(file, stdin)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -490,7 +490,7 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
return err return err
} }
targets, err := collectTargets(opts, args, msg.E) targets, err := collectTargets(opts, args, msg.E, term.InputRaw())
if err != nil { if err != nil {
return err return err
} }
@@ -582,7 +582,7 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
progressPrinter.V("read data from stdin") progressPrinter.V("read data from stdin")
} }
filename := path.Join("/", opts.StdinFilename) filename := path.Join("/", opts.StdinFilename)
var source io.ReadCloser = os.Stdin var source io.ReadCloser = term.InputRaw()
if opts.StdinCommand { if opts.StdinCommand {
source, err = fs.NewCommandReader(ctx, args, msg.E) source, err = fs.NewCommandReader(ctx, args, msg.E)
if err != nil { if err != nil {

View File

@@ -67,7 +67,7 @@ func TestCollectTargets(t *testing.T) {
FilesFromRaw: []string{f3.Name()}, FilesFromRaw: []string{f3.Name()},
} }
targets, err := collectTargets(opts, []string{filepath.Join(dir, "cmdline arg")}, t.Logf) targets, err := collectTargets(opts, []string{filepath.Join(dir, "cmdline arg")}, t.Logf, nil)
rtest.OK(t, err) rtest.OK(t, err)
sort.Strings(targets) sort.Strings(targets)
rtest.Equals(t, expect, targets) rtest.Equals(t, expect, targets)

View File

@@ -18,7 +18,7 @@ func testRunInit(t testing.TB, opts GlobalOptions) {
restic.TestSetLockTimeout(t, 0) restic.TestSetLockTimeout(t, 0)
err := withTermStatus(opts, func(ctx context.Context, gopts GlobalOptions) error { err := withTermStatus(opts, func(ctx context.Context, gopts GlobalOptions) error {
return runInit(ctx, InitOptions{}, opts, nil, gopts.term) return runInit(ctx, InitOptions{}, gopts, nil, gopts.term)
}) })
rtest.OK(t, err) rtest.OK(t, err)
t.Logf("repository initialized at %v", opts.Repo) t.Logf("repository initialized at %v", opts.Repo)
@@ -54,10 +54,18 @@ func TestInitCopyChunkerParams(t *testing.T) {
}) })
rtest.OK(t, err) rtest.OK(t, err)
repo, err := OpenRepository(context.TODO(), env.gopts, &progress.NoopPrinter{}) var repo *repository.Repository
err = withTermStatus(env.gopts, func(ctx context.Context, gopts GlobalOptions) error {
repo, err = OpenRepository(ctx, gopts, &progress.NoopPrinter{})
return err
})
rtest.OK(t, err) rtest.OK(t, err)
otherRepo, err := OpenRepository(context.TODO(), env2.gopts, &progress.NoopPrinter{}) var otherRepo *repository.Repository
err = withTermStatus(env2.gopts, func(ctx context.Context, gopts GlobalOptions) error {
otherRepo, err = OpenRepository(ctx, gopts, &progress.NoopPrinter{})
return err
})
rtest.OK(t, err) rtest.OK(t, err)
rtest.Assert(t, repo.Config().ChunkerPolynomial == otherRepo.Config().ChunkerPolynomial, rtest.Assert(t, repo.Config().ChunkerPolynomial == otherRepo.Config().ChunkerPolynomial,

View File

@@ -63,13 +63,16 @@ func testRunKeyAddNewKeyUserHost(t testing.TB, gopts GlobalOptions) {
}) })
rtest.OK(t, err) rtest.OK(t, err)
repo, err := OpenRepository(context.TODO(), gopts, &progress.NoopPrinter{}) _ = withTermStatus(gopts, func(ctx context.Context, gopts GlobalOptions) error {
repo, err := OpenRepository(ctx, gopts, &progress.NoopPrinter{})
rtest.OK(t, err) rtest.OK(t, err)
key, err := repository.SearchKey(context.TODO(), repo, testKeyNewPassword, 2, "") key, err := repository.SearchKey(ctx, repo, testKeyNewPassword, 2, "")
rtest.OK(t, err) rtest.OK(t, err)
rtest.Equals(t, "john", key.Username) rtest.Equals(t, "john", key.Username)
rtest.Equals(t, "example.com", key.Hostname) rtest.Equals(t, "example.com", key.Hostname)
return nil
})
} }
func testRunKeyPasswd(t testing.TB, newPassword string, gopts GlobalOptions) { func testRunKeyPasswd(t testing.TB, newPassword string, gopts GlobalOptions) {

View File

@@ -13,7 +13,7 @@ import (
func testRunList(t testing.TB, opts GlobalOptions, tpe string) restic.IDs { func testRunList(t testing.TB, opts GlobalOptions, tpe string) restic.IDs {
buf, err := withCaptureStdout(opts, func(opts GlobalOptions) error { buf, err := withCaptureStdout(opts, func(opts GlobalOptions) error {
return withTermStatus(opts, func(ctx context.Context, gopts GlobalOptions) error { return withTermStatus(opts, func(ctx context.Context, gopts GlobalOptions) error {
return runList(ctx, opts, []string{tpe}, gopts.term) return runList(ctx, gopts, []string{tpe}, gopts.term)
}) })
}) })
rtest.OK(t, err) rtest.OK(t, err)

View File

@@ -260,7 +260,7 @@ func ReadPassword(ctx context.Context, opts GlobalOptions, prompt string, printe
err error err error
) )
if terminal.StdinIsTerminal() { if opts.term.InputIsTerminal() {
password, err = terminal.ReadPassword(ctx, os.Stdin, os.Stderr, prompt) password, err = terminal.ReadPassword(ctx, os.Stdin, os.Stderr, prompt)
} else { } else {
printer.PT("reading repository password from stdin") printer.PT("reading repository password from stdin")
@@ -286,7 +286,7 @@ func ReadPasswordTwice(ctx context.Context, gopts GlobalOptions, prompt1, prompt
if err != nil { if err != nil {
return "", err return "", err
} }
if terminal.StdinIsTerminal() { if gopts.term.InputIsTerminal() {
pw2, err := ReadPassword(ctx, gopts, prompt2, printer) pw2, err := ReadPassword(ctx, gopts, prompt2, printer)
if err != nil { if err != nil {
return "", err return "", err
@@ -349,7 +349,7 @@ func OpenRepository(ctx context.Context, opts GlobalOptions, printer progress.Pr
} }
passwordTriesLeft := 1 passwordTriesLeft := 1
if terminal.StdinIsTerminal() && opts.password == "" && !opts.InsecureNoPassword { if opts.term.InputIsTerminal() && opts.password == "" && !opts.InsecureNoPassword {
passwordTriesLeft = 3 passwordTriesLeft = 3
} }

View File

@@ -427,7 +427,7 @@ func withTermStatus(gopts GlobalOptions, callback func(ctx context.Context, gopt
ctx, cancel := context.WithCancel(context.TODO()) ctx, cancel := context.WithCancel(context.TODO())
var wg sync.WaitGroup var wg sync.WaitGroup
term := termstatus.New(gopts.stdout, gopts.stderr, gopts.Quiet) term := termstatus.New(os.Stdin, gopts.stdout, gopts.stderr, gopts.Quiet)
gopts.term = term gopts.term = term
wg.Add(1) wg.Add(1)
go func() { go func() {

View File

@@ -178,7 +178,7 @@ func main() {
backends: collectBackends(), backends: collectBackends(),
} }
func() { func() {
term, cancel := termstatus.Setup(os.Stdout, os.Stderr, globalOptions.Quiet) term, cancel := termstatus.Setup(os.Stdin, os.Stdout, os.Stderr, globalOptions.Quiet)
defer cancel() defer cancel()
globalOptions.stdout, globalOptions.stderr = termstatus.WrapStdio(term) globalOptions.stdout, globalOptions.stderr = termstatus.WrapStdio(term)
globalOptions.term = term globalOptions.term = term

View File

@@ -131,12 +131,6 @@ func TestFillSecondaryGlobalOpts(t *testing.T) {
PasswordCommand: "notEmpty", PasswordCommand: "notEmpty",
}, },
}, },
{
// Test must fail as no password is given.
Opts: secondaryRepoOptions{
Repo: "backupDst",
},
},
{ {
// Test must fail as current and legacy options are mixed // Test must fail as current and legacy options are mixed
Opts: secondaryRepoOptions{ Opts: secondaryRepoOptions{

View File

@@ -1,13 +1,11 @@
package terminal package terminal
import ( import (
"os"
"golang.org/x/term" "golang.org/x/term"
) )
func StdinIsTerminal() bool { func InputIsTerminal(fd uintptr) bool {
return term.IsTerminal(int(os.Stdin.Fd())) return term.IsTerminal(int(fd))
} }
func OutputIsTerminal(fd uintptr) bool { func OutputIsTerminal(fd uintptr) bool {

View File

@@ -25,6 +25,14 @@ func (m *MockTerminal) CanUpdateStatus() bool {
return true return true
} }
func (m *MockTerminal) InputRaw() io.ReadCloser {
return nil
}
func (m *MockTerminal) InputIsTerminal() bool {
return true
}
func (m *MockTerminal) OutputRaw() io.Writer { func (m *MockTerminal) OutputRaw() io.Writer {
return nil return nil
} }

View File

@@ -13,6 +13,8 @@ type Terminal interface {
SetStatus(lines []string) SetStatus(lines []string)
// CanUpdateStatus returns true if the terminal can update the status lines. // CanUpdateStatus returns true if the terminal can update the status lines.
CanUpdateStatus() bool CanUpdateStatus() bool
InputRaw() io.ReadCloser
InputIsTerminal() bool
// OutputRaw returns the output writer. Should only be used if there is no // OutputRaw returns the output writer. Should only be used if there is no
// other option. Must not be used in combination with Print, Error, SetStatus // other option. Must not be used in combination with Print, Error, SetStatus
// or any other method that writes to the terminal. // or any other method that writes to the terminal.

View File

@@ -17,14 +17,16 @@ var _ ui.Terminal = &Terminal{}
// updated. When the output is redirected to a file, the status lines are not // updated. When the output is redirected to a file, the status lines are not
// printed. // printed.
type Terminal struct { type Terminal struct {
rd io.ReadCloser
wr io.Writer wr io.Writer
fd uintptr fd uintptr
errWriter io.Writer errWriter io.Writer
msg chan message msg chan message
status chan status status chan status
lastStatusLen int
inputIsTerminal bool
outputIsTerminal bool outputIsTerminal bool
canUpdateStatus bool canUpdateStatus bool
lastStatusLen int
// will be closed when the goroutine which runs Run() terminates, so it'll // will be closed when the goroutine which runs Run() terminates, so it'll
// yield a default value immediately // yield a default value immediately
@@ -56,12 +58,12 @@ type fder interface {
// defer cancel() // defer cancel()
// // do stuff // // do stuff
// ``` // ```
func Setup(stdout, stderr io.Writer, quiet bool) (*Terminal, func()) { func Setup(stdin io.ReadCloser, stdout, stderr io.Writer, quiet bool) (*Terminal, func()) {
var wg sync.WaitGroup var wg sync.WaitGroup
// only shutdown once cancel is called to ensure that no output is lost // only shutdown once cancel is called to ensure that no output is lost
cancelCtx, cancel := context.WithCancel(context.Background()) cancelCtx, cancel := context.WithCancel(context.Background())
term := New(stdout, stderr, quiet) term := New(stdin, stdout, stderr, quiet)
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
@@ -82,8 +84,9 @@ func Setup(stdout, stderr io.Writer, quiet bool) (*Terminal, func()) {
// normal output (via Print/Printf) are written to wr, error messages are // normal output (via Print/Printf) are written to wr, error messages are
// written to errWriter. If disableStatus is set to true, no status messages // written to errWriter. If disableStatus is set to true, no status messages
// are printed even if the terminal supports it. // are printed even if the terminal supports it.
func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal { func New(rd io.ReadCloser, wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal {
t := &Terminal{ t := &Terminal{
rd: rd,
wr: wr, wr: wr,
errWriter: errWriter, errWriter: errWriter,
msg: make(chan message), msg: make(chan message),
@@ -95,6 +98,12 @@ func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal {
return t return t
} }
if d, ok := rd.(fder); ok {
if terminal.InputIsTerminal(d.Fd()) {
t.inputIsTerminal = true
}
}
if d, ok := wr.(fder); ok { if d, ok := wr.(fder); ok {
if terminal.CanUpdateStatus(d.Fd()) { if terminal.CanUpdateStatus(d.Fd()) {
// only use the fancy status code when we're running on a real terminal. // only use the fancy status code when we're running on a real terminal.
@@ -111,6 +120,16 @@ func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal {
return t return t
} }
// InputIsTerminal returns whether the input is a terminal.
func (t *Terminal) InputIsTerminal() bool {
return t.inputIsTerminal
}
// InputRaw returns the input reader.
func (t *Terminal) InputRaw() io.ReadCloser {
return t.rd
}
// CanUpdateStatus return whether the status output is updated in place. // CanUpdateStatus return whether the status output is updated in place.
func (t *Terminal) CanUpdateStatus() bool { func (t *Terminal) CanUpdateStatus() bool {
return t.canUpdateStatus return t.canUpdateStatus

View File

@@ -13,7 +13,7 @@ import (
func TestSetStatus(t *testing.T) { func TestSetStatus(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
term := New(&buf, io.Discard, false) term := New(nil, &buf, io.Discard, false)
term.canUpdateStatus = true term.canUpdateStatus = true
term.fd = ^uintptr(0) term.fd = ^uintptr(0)