wincmd/ntfs/usn/osio_test.go
starainrt 7e6cc73106
完善 Windows 运维封装与 NTFS 索引解析
- 新增自启动幂等配置、统一错误语义、进程等待和进程树终止能力
- 增强服务生命周期管理,支持等待状态、重启、幂等创建和配置更新
- 新增 NTFS 卷索引、文件 ID 解析、文件遍历、USN 变更监听和 bookmark 持久化
- 修复 NTFS boot sector、fragment、MFT、USN 解析边界和路径重建问题
- 补充权限、进程、服务、NTFS 解析和工作流回归测试
- 增加 Windows 测试脚本和管理员 NTFS smoke 验证脚本
- 升级 Go 兼容版本到 1.18,并更新 stario、win32api 及相关间接依赖
2026-06-09 15:59:31 +08:00

401 lines
12 KiB
Go

package usn
import (
"encoding/binary"
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"unicode/utf16"
"b612.me/win32api"
)
func TestGetPointerUsesSliceLength(t *testing.T) {
buf := make([]uint16, 3, 16)
_, size, err := getPointer(buf)
if err != nil {
t.Fatalf("getPointer failed: %v", err)
}
if want := uintptr(len(buf)) * uintptr(2); size != want {
t.Fatalf("slice size = %d, want %d", size, want)
}
}
func TestParseUSNOutput(t *testing.T) {
buf := buildTestUSNBuffer(1234, "hello.txt", false, 0x20)
var got usnRecordData
next, err := parseUSNOutput(buf, uint32(len(buf)), func(record usnRecordData) error {
got = record
return nil
})
if err != nil {
t.Fatalf("parseUSNOutput failed: %v", err)
}
if next != 1234 {
t.Fatalf("next = %d, want 1234", next)
}
if got.FileName != "hello.txt" {
t.Fatalf("FileName = %q, want %q", got.FileName, "hello.txt")
}
if got.FileReferenceNumber != 100 {
t.Fatalf("FileReferenceNumber = %d, want 100", got.FileReferenceNumber)
}
if got.ParentFileReferenceNumber != 55 {
t.Fatalf("ParentFileReferenceNumber = %d, want 55", got.ParentFileReferenceNumber)
}
if got.Reason != 0x20 {
t.Fatalf("Reason = %#x, want %#x", got.Reason, 0x20)
}
}
func TestParseUSNOutputRejectsShortRecord(t *testing.T) {
buf := buildTestUSNBuffer(1, "bad", false, 0)
binary.LittleEndian.PutUint32(buf[usnBufferHeaderSize:], uint32(usnRecordMinSize-2))
if _, err := parseUSNOutput(buf, uint32(len(buf)), func(usnRecordData) error { return nil }); err == nil {
t.Fatal("expected parseUSNOutput to reject short record")
}
}
func TestShouldPreferUSNFileName(t *testing.T) {
tests := []struct {
current string
candidate string
want bool
}{
{current: "", candidate: "Program Files", want: true},
{current: "PROGRA~1", candidate: "Program Files", want: true},
{current: "Program Files", candidate: "PROGRA~1", want: false},
{current: "abc", candidate: "abcdef", want: true},
{current: "abcdef", candidate: "abc", want: false},
{current: "Program Files", candidate: "program files", want: false},
}
for _, tt := range tests {
if got := shouldPreferUSNFileName(tt.current, tt.candidate); got != tt.want {
t.Fatalf("shouldPreferUSNFileName(%q, %q) = %v, want %v", tt.current, tt.candidate, got, tt.want)
}
}
}
func TestMergeUSNFileEntryPrefersLongName(t *testing.T) {
current := FileEntry{Name: "PROGRA~1", Parent: 7}
candidate := FileEntry{Name: "Program Files", Parent: 9}
merged := mergeUSNFileEntry(current, candidate)
if merged.Name != "Program Files" {
t.Fatalf("Name = %q, want %q", merged.Name, "Program Files")
}
if merged.Parent != 9 {
t.Fatalf("Parent = %d, want 9", merged.Parent)
}
}
func TestMergeUSNFileEntryTracksRename(t *testing.T) {
current := FileEntry{Name: "alpha.txt", Parent: 7}
candidate := FileEntry{Name: "omega.txt", Parent: 7}
merged := mergeUSNFileEntry(current, candidate)
if merged.Name != "omega.txt" {
t.Fatalf("Name = %q, want %q", merged.Name, "omega.txt")
}
}
func TestFilterUSNFileMapUsesFinalName(t *testing.T) {
fileMap := map[win32api.DWORDLONG]FileEntry{
1: {Name: "Windows", Parent: 1, Type: 1},
2: {Name: "Program Files", Parent: 1, Type: 0},
3: {Name: "Temp", Parent: 1, Type: 0},
}
filtered := filterUSNFileMap(fileMap, func(name string, _ bool) bool {
return strings.Contains(name, "Program")
})
if _, ok := filtered[1]; !ok {
t.Fatal("expected directory entry to be retained")
}
if _, ok := filtered[2]; !ok {
t.Fatal("expected matching file entry to be retained")
}
if _, ok := filtered[3]; ok {
t.Fatal("did not expect non-matching file entry to be retained")
}
}
func TestNeedPathCanonicalNameOverlay(t *testing.T) {
if needPathCanonicalNameOverlay(map[win32api.DWORDLONG]FileEntry{
1: {Name: "Program Files", Parent: 1},
}) {
t.Fatal("did not expect overlay for long names only")
}
if !needPathCanonicalNameOverlay(map[win32api.DWORDLONG]FileEntry{
1: {Name: "PROGRA~1", Parent: 1},
}) {
t.Fatal("expected overlay when short name exists")
}
}
func TestWindowsBaseName(t *testing.T) {
if got := windowsBaseName(`C:\Program Files\`); got != "Program Files" {
t.Fatalf("windowsBaseName returned %q", got)
}
if got := windowsBaseName(`C:\Windows\System32`); got != "System32" {
t.Fatalf("windowsBaseName returned %q", got)
}
if got := windowsBaseName(`single`); got != "single" {
t.Fatalf("windowsBaseName returned %q", got)
}
}
func TestApplyPathCanonicalNamesUsesNormalizedPath(t *testing.T) {
origNormalize := normalizePathForUSN
defer func() {
normalizePathForUSN = origNormalize
}()
normalizePathForUSN = func(path string) string {
if strings.Contains(path, "PROGRA~1") {
return strings.Replace(path, "PROGRA~1", "Program Files", 1)
}
return path
}
fileMap := map[win32api.DWORDLONG]FileEntry{
1: {Name: "", Parent: 1, Type: 1},
2: {Name: "PROGRA~1", Parent: 1, Type: 0},
}
applyPathCanonicalNames("C:\\", fileMap)
entry := fileMap[2]
if entry.Name != "Program Files" {
t.Fatalf("Name = %q, want %q", entry.Name, "Program Files")
}
if entry.Parent != 1 {
t.Fatalf("Parent = %d, want 1", entry.Parent)
}
}
func TestApplyPathCanonicalNamesSkipsWhenNotNeeded(t *testing.T) {
origNormalize := normalizePathForUSN
defer func() {
normalizePathForUSN = origNormalize
}()
called := false
normalizePathForUSN = func(path string) string {
called = true
return path
}
fileMap := map[win32api.DWORDLONG]FileEntry{
2: {Name: "Program Files", Parent: 1, Type: 0},
}
applyPathCanonicalNames("C:\\", fileMap)
if called {
t.Fatal("did not expect normalization when no short names exist")
}
}
func TestFileStatFromIDWithfd(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "usn-by-id.txt")
content := []byte("usn by id test")
if err := os.WriteFile(path, content, 0600); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
volume := filepath.VolumeName(path) + `\`
info, err := GetDiskInfo(volume)
if err != nil {
t.Fatalf("GetDiskInfo failed: %v", err)
}
if !strings.EqualFold(info.Format, "NTFS") {
t.Skipf("volume %s is %s, not NTFS", volume, info.Format)
}
file, err := os.Open(path)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
defer file.Close()
var handleInfo syscall.ByHandleFileInformation
if err := syscall.GetFileInformationByHandle(syscall.Handle(file.Fd()), &handleInfo); err != nil {
t.Fatalf("GetFileInformationByHandle failed: %v", err)
}
volumeHandle, err := CreateFile(`\\.\`+volume[:len(volume)-1], syscall.O_RDONLY, win32api.FILE_ATTRIBUTE_NORMAL)
if err != nil {
if errors.Is(err, syscall.ERROR_ACCESS_DENIED) {
t.Skipf("opening volume handle requires extra privilege: %v", err)
}
t.Fatalf("CreateFile(volume) failed: %v", err)
}
defer syscall.Close(volumeHandle)
fileID := win32api.DWORDLONG(uint64(handleInfo.FileIndexHigh)<<32 | uint64(handleInfo.FileIndexLow))
stat, err := fileStatFromIDWithfd(volumeHandle, fileID, filepath.Base(path), path, 0)
if err != nil {
t.Fatalf("fileStatFromIDWithfd failed: %v", err)
}
if stat.Name() != filepath.Base(path) {
t.Fatalf("Name = %q, want %q", stat.Name(), filepath.Base(path))
}
if stat.Size() != int64(len(content)) {
t.Fatalf("Size = %d, want %d", stat.Size(), len(content))
}
if stat.vol != handleInfo.VolumeSerialNumber || stat.idxhi != handleInfo.FileIndexHigh || stat.idxlo != handleInfo.FileIndexLow {
t.Fatal("file identifiers do not match source handle info")
}
}
func TestCollectUSNFileStatsSkipsFailedFetch(t *testing.T) {
data := map[win32api.DWORDLONG]FileEntry{
1: {Name: "keep-a.txt", Parent: 1, Type: 0},
2: {Name: "drop-b.txt", Parent: 1, Type: 0},
3: {Name: "keep-c", Parent: 1, Type: 1},
}
got := collectUSNFileStats(data, nil, func(id win32api.DWORDLONG, entry FileEntry) (FileStat, error) {
if id == 2 {
return FileStat{}, errors.New("fetch failed")
}
stat := FileStat{name: entry.Name}
if entry.Type == 1 {
stat.FileAttributes = win32api.FILE_ATTRIBUTE_DIRECTORY
}
return stat, nil
})
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
names := map[string]bool{}
for _, stat := range got {
names[stat.Name()] = true
if stat.Name() == "" {
t.Fatal("expected failed fetch entries to be skipped instead of zero-value placeholders")
}
}
if !names["keep-a.txt"] || !names["keep-c"] {
t.Fatalf("unexpected names: %+v", names)
}
if names["drop-b.txt"] {
t.Fatal("did not expect failed fetch entry in results")
}
}
func TestCollectUSNFileStatsAppliesFilter(t *testing.T) {
data := map[win32api.DWORDLONG]FileEntry{
1: {Name: "keep-file.txt", Parent: 1, Type: 0},
2: {Name: "skip-file.txt", Parent: 1, Type: 0},
3: {Name: "keep-dir", Parent: 1, Type: 1},
}
got := collectUSNFileStats(data, func(name string, _ bool) bool {
return strings.HasPrefix(name, "keep-")
}, func(_ win32api.DWORDLONG, entry FileEntry) (FileStat, error) {
stat := FileStat{name: entry.Name}
if entry.Type == 1 {
stat.FileAttributes = win32api.FILE_ATTRIBUTE_DIRECTORY
}
return stat, nil
})
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
for _, stat := range got {
if !strings.HasPrefix(stat.Name(), "keep-") {
t.Fatalf("unexpected stat name %q", stat.Name())
}
}
}
func TestCollectUSNFileStatsNilFilterIncludesAll(t *testing.T) {
data := map[win32api.DWORDLONG]FileEntry{
1: {Name: "a.txt", Parent: 1, Type: 0},
2: {Name: "b.txt", Parent: 1, Type: 0},
3: {Name: "c", Parent: 1, Type: 1},
}
got := collectUSNFileStats(data, nil, func(_ win32api.DWORDLONG, entry FileEntry) (FileStat, error) {
return FileStat{name: entry.Name}, nil
})
if len(got) != len(data) {
t.Fatalf("len(got) = %d, want %d", len(got), len(data))
}
}
func TestCollectUSNFileStatsNilFetchReturnsEmpty(t *testing.T) {
data := map[win32api.DWORDLONG]FileEntry{
1: {Name: "a.txt", Parent: 1, Type: 0},
2: {Name: "b.txt", Parent: 1, Type: 0},
}
got := collectUSNFileStats(data, nil, nil)
if len(got) != 0 {
t.Fatalf("len(got) = %d, want 0", len(got))
}
}
func buildTestUSNBuffer(next uint64, name string, isDir bool, reason uint32) []byte {
encoded := utf16.Encode([]rune(name))
nameBytes := make([]byte, len(encoded)*2)
for i, v := range encoded {
binary.LittleEndian.PutUint16(nameBytes[i*2:], v)
}
recordLength := usnRecordMinSize + len(nameBytes)
buf := make([]byte, usnBufferHeaderSize+recordLength)
binary.LittleEndian.PutUint64(buf[:usnBufferHeaderSize], next)
record := buf[usnBufferHeaderSize:]
binary.LittleEndian.PutUint32(record, uint32(recordLength))
binary.LittleEndian.PutUint16(record[4:], 2)
binary.LittleEndian.PutUint16(record[6:], 0)
binary.LittleEndian.PutUint64(record[usnRecordOffsetFileReference:], 100)
binary.LittleEndian.PutUint64(record[usnRecordOffsetParentReference:], 55)
binary.LittleEndian.PutUint32(record[usnRecordOffsetReason:], reason)
attrs := uint32(0)
if isDir {
attrs = win32api.FILE_ATTRIBUTE_DIRECTORY
}
binary.LittleEndian.PutUint32(record[usnRecordOffsetFileAttributes:], attrs)
binary.LittleEndian.PutUint16(record[usnRecordOffsetFileNameLength:], uint16(len(nameBytes)))
binary.LittleEndian.PutUint16(record[usnRecordOffsetFileNameOffset:], uint16(usnRecordMinSize))
copy(record[usnRecordMinSize:], nameBytes)
return buf
}
func TestNormalizeDiskName(t *testing.T) {
tests := map[string]string{
"c:": "C:\\",
"c:\\temp": "C:\\",
"D:/data": "D:\\",
}
for input, want := range tests {
got, err := normalizeDiskName(input)
if err != nil {
t.Fatalf("normalizeDiskName(%q) returned error: %v", input, err)
}
if got != want {
t.Fatalf("normalizeDiskName(%q) = %q, want %q", input, got, want)
}
}
if _, err := normalizeDiskName(""); err == nil {
t.Fatal("expected empty disk name error")
}
if _, err := normalizeDiskName("not-a-drive"); err == nil {
t.Fatal("expected invalid disk name error")
}
}
func TestUSNReasonStringUnknownHighBitDoesNotPanic(t *testing.T) {
got := USNReasonString(0x80000000)
if got == "" {
t.Fatal("expected non-empty reason string")
}
}