Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18407] ファイルの概要

このコミットは、Go言語の標準ライブラリ os/exec パッケージにおける Command 関数の挙動を修正し、相対パスの扱いを改善するものです。具体的には、Command 関数がプログラムのパスを解決する際に、ドキュメントに記載されている通りのロジック(パスセパレータを含まない場合にのみ LookPath を使用する)を厳密に適用するように変更されました。これにより、相対パスで指定されたコマンドが正しく実行できるようになり、以前は手動で *Cmd 構造体を構築する必要があったケースが解消されました。

コミット

commit e6d8bfe218a5f387d7aceddcaee5067a59181838
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Mon Feb 3 16:32:13 2014 -0500

    os/exec: fix Command with relative paths

    Command was (and is) documented like:
    "If name contains no path separators, Command uses LookPath to
    resolve the path to a complete name if possible. Otherwise it
    uses name directly."

    But that wasn't true. It always did LookPath, and then
    set a sticky error that the user couldn't unset.
    And then if cmd.Dir was changed, Start would still fail
    due to the earlier sticky error being set.

    This keeps LookPath in the same place as before (so no user
    visible changes in cmd.Path after Command), but only does
    it when the documentation says it will happen.

    Also, clarify the docs about a relative Dir path.

    No change in any existing behavior, except using Command
    is now possible with relative paths. Previously it only
    worked if you built the *Cmd by hand.

    Fixes #7228

    LGTM=iant
    R=iant
    CC=adg, golang-codereviews
    https://golang.org/cl/59580044

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/e6d8bfe218a5f387d7aceddcaee5067a59181838

元コミット内容

os/exec: fix Command with relative paths

Command 関数は、ドキュメントでは「名前がパスセパレータを含まない場合、LookPath を使用して完全なパスを解決する。それ以外の場合は、名前を直接使用する」と記述されていた。

しかし、実際には常に LookPath を実行し、ユーザーが解除できない「スティッキーエラー」を設定していた。その結果、cmd.Dir が変更された場合でも、以前設定されたスティッキーエラーのために Start が失敗していた。

この変更は、LookPath の位置を以前と同じに保ちつつ(Command 呼び出し後の cmd.Path にユーザーが認識できる変更はない)、ドキュメントに記載されている場合にのみ LookPath を実行するようにする。

また、相対 Dir パスに関するドキュメントを明確にする。

既存の動作に変更はないが、Command を使用して相対パスでコマンドを実行することが可能になった。以前は、*Cmd を手動で構築した場合にのみ機能していた。

Fixes #7228

変更の背景

Go言語の os/exec パッケージは、外部コマンドを実行するための機能を提供します。このパッケージの Command 関数は、実行するプログラムの名前と引数を受け取り、*Cmd 構造体を返します。この構造体には、実行するプログラムのパス (Path フィールド) や引数 (Args フィールド) などが含まれます。

コミットメッセージによると、Command 関数のドキュメントには、プログラム名にパスセパレータが含まれない場合にのみ LookPath (システムのPATH環境変数から実行可能ファイルを探す関数) を使用し、それ以外の場合は指定された名前をそのままパスとして使用すると明記されていました。しかし、実際の挙動は異なり、Command 関数は常に LookPath を呼び出していました。

この不一致は、特に相対パスでコマンドを指定した場合に問題を引き起こしていました。例えば、./myprogram のように相対パスでコマンドを指定すると、Command 関数は LookPath を実行しようとします。しかし、LookPath は通常、PATH環境変数に登録されたディレクトリ内から実行可能ファイルを探すため、カレントディレクトリの相対パスは解決できません。この結果、LookPath はエラーを返し、そのエラーが *Cmd 構造体内部に「スティッキーエラー」として保持されていました。

さらに悪いことに、ユーザーが cmd.Dir (コマンドを実行する作業ディレクトリ) を後から変更した場合でも、このスティッキーエラーが原因で cmd.Start() が失敗するという問題がありました。これは、ユーザーが意図的に相対パスを指定し、cmd.Dir を設定してその相対パスを解決しようとしても、Command 関数が内部で発生させたエラーが邪魔をして実行できないという、直感的ではない挙動でした。

このコミットは、このドキュメントと実装の乖離を修正し、ユーザーが Command 関数をより柔軟に、特に相対パスを意図通りに扱えるようにすることを目的としています。

前提知識の解説

