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 の仕様なのかもしれません。