diff --git a/console_windows.go b/console_windows.go index 39ba505..8380c21 100644 --- a/console_windows.go +++ b/console_windows.go @@ -212,8 +212,11 @@ func checkConsole(f File) error { return nil } -// newMaster creates a Console from one of the process's standard streams -// (os.Stdin, os.Stdout, or os.Stderr); any other file is rejected. +// newMaster creates a Console from one of the process's standard streams, +// identified by its underlying console handle: os.Stdin, os.Stdout, +// os.Stderr, or any File reporting one of their handles through Fd() — a +// wrapper decorating a standard stream is accepted, since the handle is what +// designates the console object. Files with any other handle are rejected. // // Read, Write, Fd, and Name are delegated to f. The console-mode operations // (SetRaw, Reset, Size, and DisableEcho) act on the process's standard @@ -221,7 +224,7 @@ func checkConsole(f File) error { // underlying console object, so mode and size queries apply to that console // as a whole. func newMaster(f File) (Console, error) { - if f != os.Stdin && f != os.Stdout && f != os.Stderr { + if fd := f.Fd(); fd != os.Stdin.Fd() && fd != os.Stdout.Fd() && fd != os.Stderr.Fd() { return nil, errors.New("creating a console from a file is not supported on windows") } m := &master{f: f} diff --git a/console_windows_test.go b/console_windows_test.go index 5b88666..c87957a 100644 --- a/console_windows_test.go +++ b/console_windows_test.go @@ -175,3 +175,40 @@ func TestConsoleFromFile_Delegation(t *testing.T) { t.Fatalf("subprocess failed: %v\n%s", err, stderr.String()) } } + +// fileWrapper decorates an *os.File without being one of the os.Std* +// package variables — the shape callers hand over when their CLI streams +// wrap a standard stream (see docker/compose#14086). +type fileWrapper struct { + *os.File +} + +// TestNewMaster_StandardStreamIdentity verifies that newMaster identifies a +// standard stream by its console handle rather than by Go value identity: a +// File decorating a standard stream reports the same handle through Fd() and +// designates the same console object, so it must be accepted like the os.Std* +// value it wraps. Files carrying any other handle stay rejected. +// +// initStdios tolerates redirected streams, so this runs in headless CI. +func TestNewMaster_StandardStreamIdentity(t *testing.T) { + for _, f := range []*os.File{os.Stdin, os.Stdout, os.Stderr} { + if _, err := newMaster(f); err != nil { + t.Errorf("newMaster(%s): %v", f.Name(), err) + } + if _, err := newMaster(fileWrapper{f}); err != nil { + t.Errorf("newMaster(wrapper{%s}): %v", f.Name(), err) + } + } + + f, err := os.CreateTemp(t.TempDir(), "console") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := newMaster(f); err == nil { + t.Error("newMaster accepted a regular file") + } + if _, err := newMaster(fileWrapper{f}); err == nil { + t.Error("newMaster accepted a wrapped regular file") + } +}