このコミットを理解するためには、以下のGo言語の概念とオペレーティングシステムの知識が必要です。

  1. os/exec パッケージ:

    • Go言語で外部コマンドを実行するための標準ライブラリパッケージです。
    • exec.Command(name string, arg ...string) *Cmd は、指定されたプログラムと引数で *Cmd 構造体を作成します。
    • *Cmd 構造体には、実行するプログラムのパス (Path)、引数 (Args)、標準入力/出力/エラー (Stdin, Stdout, Stderr)、実行ディレクトリ (Dir) などの情報が含まれます。
    • cmd.Start() はコマンドを非同期で開始します。
    • cmd.Run() はコマンドを開始し、完了するまで待機します。
    • cmd.Output() はコマンドを実行し、標準出力をバイトスライスとして返します。
  2. exec.LookPath(file string) (path string, err error):

    • この関数は、指定された file (実行可能ファイル名) をシステムの PATH 環境変数で指定されたディレクトリ群から検索し、最初に見つかった実行可能ファイルの絶対パスを返します。
    • PATH 環境変数は、オペレーティングシステムがコマンドを探すディレクトリのリストです。例えば、Linuxでは /usr/local/bin:/usr/bin:/bin のようになります。
    • LookPath は、ファイル名にパスセパレータ(例: /\)が含まれていない場合にのみ意味を持ちます。絶対パスや相対パスが指定された場合は、通常 LookPath を使う必要はありません。
  3. パスセパレータ (Path Separator):

    • ファイルパスのディレクトリを区切る文字です。Unix系システムでは / (スラッシュ)、Windowsでは \ (バックスラッシュ) が使われます。
    • Go言語の os パッケージには os.PathSeparator という定数があり、現在のOSに応じたパスセパレータを提供します。また、os.IsPathSeparator(c uint8) 関数は、指定されたバイトがパスセパレータであるかを判定します。
  4. 相対パスと絶対パス:

    • 絶対パス: ファイルシステムのルートディレクトリから始まる完全なパスです(例: /home/user/documents/file.txt)。
    • 相対パス: 現在の作業ディレクトリを基準としたパスです(例: ./myprogram../data/config.json)。
  5. cmd.Dir フィールド:

    • *Cmd 構造体の Dir フィールドは、コマンドを実行する作業ディレクトリを指定します。このフィールドが設定されている場合、コマンドは指定されたディレクトリ内で実行されます。
    • 相対パスで指定されたコマンドは、この Dir フィールドで指定されたディレクトリを基準に解決されます。
  6. スティッキーエラー (Sticky Error):

    • このコミットメッセージで使われている「スティッキーエラー」という表現は、一度設定されると、その後の操作で解除されず、オブジェクトのライフサイクル全体にわたって影響を与え続けるエラー状態を指します。この文脈では、Command 関数が LookPath の失敗によって内部的に設定したエラーが、cmd.Dir の変更後も Start メソッドの実行を妨げていたことを意味します。

これらの知識を前提として、このコミットが os/exec パッケージの使いやすさと正確性をどのように向上させたかを理解することができます。

技術的詳細

このコミットの技術的な核心は、os/exec パッケージの Command 関数が、プログラムのパスを解決するロジックを、ドキュメントの記述と一致させるように変更した点にあります。

変更前は、Command 関数は常に exec.LookPath を呼び出してプログラムのパスを解決しようとしていました。これは、プログラム名が ls のような単純な名前であっても、./myprogram のような相対パスであっても同様でした。

問題は、./myprogram のような相対パスが LookPath に渡された場合です。LookPathPATH 環境変数に基づいて実行可能ファイルを検索するため、通常はカレントディレクトリの相対パスを解決できません。このため、LookPath はエラーを返し、そのエラーが *Cmd 構造体の内部フィールド (err という名前でした) に格納されていました。このエラーは「スティッキーエラー」となり、たとえユーザーが後から cmd.Dir を設定して相対パスが解決可能になるようにしても、cmd.Start() を呼び出すと、この以前のエラーが原因で即座に失敗していました。

