Skip to content
Open
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
27 changes: 19 additions & 8 deletions console_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func (m *master) initStdios() {
}

type master struct {
f File

in windows.Handle
inMode uint32

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
177 changes: 177 additions & 0 deletions console_windows_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading