読者です 読者をやめる 読者になる 読者になる

Go のプログラムで Windows 上で chmod しようとしただけなのになぜか golang.org/x/sys/windows にバグっぽい挙動を見つけたばかりかなぜかアセンブラを読むハメになった話

何を言ってるかわか(ry

ファイルのパーミッションを変更する chmod コマンド相当の関数は、私の知る限り Golang の標準ライブラリには 2 つあって、しかもそのいずれもが Windows 上では期待したような動作にならないって、みなさん知っていましたか?

まずひとつめ、os.Chmod() 、これは内部的に syscall.Chmod() を呼んでいます。

syscall.Chmod()このあたり に実装があって(本記事執筆時点)、ソースコードをご覧頂いてもわかるように Windows APISetFileAttributes() を呼んでファイルの ReadOnly 属性を設定したり落としたりしているだけです。

魚拓: f:id:bearmini:20170427193959p:plain

なので、os.Chmod(path, 0600) とかやっても、もともと他の人が読み書きできる設定になってるファイルはそのまま他の人が読み書きできる状態が維持されてしまいます。

びっくりしますね。

続いてふたつめ、 func (*os.File) Chmod() です。これは内部的に (*poll.FD) Fchmod() を呼んでいて、そこから syscall.Fchmod() が呼び出されます。こちらの実装は常に EWINDOWS という値を返すようになっています。つまり常にエラーです。

魚拓: f:id:bearmini:20170427194850p:plain

なので、var f *os.Filef に対して f.Chmod(0600) とかやっても、エラーになるだけでファイルのパーミッションは何も変わりません。

このあたりで、「Windows には ACL (Access Control Lists) というものがあったな・・・POSIX/UNIX のシンプルなファイルパーミッションとは根本的に仕組みが違うのかな・・・」ということに気づいちゃいます。

ということで、「じゃあ GolangWindowsACL が操作できるライブラリがあればいいじゃないか。きっとあるはずだ。」と思っておもむろにグーグル先生に聞いてみるわけじゃないですか。

するといくつかライブラリが見つかりますが、私がやりたいことができそうだったのが以下のライブラリです。

github.com

しかも chmod.go という名前の、そのものズバリなファイル(そしてその中に Chmod() 関数)まであるじゃありませんか。

ということでこいつを使ってみます。

すると、ほぼ期待したような動作をしてくれるようです。 「いいじゃないか、こいつを採用だ」と思った時に、一つ問題が見つかりました。

この go-aclChmod() は、エラーが起きたとき(たとえば存在しないファイルに対して呼び出したときなど)に、error を返してくれるのは良いのですが、error.Error() でそのエラーメッセージを取り出すと、常に

The operation completed successfully.

という文字列が取得されるのです。

なぜだ、、、

ということで Deep dive が始まります。

go-aclChmod() は内部で同じパッケージの Apply() を呼んでいます。 Apply() は内部で go-ac/api パッケージの GetNamedSecurityInfo()SetNamedSecurityInfo() を呼び出します。

ここでは GetNamedSecurityInfo() を見ていくことにしましょう。

// https://github.com/hectane/go-acl/blob/master/api/secinfo.go#L44

var (
    procGetNamedSecurityInfoW = advapi32.MustFindProc("GetNamedSecurityInfoW")
    procSetNamedSecurityInfoW = advapi32.MustFindProc("SetNamedSecurityInfoW")
)

// https://msdn.microsoft.com/en-us/library/windows/desktop/aa446645.aspx
func GetNamedSecurityInfo(objectName string, objectType int32, secInfo uint32, owner, group **windows.SID, dacl, sacl, secDesc *windows.Handle) error {
    ret, _, err := procGetNamedSecurityInfoW.Call(
        uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(objectName))),
        uintptr(objectType),
        uintptr(secInfo),
        uintptr(unsafe.Pointer(owner)),
        uintptr(unsafe.Pointer(group)),
        uintptr(unsafe.Pointer(dacl)),
        uintptr(unsafe.Pointer(sacl)),
        uintptr(unsafe.Pointer(secDesc)),
    )
    if ret != 0 {
        return err
    }
    return nil
}