このコミットでは、以下の変更が行われました。

  1. Cmd 構造体の変更:

    • Path フィールドのコメントが更新され、「Path が相対パスの場合、Dir に対して評価される」という点が明確化されました。これは、cmd.Dir を設定することで相対パスのコマンドを実行できるという意図を強調しています。
    • 内部エラーフィールドの名前が err から lookPathErr に変更され、その目的が LookPath からのエラーに限定されることが明確になりました。これにより、他の種類のエラーと区別しやすくなります。
  2. Command 関数のロジック変更:

    • 最も重要な変更点です。Command 関数は、まず引数として渡された name をそのまま cmd.Path に設定します。
    • 次に、新しいヘルパー関数 containsPathSeparator(name) を呼び出して、name にパスセパレータが含まれているかどうかをチェックします。
    • パスセパレータが含まれていない場合(例: ls, go)にのみexec.LookPath(name) を呼び出します。
      • LookPath が成功した場合、cmd.PathLookPath が返した完全なパスに更新されます。
      • LookPath が失敗した場合、そのエラーは cmd.lookPathErr に格納されます。
    • パスセパレータが含まれている場合(例: ./myprogram, /usr/bin/ls)はLookPath は呼び出されず、cmd.Path は初期設定された name のままになります。この場合、cmd.lookPathErr は設定されません。
  3. containsPathSeparator ヘルパー関数の追加:

    • この新しい関数は、文字列 s を走査し、os.IsPathSeparator(s[i]) を使用して、OS固有のパスセパレータが含まれているかを効率的に判定します。
  4. Start 関数の変更:

    • Start 関数は、コマンドの実行を開始する前に、以前は c.err をチェックしていましたが、この変更により c.lookPathErr をチェックするように変わりました。これにより、LookPath に関連するエラーのみが Start の即時失敗を引き起こすようになります。

これらの変更により、Command 関数はドキュメント通りの挙動をするようになり、./myprogram のような相対パスが指定された場合でも、LookPath が不必要に呼び出されてエラーが設定されることがなくなりました。これにより、ユーザーは cmd.Dir を設定することで、相対パスのコマンドを意図通りに実行できるようになりました。既存の絶対パスや単純なコマンド名に対する挙動は維持されつつ、相対パスの柔軟性が向上した点がこのコミットの大きな成果です。

コアとなるコードの変更箇所

このコミットにおける主要なコード変更は、src/pkg/os/exec/exec.go ファイルと src/pkg/os/exec/exec_test.go ファイルに集中しています。

