wincmd/ntfs_index.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

296 lines
8.5 KiB
Go

package wincmd
import (
"context"
"encoding/binary"
"fmt"
"strings"
"syscall"
"time"
"unsafe"
"b612.me/win32api"
"b612.me/wincmd/ntfs/usn"
)
const (
watchUSNBufferHeaderSize = int(unsafe.Sizeof(win32api.USN(0)))
watchUSNRecordMinSize = int(unsafe.Offsetof(win32api.USN_RECORD{}.FileName))
watchUSNRecordOffsetUsn = int(unsafe.Offsetof(win32api.USN_RECORD{}.Usn))
watchUSNRecordOffsetFileReference = int(unsafe.Offsetof(win32api.USN_RECORD{}.FileReferenceNumber))
watchUSNRecordOffsetParentReference = int(unsafe.Offsetof(win32api.USN_RECORD{}.ParentFileReferenceNumber))
watchUSNRecordOffsetReason = int(unsafe.Offsetof(win32api.USN_RECORD{}.Reason))
watchUSNRecordOffsetFileAttributes = int(unsafe.Offsetof(win32api.USN_RECORD{}.FileAttributes))
watchUSNRecordOffsetFileNameLength = int(unsafe.Offsetof(win32api.USN_RECORD{}.FileNameLength))
watchUSNRecordOffsetFileNameOffset = int(unsafe.Offsetof(win32api.USN_RECORD{}.FileNameOffset))
)
// FileMeta is the unified file metadata model used by wincmd task-level APIs.
type FileMeta struct {
ID uint64
ParentID uint64
Name string
Path string
IsDir bool
Size uint64
ModTime time.Time
Source string
}
// IndexOptions controls how BuildVolumeIndex collects metadata.
type IndexOptions struct {
IncludeUSN bool
IncludeMFT bool
IncludeFileStat bool
MaxEntries int
Filter func(FileMeta) bool
}
// VolumeIndex is an in-memory query index for a volume.
type VolumeIndex struct {
Volume string
BuiltAt time.Time
BookmarkUSN uint64
ByID map[uint64]FileMeta
ByPath map[string]uint64
}
// ChangeEvent is a normalized USN change record.
type ChangeEvent struct {
USN uint64
Reason string
File FileMeta
At time.Time
}
// BuildVolumeIndex builds a unified file index from USN/MFT sources.
func BuildVolumeIndex(volume string, opts IndexOptions) (*VolumeIndex, error) {
return BuildVolumeIndexContext(context.Background(), volume, opts)
}
// ResolveFileByID resolves a file id to a canonical metadata entry.
func ResolveFileByID(volume string, id uint64) (FileMeta, error) {
vol, err := normalizeVolume(volume)
if err != nil {
return FileMeta{}, err
}
fileMap, err := usn.ListUsnFile(vol)
if err != nil {
return FileMeta{}, err
}
did := win32api.DWORDLONG(id)
entry, ok := fileMap[did]
if !ok {
return FileMeta{}, wrapNotFoundError(fmt.Sprintf("file id %d", id))
}
meta := FileMeta{
ID: id,
ParentID: uint64(entry.Parent),
Name: entry.Name,
Path: usn.GetFullUsnPath(vol, fileMap, did),
IsDir: entry.Type == 1,
Source: "usn",
}
if stat, err := usn.GetUsnFileInfo(vol, fileMap, did); err == nil {
meta.Size = uint64(stat.Size())
meta.ModTime = stat.ModTime()
if stat.IsDir() {
meta.IsDir = true
}
}
return meta, nil
}
// WalkFiles streams files from USN and invokes callback for each matching entry.
func WalkFiles(volume string, filter func(FileMeta) bool, fn func(FileMeta) error) error {
return WalkFilesContext(context.Background(), volume, filter, fn)
}
// WatchVolumeChanges consumes one or more USN batches from a bookmark and emits normalized events.
// If fromUSN is 0, it tails from the journal's current NextUsn (new changes only).
func WatchVolumeChanges(volume string, fromUSN uint64, fn func(ChangeEvent) error) (nextUSN uint64, err error) {
return WatchVolumeChangesContext(context.Background(), volume, fromUSN, fn)
}
type usnWatchRecord struct {
Usn uint64
FileReferenceNumber uint64
ParentFileReferenceNumber uint64
Reason uint32
FileAttributes uint32
FileName string
}
func parseWatchUSNRecords(buf []byte, done uint32, fn func(usnWatchRecord) error) error {
for offset := watchUSNBufferHeaderSize; offset < int(done); {
remaining := int(done) - offset
if remaining < watchUSNRecordMinSize {
return fmt.Errorf("usn record header truncated: remaining=%d", remaining)
}
recordLength := int(binary.LittleEndian.Uint32(buf[offset:]))
if recordLength < watchUSNRecordMinSize {
return fmt.Errorf("invalid usn record length %d", recordLength)
}
if recordLength > remaining {
return fmt.Errorf("usn record length %d exceeds remaining %d", recordLength, remaining)
}
record := buf[offset : offset+recordLength]
nameLen := int(binary.LittleEndian.Uint16(record[watchUSNRecordOffsetFileNameLength:]))
nameOff := int(binary.LittleEndian.Uint16(record[watchUSNRecordOffsetFileNameOffset:]))
if nameLen < 0 || nameLen%2 != 0 {
return fmt.Errorf("invalid usn name length %d", nameLen)
}
if nameOff < watchUSNRecordMinSize || nameOff > recordLength {
return fmt.Errorf("invalid usn name offset %d", nameOff)
}
if nameOff+nameLen > recordLength {
return fmt.Errorf("usn name out of record boundary")
}
fileName, err := decodeUTF16Bytes(record[nameOff : nameOff+nameLen])
if err != nil {
return err
}
event := usnWatchRecord{
Usn: binary.LittleEndian.Uint64(record[watchUSNRecordOffsetUsn:]),
FileReferenceNumber: binary.LittleEndian.Uint64(record[watchUSNRecordOffsetFileReference:]),
ParentFileReferenceNumber: binary.LittleEndian.Uint64(record[watchUSNRecordOffsetParentReference:]),
Reason: binary.LittleEndian.Uint32(record[watchUSNRecordOffsetReason:]),
FileAttributes: binary.LittleEndian.Uint32(record[watchUSNRecordOffsetFileAttributes:]),
FileName: fileName,
}
if err := fn(event); err != nil {
return err
}
offset += recordLength
}
return nil
}
func decodeUTF16Bytes(data []byte) (string, error) {
if len(data)%2 != 0 {
return "", fmt.Errorf("invalid utf16 byte length %d", len(data))
}
chars := make([]uint16, len(data)/2)
for i := range chars {
chars[i] = binary.LittleEndian.Uint16(data[i*2:])
}
return syscall.UTF16ToString(chars), nil
}
func currentUSNBookmark(volume string) (uint64, error) {
pDriver := `\\.\` + strings.TrimSuffix(volume, `\`)
fd, err := usn.CreateFile(pDriver, syscall.O_RDONLY, win32api.FILE_ATTRIBUTE_NORMAL)
if err != nil {
return 0, err
}
defer syscall.Close(fd)
var journal win32api.USN_JOURNAL_DATA
var done uint32
if err := usn.DeviceIoControl(fd, win32api.FSCTL_QUERY_USN_JOURNAL, []byte{}, &journal, &done); err != nil {
return 0, err
}
return uint64(journal.NextUsn), nil
}
func normalizeVolume(volume string) (string, error) {
v := strings.TrimSpace(strings.ReplaceAll(volume, "/", "\\"))
if v == "" {
return "", wrapInputError("empty volume")
}
if len(v) == 2 && v[1] == ':' {
v += "\\"
}
if len(v) < 3 || v[1] != ':' {
return "", wrapVolumeError(volume, nil)
}
if v[len(v)-1] != '\\' {
v += "\\"
}
return strings.ToUpper(v[:1]) + v[1:], nil
}
func normalizePathKey(path string) string {
if path == "" {
return ""
}
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(path), "/", "\\"))
}
func indexMergeMeta(idx *VolumeIndex, incoming FileMeta, maxEntries int) {
if idx == nil {
return
}
if current, ok := idx.ByID[incoming.ID]; ok {
merged := mergeFileMeta(current, incoming)
idx.ByID[incoming.ID] = merged
if key := normalizePathKey(merged.Path); key != "" {
idx.ByPath[key] = merged.ID
}
return
}
if maxEntries > 0 && len(idx.ByID) >= maxEntries {
return
}
idx.ByID[incoming.ID] = incoming
if key := normalizePathKey(incoming.Path); key != "" {
idx.ByPath[key] = incoming.ID
}
}
func mergeFileMeta(current FileMeta, incoming FileMeta) FileMeta {
merged := current
if merged.Name == "" {
merged.Name = incoming.Name
}
if merged.Path == "" {
merged.Path = incoming.Path
}
if merged.ParentID == 0 {
merged.ParentID = incoming.ParentID
}
if incoming.IsDir {
merged.IsDir = true
}
if merged.Size == 0 && incoming.Size != 0 {
merged.Size = incoming.Size
}
if merged.ModTime.IsZero() && !incoming.ModTime.IsZero() {
merged.ModTime = incoming.ModTime
}
if merged.Source == "" {
merged.Source = incoming.Source
} else if incoming.Source != "" && merged.Source != incoming.Source && !strings.Contains(merged.Source, incoming.Source) {
merged.Source = merged.Source + "+" + incoming.Source
}
return merged
}
func applyIndexFilter(idx *VolumeIndex, filter func(FileMeta) bool) {
if idx == nil || filter == nil {
return
}
for id, meta := range idx.ByID {
if filter(meta) {
continue
}
delete(idx.ByID, id)
if key := normalizePathKey(meta.Path); key != "" {
delete(idx.ByPath, key)
}
}
}
func usnReasonString(reason uint32) string {
return usn.USNReasonString(win32api.DWORD(reason))
}