Go のプログラムで Windows 上で chmod しようとしただけなのになぜか golang.org/x/sys/windows にバグっぽい挙動を見つけたばかりかなぜかアセンブラを読むハメになった話
何を言ってるかわか(ry
ファイルのパーミッションを変更する chmod
コマンド相当の関数は、私の知る限り Golang の標準ライブラリには 2 つあって、しかもそのいずれもが Windows 上では期待したような動作にならないって、みなさん知っていましたか?
まずひとつめ、os.Chmod() 、これは内部的に syscall.Chmod()
を呼んでいます。
syscall.Chmod()
は このあたり に実装があって(本記事執筆時点)、ソースコードをご覧頂いてもわかるように Windows API の SetFileAttributes()
を呼んでファイルの ReadOnly 属性を設定したり落としたりしているだけです。
魚拓:
なので、os.Chmod(path, 0600)
とかやっても、もともと他の人が読み書きできる設定になってるファイルはそのまま他の人が読み書きできる状態が維持されてしまいます。
びっくりしますね。
続いてふたつめ、 func (*os.File) Chmod() です。これは内部的に (*poll.FD) Fchmod()
を呼んでいて、そこから syscall.Fchmod()
が呼び出されます。こちらの実装は常に EWINDOWS
という値を返すようになっています。つまり常にエラーです。
魚拓:
なので、var f *os.File
な f
に対して f.Chmod(0600)
とかやっても、エラーになるだけでファイルのパーミッションは何も変わりません。
このあたりで、「Windows には ACL (Access Control Lists) というものがあったな・・・POSIX/UNIX のシンプルなファイルパーミッションとは根本的に仕組みが違うのかな・・・」ということに気づいちゃいます。
ということで、「じゃあ Golang で Windows の ACL が操作できるライブラリがあればいいじゃないか。きっとあるはずだ。」と思っておもむろにグーグル先生に聞いてみるわけじゃないですか。
するといくつかライブラリが見つかりますが、私がやりたいことができそうだったのが以下のライブラリです。
しかも chmod.go という名前の、そのものズバリなファイル(そしてその中に Chmod()
関数)まであるじゃありませんか。
ということでこいつを使ってみます。
すると、ほぼ期待したような動作をしてくれるようです。 「いいじゃないか、こいつを採用だ」と思った時に、一つ問題が見つかりました。
この go-acl の Chmod()
は、エラーが起きたとき(たとえば存在しないファイルに対して呼び出したときなど)に、error
を返してくれるのは良いのですが、error.Error()
でそのエラーメッセージを取り出すと、常に
The operation completed successfully.
という文字列が取得されるのです。
なぜだ、、、
ということで Deep dive が始まります。
go-acl の Chmod()
は内部で同じパッケージの 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()
でロードした procGetNamedSecurityInfoW
は windows.Proc
型です。
procGetNamedSecurityInfoW.Call()
の 3 番目の戻り値に問題がありそうです。
windows.Proc
の Call()
関数は以下のような定義になっています。
// 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 : }
m
は g
のメンバです。
// 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
とかについては ↓ このあたりに良い説明があります。
要は、
// 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
は g
の m
の syscall
すなわち libcall
型なので先程のアセンブラ
// GetLastError(). MOVQ 0x30(GS), DI //* DI = _NT_TIB.Self MOVL 0x68(DI), AX //* AX = _TEB.LastErrorValue MOVQ AX, libcall_err(CX)
では c.err
に LastError
を入れてますよ、ということになります。
ちなみに 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 API の FormatMessageW
を呼び出しているだけです。
ということは e
が 0 なのか、、、
第 1 戻り値はちゃんとエラーの理由を表す値になっているようなので、もしかしたら GetNamedSecurityInfoW()
Windows API は LastError を設定しない君なのでは?という可能性に気づき、やる気が萎えたのでここでおしまい。
ということでタイトルでは「バグっぽい」と煽り気味に書きましたが、Golang のライブラリのバグではなくて、Windows の仕様なのかもしれません。