diff --git a/console_windows.go b/console_windows.go index 6896db1..39ba505 100644 --- a/console_windows.go +++ b/console_windows.go @@ -62,6 +62,8 @@ func (m *master) initStdios() { } type master struct { + f File + in windows.Handle inMode uint32 @@ -153,22 +155,23 @@ func (m *master) Close() error { } func (m *master) Read(b []byte) (int, error) { - return os.Stdin.Read(b) + return m.f.Read(b) } func (m *master) Write(b []byte) (int, error) { - return os.Stdout.Write(b) + return m.f.Write(b) } func (m *master) Fd() uintptr { - return uintptr(m.in) + return m.f.Fd() } -// on windows, console can only be made from os.Std{in,out,err}, hence there -// isnt a single name here we can use. Return a dummy "console" value in this -// case should be sufficient. +// Name returns the name of the underlying file the console was created from +// (for example "/dev/stdout"). It intentionally no longer returns a fixed +// "console" string so the reported name matches the originating file, +// consistent with the Unix implementation. func (m *master) Name() string { - return "console" + return m.f.Name() } // makeInputRaw puts the terminal (Windows Console) connected to the given @@ -209,11 +212,19 @@ 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. +// +// Read, Write, Fd, and Name are delegated to f. The console-mode operations +// (SetRaw, Reset, Size, and DisableEcho) act on the process's standard +// handles rather than f alone: on Windows these streams share a single +// 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 { return nil, errors.New("creating a console from a file is not supported on windows") } - m := &master{} + m := &master{f: f} m.initStdios() return m, nil } diff --git a/console_windows_test.go b/console_windows_test.go new file mode 100644 index 0000000..5b88666 --- /dev/null +++ b/console_windows_test.go @@ -0,0 +1,177 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package console + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "testing" +) + +// TestMaster_DelegatesToFile verifies that a master delegates Read, Write, +// Fd, and Name to the file it was constructed with, rather than the +// process's global os.Stdin/os.Stdout. The Write case is the regression +// test for https://github.com/containerd/console/issues/83, where output +// written to a console created from a non-stdout stream leaked to stdout. +// +// The master is backed by an os.Pipe so the assertions exercise real I/O +// without requiring an attached console, allowing the test to run anywhere. +func TestMaster_DelegatesToFile(t *testing.T) { + t.Run("WriteGoesToFile", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + + want := []byte("written via master") + + // Drain the pipe concurrently so Write does not block. + type result struct { + data []byte + err error + } + done := make(chan result, 1) + go func() { + data, err := io.ReadAll(r) + done <- result{data, err} + }() + + m := &master{f: w} + if _, err := m.Write(want); err != nil { + t.Fatalf("Write: %v", err) + } + w.Close() + + got := <-done + if got.err != nil { + t.Fatalf("reading pipe: %v", got.err) + } + if string(got.data) != string(want) { + t.Errorf("Write reached the wrong stream: pipe got %q, want %q", got.data, want) + } + }) + + t.Run("ReadComesFromFile", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + + want := []byte("read via master") + go func() { + w.Write(want) + w.Close() + }() + + m := &master{f: r} + got, err := io.ReadAll(m) + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(got) != string(want) { + t.Errorf("Read drew from the wrong stream: got %q, want %q", got, want) + } + }) + + t.Run("FdAndName", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + + m := &master{f: w} + if got, want := m.Fd(), w.Fd(); got != want { + t.Errorf("Fd() = %v, want %v", got, want) + } + if m.Fd() == os.Stdout.Fd() { + t.Error("Fd() returned os.Stdout's descriptor; expected the provided file's") + } + if got, want := m.Name(), w.Name(); got != want { + t.Errorf("Name() = %q, want %q", got, want) + } + }) +} + +// TestConsoleFromFile_Delegation is an end-to-end test that exercises +// the public ConsoleFromFile API with real console handles. +// +// Because "go test" redirects standard streams to pipes, the test +// spawns itself as a subprocess with stdin/stdout connected to the real +// console devices (CONIN$/CONOUT$); the subprocess's stderr is captured +// so failures are reported with detail. This is the same helper-process +// pattern used in the Go standard library (os/exec tests). +func TestConsoleFromFile_Delegation(t *testing.T) { + if os.Getenv("CONSOLE_E2E_SUBPROCESS") == "1" { + // Subprocess: stdin and stdout are wired to the real console. + // stderr is captured by the parent and used to report diagnostics. + var failed bool + for _, f := range []*os.File{os.Stdin, os.Stdout} { + c, err := ConsoleFromFile(f) + if err != nil { + fmt.Fprintf(os.Stderr, "ConsoleFromFile(%s): %v\n", f.Name(), err) + failed = true + continue + } + if got, want := c.Fd(), f.Fd(); got != want { + fmt.Fprintf(os.Stderr, "ConsoleFromFile(%s).Fd() = %v, want %v\n", f.Name(), got, want) + failed = true + } + if got, want := c.Name(), f.Name(); got != want { + fmt.Fprintf(os.Stderr, "ConsoleFromFile(%s).Name() = %q, want %q\n", f.Name(), got, want) + failed = true + } + } + if failed { + os.Exit(1) + } + os.Exit(0) + } + + conin, err := os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + t.Skip("no console available:", err) + } + defer conin.Close() + + conout, err := os.OpenFile("CONOUT$", os.O_RDWR, 0) + if err != nil { + t.Skip("no console available:", err) + } + defer conout.Close() + + // stdin and stdout go to the real console (what we are testing); stderr is + // captured so subprocess diagnostics surface in the failure message. + var stderr bytes.Buffer + cmd := exec.Command(os.Args[0], "-test.run=^TestConsoleFromFile_Delegation$") + cmd.Env = append(os.Environ(), "CONSOLE_E2E_SUBPROCESS=1") + cmd.Stdin = conin + cmd.Stdout = conout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + t.Fatalf("subprocess failed: %v\n%s", err, stderr.String()) + } +}