2022-03-09 13:42:01 +08:00
|
|
|
package usn
|
2021-11-15 17:25:04 +08:00
|
|
|
|
|
|
|
|
import (
|
2026-06-09 15:59:31 +08:00
|
|
|
"encoding/binary"
|
|
|
|
|
"errors"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"syscall"
|
2021-11-15 17:25:04 +08:00
|
|
|
"testing"
|
2026-06-09 15:59:31 +08:00
|
|
|
"unicode/utf16"
|
|
|
|
|
|
|
|
|
|
"b612.me/win32api"
|
2021-11-15 17:25:04 +08:00
|
|
|
)
|
|
|
|
|
|
2026-06-09 15:59:31 +08:00
|
|
|
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")
|
|
|
|
|
}
|
2021-11-15 17:25:04 +08:00
|
|
|
}
|