src/pkg/os/exec/exec.go

  1. Cmd 構造体の定義変更:

    --- a/src/pkg/os/exec/exec.go
    +++ b/src/pkg/os/exec/exec.go
    @@ -33,7 +33,8 @@ type Cmd struct {
      	// Path is the path of the command to run.
      	//
      	// This is the only field that must be set to a non-zero
    -	// value.
    +	// value. If Path is relative, it is evaluated relative
    +	// to Dir.
      	Path string
    
      	// Args holds command line arguments, including the command as Args[0].
    @@ -84,7 +85,7 @@ type Cmd struct {
      	// available after a call to Wait or Run.
      	ProcessState *os.ProcessState
    
    -	err             error // last error (from LookPath, stdin, stdout, stderr)
    +	lookPathErr     error // LookPath error, if any.
      	finished        bool  // when Wait was called
      	childFiles      []*os.File
      	closeAfterStart []io.Closer
    
    • Path フィールドのコメントが更新され、相対パスが Dir に対して評価されることが明記されました。
    • 内部エラーフィールド errlookPathErr にリネームされ、その役割が LookPath からのエラーに限定されました。
  2. Command 関数のロジック変更:

    --- a/src/pkg/os/exec/exec.go
    +++ b/src/pkg/os/exec/exec.go
    @@ -107,19 +107,31 @@ func Command(name string, arg ...string) *Cmd {
     // followed by the elements of arg, so arg should not include the
     // command name itself. For example, Command("echo", "hello")
     func Command(name string, arg ...string) *Cmd {
    -	aname, err := LookPath(name)
    -	if err != nil {
    -		aname = name
    -	}
    -	return &Cmd{\
    -		Path: aname,
    +	cmd := &Cmd{
    +		Path: name,
     		Args: append([]string{name}, arg...),\
    -		err:  err,\
     	}\
    +	if !containsPathSeparator(name) {
    +		if lp, err := LookPath(name); err != nil {
    +			cmd.lookPathErr = err
    +		} else {
    +			cmd.Path = lp
    +		}
    +	}
    +	return cmd
    +}
    +
    +func containsPathSeparator(s string) bool {
    +	for i := 0; i < len(s); i++ {
    +		if os.IsPathSeparator(s[i]) {
    +			return true
    +		}
    +	}
    +	return false
     }
    
     // interfaceEqual protects against panics from doing equality tests on
    -// two interfaces with non-comparable underlying types
    +// two interfaces with non-comparable underlying types.
     func interfaceEqual(a, b interface{}) bool {
     	defer func() {
     	\trecover()\
    @@ -235,10 +247,10 @@ func (c *Cmd) Run() error {
    
     // Start starts the specified command but does not wait for it to complete.
     func (c *Cmd) Start() error {
    -	if c.err != nil {
    +	if c.lookPathErr != nil {
     		c.closeDescriptors(c.closeAfterStart)
     		c.closeDescriptors(c.closeAfterWait)
    -		return c.err
    +		return c.lookPathErr
     	}
     	if c.Process != nil {
     		return errors.New("exec: already started")
    
    • Command 関数内で LookPath を呼び出すロジックが変更され、containsPathSeparator 関数による条件分岐が導入されました。
    • 新しいヘルパー関数 containsPathSeparator が追加されました。
    • Start 関数が c.err の代わりに c.lookPathErr をチェックするように変更されました。

src/pkg/os/exec/exec_test.go

  1. 新しいテストケース TestCommandRelativeName の追加:
    --- a/src/pkg/os/exec/exec_test.go
    +++ b/src/pkg/os/exec/exec_test.go
    @@ -44,6 +44,33 @@ func TestEcho(t *testing.T) {
      	}\
      }\
    
    +func TestCommandRelativeName(t *testing.T) {
    +	// Run our own binary as a relative path
    +	// (e.g. "_test/exec.test") our parent directory.
    +	base := filepath.Base(os.Args[0]) // "exec.test"
    +	dir := filepath.Dir(os.Args[0])   // "/tmp/go-buildNNNN/os/exec/_test"
    +	if dir == "." {
    +		t.Skip("skipping; running test at root somehow")
    +	}
    +	parentDir := filepath.Dir(dir) // "/tmp/go-buildNNNN/os/exec"
    +	dirBase := filepath.Base(dir)  // "_test"
    +	if dirBase == "." {
    +		t.Skipf("skipping; unexpected shallow dir of %q", dir)\
    +	}
    +
    +	cmd := exec.Command(filepath.Join(dirBase, base), "-test.run=TestHelperProcess", "--", "echo", "foo")
    +	cmd.Dir = parentDir
    +	cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"}
    +
    +	out, err := cmd.Output()
    +	if err != nil {
    +		t.Errorf("echo: %v", err)
    +	}
    +	if g, e := string(out), "foo\\n"; g != e {
    +		t.Errorf("echo: want %q, got %q", e, g)
    +	}
    +}
    +
     func TestCatStdin(t *testing.T) {
     	// Cat, testing stdin and stdout.
     	input := "Input string\\nLine 2"
    
    • このテストは、自身のバイナリを相対パスで実行し、cmd.Dir を設定することで、新しい相対パスの挙動が正しく機能することを確認します。

コアとなるコードの解説

src/pkg/os/exec/exec.go

Cmd 構造体の変更

  • Path フィールドのコメント更新:

    // Path is the path of the command to run.
    //
    // This is the only field that must be set to a non-zero
    // value. If Path is relative, it is evaluated relative
    // to Dir.
    Path string
    

    この変更は、Path が相対パスである場合に、Cmd.Dir フィールドで指定されたディレクトリを基準として解決されるという、os/exec パッケージの意図された挙動を明確にしています。これは、ユーザーが相対パスでコマンドを指定し、Dir を設定することでそのコマンドを実行できるというシナリオをサポートするための重要な説明です。

  • err フィールドから lookPathErr へのリネーム:

    lookPathErr     error // LookPath error, if any.
    

    以前は err という汎用的な名前だったフィールドが lookPathErr に変更されました。これにより、このフィールドが exec.LookPath 関数によって発生した特定のエラーのみを保持することが明確になります。この区別は重要で、Command 関数が LookPath を呼び出すべきではない状況で発生したエラーが、後続の Start メソッドの実行を不必要に妨げないようにするために役立ちます。

Command 関数のロジック変更

func Command(name string, arg ...string) *Cmd {
	cmd := &Cmd{
		Path: name,
		Args: append([]string{name}, arg...),
	}
	if !containsPathSeparator(name) {
		if lp, err := LookPath(name); err != nil {
			cmd.lookPathErr = err
		} else {
			cmd.Path = lp
		}
	}
	return cmd
}

これがこのコミットの最も重要な変更点です。

  1. cmd := &Cmd{Path: name, Args: append([]string{name}, arg...)}
    • まず、引数として渡された name をそのまま cmd.Path に設定し、Args も初期化します。これは、name が絶対パスや相対パスである場合に、そのパスを直接使用するという意図を反映しています。
  2. if !containsPathSeparator(name)
    • ここで新しいヘルパー関数 containsPathSeparator が使用されます。この条件は、namelsgo のようにパスセパレータを含まない単純なコマンド名である場合に true となります。
    • この条件が true の場合のみ、以下の LookPath 処理が実行されます。
      • if lp, err := LookPath(name); err != nil
        • exec.LookPath を呼び出して、PATH 環境変数から実行可能ファイルを検索します。
        • LookPath がエラーを返した場合(例: コマンドが見つからない)、そのエラーは cmd.lookPathErr に格納されます。このエラーは、後で Start メソッドが呼び出されたときにチェックされます。
        • LookPath が成功した場合、lp (見つかった実行可能ファイルの絶対パス) が cmd.Path に設定されます。
    • この条件が false の場合(つまり、name にパスセパレータが含まれている場合、例: ./myprogram, /usr/bin/lsLookPath は呼び出されません。cmd.Path は初期設定された name のままとなり、cmd.lookPathErr も設定されません。これにより、相対パスが指定された場合に不必要な LookPath の呼び出しと、それに伴う「スティッキーエラー」の発生が回避されます。

containsPathSeparator ヘルパー関数

func containsPathSeparator(s string) bool {
	for i := 0; i < len(s); i++ {
		if os.IsPathSeparator(s[i]) {
			return true
		}
	}
	return false
}

この関数は、与えられた文字列 s がOS固有のパスセパレータ(os.PathSeparator)を含んでいるかどうかを効率的にチェックします。これは、Command 関数が LookPath を呼び出すべきかどうかを判断するためのシンプルなユーティリティです。

Start 関数の変更

func (c *Cmd) Start() error {
	if c.lookPathErr != nil {
		c.closeDescriptors(c.closeAfterStart)
		c.closeDescriptors(c.closeAfterWait)
		return c.lookPathErr
	}
	// ... (rest of the Start function)
}

Start メソッドは、コマンドの実行を開始する前に、c.lookPathErr が設定されているかどうかをチェックするようになりました。これにより、Command 関数が LookPath の失敗によって設定したエラーがある場合にのみ、Start が即座に失敗するようになります。相対パスが指定された場合に lookPathErr が設定されないようになったため、cmd.Dir を設定して相対パスを解決するシナリオが正しく機能するようになります。

src/pkg/os/exec/exec_test.go

TestCommandRelativeName テストケース

func TestCommandRelativeName(t *testing.T) {
	// Run our own binary as a relative path
	// (e.g. "_test/exec.test") our parent directory.
	base := filepath.Base(os.Args[0]) // "exec.test"
	dir := filepath.Dir(os.Args[0])   // "/tmp/go-buildNNNN/os/exec/_test"
	if dir == "." {
		t.Skip("skipping; running test at root somehow")
	}
	parentDir := filepath.Dir(dir) // "/tmp/go-buildNNNN/os/exec"
	dirBase := filepath.Base(dir)  // "_test"
	if dirBase == "." {
		t.Skipf("skipping; unexpected shallow dir of %q", dir)
	}

	cmd := exec.Command(filepath.Join(dirBase, base), "-test.run=TestHelperProcess", "--", "echo", "foo")
	cmd.Dir = parentDir
	cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"}

	out, err := cmd.Output()
	if err != nil {
		t.Errorf("echo: %v", err)
	}
	if g, e := string(out), "foo\n"; g != e {
		t.Errorf("echo: want %q, got %q", e, g)
	}
}

この新しいテストケースは、このコミットによって修正された主要なシナリオを検証します。

  • テストバイナリ自身のパス (os.Args[0]) を利用して、相対パス (_test/exec.test のような形式) を構築します。
  • exec.Command にこの相対パスを渡し、cmd.Dir を適切に設定します。これにより、コマンドが実行されるべきディレクトリが指定されます。
  • cmd.Output() を呼び出してコマンドを実行し、エラーがないこと、および期待される出力 (foo\n) が得られることを確認します。

このテストは、Command 関数が相対パスを正しく処理し、cmd.Dir と組み合わせて期待通りに動作することを示す重要な回帰テストです。

関連リンク

  • Go Issue #7228: https://github.com/golang/go/issues/7228
    • このコミットが修正した元のバグ報告です。相対パスで exec.Command を使用した際に LookPath がエラーを返す問題が議論されています。
  • Go Code Review CL 59580044: https://golang.org/cl/59580044
    • このコミットのコードレビューページです。変更内容の詳細な議論や、レビュアーからのコメントを確認できます。

参考にした情報源リンク