296 lines
8.5 KiB
Go
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))
|
||
|
|
}
|