advapi32.dll からロードした GetNamedSecurityInfoW() Windows API を呼んでいます。

advapi32 は windows.MustLoadDLL() でロードした windows.DLL 型なので、advapi32.MustFindProc() でロードした procGetNamedSecurityInfoWwindows.Proc 型です。

procGetNamedSecurityInfoW.Call() の 3 番目の戻り値に問題がありそうです。

windows.ProcCall() 関数は以下のような定義になっています。

// https://github.com/golang/sys/blob/master/windows/dll_windows.go#L126

func (p *Proc) Call(a ...uintptr) (r1, r2 uintptr, lastErr error) {
    switch len(a) {
    case 0:
        return syscall.Syscall(p.Addr(), uintptr(len(a)), 0, 0, 0)
    case 1:
        return syscall.Syscall(p.Addr(), uintptr(len(a)), a[0], 0, 0)
    case 2:
        return syscall.Syscall(p.Addr(), uintptr(len(a)), a[0], a[1], 0)

  :

syscall.Syscall() を呼んでいますね。

// https://github.com/golang/go/blob/master/src/runtime/syscall_windows.go#L156

//go:linkname syscall_Syscall syscall.Syscall
//go:nosplit
func syscall_Syscall(fn, nargs, a1, a2, a3 uintptr) (r1, r2, err uintptr) {
    c := &getg().m.syscall
    c.fn = fn
    c.n = nargs
    c.args = uintptr(noescape(unsafe.Pointer(&a1)))
    cgocall(asmstdcallAddr, unsafe.Pointer(c))
    return c.r1, c.r2, c.err
}

この 3 番目の戻り値 c.err が怪しいです。 ちなみに、ここでは関数シグネチャでは 3 番目の戻り値の型は uintptr になっていますが、この関数の宣言部では以下のように syscall.Errno になっています。

// https://github.com/golang/go/blob/master/src/syscall/dll_windows.go#L24

// Implemented in ../runtime/syscall_windows.go.
func Syscall(trap, nargs, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)

さて、syscall_Syscall() の中に戻って見ていきます。

この3番目の戻り値 c.err が設定されているのは、おそらく cgocall() の中でしょう。

少し長いですが cgocall() 関数の全体を以下に掲載します。

// https://github.com/golang/go/blob/master/src/runtime/cgocall.go#L92

// Call from Go to C.
//go:nosplit
func cgocall(fn, arg unsafe.Pointer) int32 {
    if !iscgo && GOOS != "solaris" && GOOS != "windows" {
        throw("cgocall unavailable")
    }

    if fn == nil {
        throw("cgocall nil")
    }

    if raceenabled {
        racereleasemerge(unsafe.Pointer(&racecgosync))
    }

    // Lock g to m to ensure we stay on the same stack if we do a
    // cgo callback. In case of panic, unwindm calls endcgo.
    lockOSThread()
    mp := getg().m
    mp.ncgocall++
    mp.ncgo++
    mp.incgo = true

    // Reset traceback.
    mp.cgoCallers[0] = 0

    // Announce we are entering a system call
    // so that the scheduler knows to create another
    // M to run goroutines while we are in the
    // foreign code.
    //
    // The call to asmcgocall is guaranteed not to
    // grow the stack and does not allocate memory,
    // so it is safe to call while "in a system call", outside
    // the $GOMAXPROCS accounting.
    //
    // fn may call back into Go code, in which case we'll exit the
    // "system call", run the Go code (which may grow the stack),
    // and then re-enter the "system call" reusing the PC and SP
    // saved by entersyscall here.
    entersyscall(0)
    errno := asmcgocall(fn, arg)
    exitsyscall(0)

    // From the garbage collector's perspective, time can move
    // backwards in the sequence above. If there's a callback into
    // Go code, GC will see this function at the call to
    // asmcgocall. When the Go call later returns to C, the
    // syscall PC/SP is rolled back and the GC sees this function
    // back at the call to entersyscall. Normally, fn and arg
    // would be live at entersyscall and dead at asmcgocall, so if
    // time moved backwards, GC would see these arguments as dead
    // and then live. Prevent these undead arguments from crashing
    // GC by forcing them to stay live across this time warp.
    KeepAlive(fn)
    KeepAlive(arg)

    endcgo(mp)
    return errno
}

直接 c.err に何か値を設定しているところはないようです。

 errno := asmcgocall(fn, arg)

の行が怪しいですね。 ここで fn は、cgocall() を呼び出す際に第一引数に指定された asmstdcallAddr です。

asmstdcallAddr は以下のように定義されていて、

// https://github.com/golang/go/blob/master/src/runtime/os_windows.go#L157

var asmstdcallAddr unsafe.Pointer

以下のように初期化されています。

// https://github.com/golang/go/blob/master/src/runtime/os_windows.go#L263
func osinit() {
    asmstdcallAddr = unsafe.Pointer(funcPC(asmstdcall))

asmstdcall という関数のアドレスのようです。

asmstdcall

// https://github.com/golang/go/blob/master/src/runtime/os_windows.go#L153

// Call a Windows function with stdcall conventions,
// and switch to os stack during the call.
func asmstdcall(fn unsafe.Pointer)

というように宣言だけされていますが、実体はアセンブラで記述されています。 名前の通り、stdcall 呼び出し規約に則って関数を呼び出す処理がアセンブラで記述されているようです。

以下は 64bit windows 環境用のアセンブリコードです。

// https://github.com/golang/go/blob/master/src/runtime/sys_windows_amd64.s#L13

// void runtime·asmstdcall(void *c);
TEXT runtime·asmstdcall(SB),NOSPLIT|NOFRAME,$0
    // asmcgocall will put first argument into CX.
    PUSHQ   CX          // save for later
    MOVQ    libcall_fn(CX), AX
    MOVQ    libcall_args(CX), SI
    MOVQ    libcall_n(CX), CX

    // SetLastError(0).
    // https://en.wikipedia.org/wiki/Win32_Thread_Information_Block
    // http://www.geoffchappell.com/studies/windows/win32/ntdll/structs/teb/index.htm
    // http://shitwefoundout.com/wiki/Win32_Thread_Environment_Block
    MOVQ    0x30(GS), DI   //* DI = _NT_TIB.Self (where GS == TIB: Thread Information Block)
    MOVL    $0, 0x68(DI)   //* _TEB.LastErrorValue = 0

    SUBQ    $(maxargs*8), SP    // room for args

    // Fast version, do not store args on the stack.
    CMPL    CX, $4
    JLE loadregs

    // Check we have enough room for args.
    CMPL    CX, $maxargs
    JLE 2(PC)
    INT $3          // not enough room -> crash

    // Copy args to the stack.
    MOVQ    SP, DI
    CLD
    REP; MOVSQ
    MOVQ    SP, SI

loadregs:
    // Load first 4 args into correspondent registers.
    MOVQ    0(SI), CX
    MOVQ    8(SI), DX
    MOVQ    16(SI), R8
    MOVQ    24(SI), R9
    // Floating point arguments are passed in the XMM
    // registers. Set them here in case any of the arguments
    // are floating point values. For details see
    //  https://msdn.microsoft.com/en-us/library/zthk2dkh.aspx
    MOVQ    CX, X0
    MOVQ    DX, X1
    MOVQ    R8, X2
    MOVQ    R9, X3

    // Call stdcall function.
    CALL    AX

    ADDQ    $(maxargs*8), SP

    // Return result.
    POPQ    CX
    MOVQ    AX, libcall_r1(CX)

    // GetLastError().
    MOVQ    0x30(GS), DI          //* DI = _NT_TIB.Self
    MOVL    0x68(DI), AX          //* AX = _TEB.LastErrorValue
    MOVQ    AX, libcall_err(CX)

    RET

このアセンブラは Go の処理系特有の記法で、この文法などについての最も詳しい説明は以下のリンク先のようです。

A Manual for the Plan 9 assembler

Rob Pike 先生が自ら書かれた説明のようですね。

多少特殊な記法はありますが、Intel 系のアセンブラに慣れた人なら特に問題なく読めるでしょう。

この最後の

 // GetLastError().
    MOVQ    0x30(GS), DI          //* DI = _NT_TIB.Self
    MOVL    0x68(DI), AX          //* AX = _TEB.LastErrorValue
    MOVQ    AX, libcall_err(CX)

の部分です。

//* で始まるコメントは私が追加したものです。

早い話が、Thread Information Block (TIB) またの名を Thread Environment Block (TEB) の LastErrorValue を取り出して、libcall.err に入れています。

libcall というのは↓のことで、

// https://github.com/golang/go/blob/master/src/runtime/runtime2.go#L295

type libcall struct {
    fn   uintptr
    n    uintptr // number of parameters
    args uintptr // parameters
    r1   uintptr // return values
    r2   uintptr
    err  uintptr // error number
}

これは m のメンバです。

type m struct {
  :
  
    syscall   libcall // stores syscall parameters on windows

  :
}

mg のメンバです。

// https://github.com/golang/go/blob/master/src/runtime/runtime2.go#L320

type g struct {
  :
  
    m              *m      // current m; offset known to arm liblink

  :

m とか g とかについては ↓ このあたりに良い説明があります。

niconegoto.hatenadiary.jp

要は、

// https://github.com/golang/go/blob/master/src/runtime/syscall_windows.go#L156

//go:linkname syscall_Syscall syscall.Syscall
//go:nosplit
func syscall_Syscall(fn, nargs, a1, a2, a3 uintptr) (r1, r2, err uintptr) {
    c := &getg().m.syscall

この cgmsyscall すなわち libcall 型なので先程のアセンブラ

 // GetLastError().
    MOVQ    0x30(GS), DI          //* DI = _NT_TIB.Self
    MOVL    0x68(DI), AX          //* AX = _TEB.LastErrorValue
    MOVQ    AX, libcall_err(CX)

では c.errLastError を入れてますよ、ということになります。

ちなみに TIB のメモリレイアウトに関しては以下のリンク先を参考にしました。

Win32 Thread Environment Block - Shit we found out

さて、そういうわけで procGetNamedSecurityInfoW.Call() の第 3 戻り値には LastError が入っていそうなものです。

Errno 型なので、以下の関数によって string に変換されるはずです。

// https://github.com/golang/go/blob/master/src/syscall/syscall_windows.go#L90

func (e Errno) Error() string {
    // deal with special go errors
    idx := int(e - APPLICATION_ERROR)
    if 0 <= idx && idx < len(errors) {
        return errors[idx]
    }
    // ask windows for the remaining errors
    var flags uint32 = FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_ARGUMENT_ARRAY | FORMAT_MESSAGE_IGNORE_INSERTS
    b := make([]uint16, 300)
    n, err := formatMessage(flags, 0, uint32(e), langid(LANG_ENGLISH, SUBLANG_ENGLISH_US), b, nil)
    if err != nil {
        n, err = formatMessage(flags, 0, uint32(e), 0, b, nil)
        if err != nil {
            return "winapi error #" + itoa(int(e))
        }
    }
    // trim terminating \r and \n
    for ; n > 0 && (b[n-1] == '\n' || b[n-1] == '\r'); n-- {
    }
    return string(utf16.Decode(b[:n]))
}

事前に定義済みの errors の中には “The operation completed successfully” という文字列は見つかりませんでしたので、おそらく formatMessage() 関数がその文言を常に返してきてしまっているという状況なのでしょう。

formatMessage()

// https://github.com/golang/go/blob/master/src/syscall/syscall_windows.go#L144

//sys   formatMessage(flags uint32, msgsrc uintptr, msgid uint32, langid uint32, buf []uint16, args *byte) (n uint32, err error) = FormatMessageW

という感じで Windows APIFormatMessageW を呼び出しているだけです。

ということは e が 0 なのか、、、

第 1 戻り値はちゃんとエラーの理由を表す値になっているようなので、もしかしたら GetNamedSecurityInfoW() Windows API は LastError を設定しない君なのでは?という可能性に気づき、やる気が萎えたのでここでおしまい。

ということでタイトルでは「バグっぽい」と煽り気味に書きましたが、Golang のライブラリのバグではなくて、Windows の仕様なのかもしれません。