完善 Windows 运维封装与 NTFS 索引解析

- 新增自启动幂等配置、统一错误语义、进程等待和进程树终止能力
- 增强服务生命周期管理,支持等待状态、重启、幂等创建和配置更新
- 新增 NTFS 卷索引、文件 ID 解析、文件遍历、USN 变更监听和 bookmark 持久化
- 修复 NTFS boot sector、fragment、MFT、USN 解析边界和路径重建问题
- 补充权限、进程、服务、NTFS 解析和工作流回归测试
- 增加 Windows 测试脚本和管理员 NTFS smoke 验证脚本
- 升级 Go 兼容版本到 1.18,并更新 stario、win32api 及相关间接依赖
This commit is contained in:
2026-06-09 15:59:31 +08:00
parent feb1a21da8
commit 7e6cc73106
31 changed files with 4937 additions and 981 deletions
+260 -223
View File
@@ -1,17 +1,12 @@
package mft
import (
"b612.me/wincmd/ntfs/binutil"
"b612.me/wincmd/ntfs/utf16"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"reflect"
"runtime"
"strings"
"time"
"unsafe"
)
type MFTFile struct {
@@ -22,126 +17,27 @@ type MFTFile struct {
Aszie uint64
IsDir bool
Node uint64
Parent uint64
}
type FileEntry struct {
Name string
Parent uint64
}
const (
defaultMFTRecordSize = int64(1024)
maxMFTBatchRecords = int64(1024)
)
func GetFileListsByMftFn(driver string, fn func(string, bool) bool) ([]MFTFile, error) {
var result []MFTFile
extendMftRecord := make(map[uint64][]Attribute)
fileMap := make(map[uint64]FileEntry)
f, size, err := GetMFTFile(driver)
reader, size, recordSize, err := openMFTFile(driver)
if err != nil {
return []MFTFile{}, err
}
recordSize := int64(1024)
alreadyGot := int64(0)
maxRecordSize := size / recordSize
if maxRecordSize > 1024 {
maxRecordSize = 1024
}
for {
for {
if (size - alreadyGot) < maxRecordSize*recordSize {
maxRecordSize--
} else {
break
}
}
if maxRecordSize < 10 {
maxRecordSize = 1
}
buf := make([]byte, maxRecordSize*recordSize)
got, err := io.ReadFull(f, buf)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return []MFTFile{}, err
}
alreadyGot += int64(got)
for j := int64(0); j < 1024*maxRecordSize; j += 1024 {
record, err := ParseRecord(buf[j : j+1024])
if err != nil {
continue
}
if record.BaseRecordReference.ToUint64() != 0 {
val := extendMftRecord[record.BaseRecordReference.ToUint64()]
for _, v := range record.Attributes {
if v.Type == AttributeTypeData && v.ActualSize != 0 {
val = append(val, v)
}
}
if len(val) != 0 {
extendMftRecord[record.BaseRecordReference.ToUint64()] = val
}
}
if record.Flags&RecordFlagInUse == 1 && record.Flags&RecordFlagIsIndex == 0 {
var file MFTFile
file.IsDir = record.Flags&RecordFlagIsDirectory != 0
file.Node = record.FileReference.ToUint64()
parent := uint64(0)
for _, v := range record.Attributes {
if v.Type == AttributeTypeData {
file.Size = v.ActualSize
file.Aszie = v.AllocatedSize
}
if v.Type == AttributeTypeStandardInformation {
if len(v.Data) >= 48 {
r := binutil.NewLittleEndianReader(v.Data)
file.ModTime = ConvertFileTime(r.Uint64(0x08))
}
}
if v.Type == AttributeTypeFileName {
name := utf16.DecodeString(v.Data[66:], binary.LittleEndian)
if len(file.Name) < len(name) && len(name) > 0 {
if len(file.Name) > 0 && !strings.Contains(file.Name, "~") {
continue
}
file.Name = name
}
if file.Name != "" {
parent = binutil.NewLittleEndianReader(v.Data[:8]).Uint64(0)
}
}
}
defer reader.Close()
if file.Name != "" {
canAdd := fn(file.Name, file.IsDir)
if canAdd {
result = append(result, file)
}
if canAdd || file.IsDir {
fileMap[uint64(file.Node)] = FileEntry{
Name: file.Name,
Parent: uint64(parent),
}
}
}
}
}
}
(*reflect.SliceHeader)(unsafe.Pointer(&result)).Cap = len(result)
for k, v := range result {
if attrs, ok := extendMftRecord[v.Node]; ok {
if v.Aszie == 0 {
for _, v := range attrs {
if v.Type == AttributeTypeData && v.ActualSize != 0 {
result[k].Size = v.ActualSize
result[k].Aszie = v.AllocatedSize
}
}
}
delete(extendMftRecord, v.Node)
}
result[k].Path = GetFullUsnPath(driver, fileMap, uint64(v.Node))
}
fileMap = nil
runtime.GC()
return result, nil
return collectMFTFiles(driver, reader, size, recordSize, fn)
}
func GetFileListsByMft(driver string) ([]MFTFile, error) {
@@ -149,129 +45,51 @@ func GetFileListsByMft(driver string) ([]MFTFile, error) {
}
func GetFileListsFromMftFileFn(filepath string, fn func(string, bool) bool) ([]MFTFile, error) {
var result []MFTFile
extendMftRecord := make(map[uint64][]Attribute)
fileMap := make(map[uint64]FileEntry)
f, err := os.Open(filepath)
if err != nil {
return []MFTFile{}, err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return []MFTFile{}, err
}
size := stat.Size()
recordSize := int64(1024)
alreadyGot := int64(0)
maxRecordSize := size / recordSize
if maxRecordSize > 1024 {
maxRecordSize = 1024
}
for {
for {
if (size - alreadyGot) < maxRecordSize*recordSize {
maxRecordSize--
} else {
break
}
}
if maxRecordSize < 10 {
maxRecordSize = 1
}
buf := make([]byte, maxRecordSize*recordSize)
got, err := io.ReadFull(f, buf)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return []MFTFile{}, err
}
alreadyGot += int64(got)
for j := int64(0); j < 1024*maxRecordSize; j += 1024 {
record, err := ParseRecord(buf[j : j+1024])
if err != nil {
continue
}
if record.BaseRecordReference.ToUint64() != 0 {
val := extendMftRecord[record.BaseRecordReference.ToUint64()]
for _, v := range record.Attributes {
if v.Type == AttributeTypeData && v.ActualSize != 0 {
val = append(val, v)
}
}
if len(val) != 0 {
extendMftRecord[record.BaseRecordReference.ToUint64()] = val
}
}
if record.Flags&RecordFlagInUse == 1 && record.Flags&RecordFlagIsIndex == 0 {
var file MFTFile
file.IsDir = record.Flags&RecordFlagIsDirectory != 0
file.Node = record.FileReference.ToUint64()
parent := uint64(0)
for _, v := range record.Attributes {
if v.Type == AttributeTypeData {
file.Size = v.ActualSize
file.Aszie = v.AllocatedSize
}
if v.Type == AttributeTypeStandardInformation {
if len(v.Data) >= 48 {
r := binutil.NewLittleEndianReader(v.Data)
file.ModTime = ConvertFileTime(r.Uint64(0x08))
}
}
if v.Type == AttributeTypeFileName {
name := utf16.DecodeString(v.Data[66:], binary.LittleEndian)
if len(file.Name) < len(name) && len(name) > 0 {
if len(file.Name) > 0 && !strings.Contains(file.Name, "~") {
continue
}
file.Name = name
}
if file.Name != "" {
parent = binutil.NewLittleEndianReader(v.Data[:8]).Uint64(0)
}
}
}
if file.Name != "" {
canAdd := fn(file.Name, file.IsDir)
if canAdd {
result = append(result, file)
}
if canAdd || file.IsDir {
fileMap[uint64(file.Node)] = FileEntry{
Name: file.Name,
Parent: uint64(parent),
}
}
}
}
}
}
(*reflect.SliceHeader)(unsafe.Pointer(&result)).Cap = len(result)
for k, v := range result {
if attrs, ok := extendMftRecord[v.Node]; ok {
if v.Aszie == 0 {
for _, v := range attrs {
if v.Type == AttributeTypeData && v.ActualSize != 0 {
result[k].Size = v.ActualSize
result[k].Aszie = v.AllocatedSize
}
}
}
delete(extendMftRecord, v.Node)
}
result[k].Path = GetFullUsnPath(" ", fileMap, uint64(v.Node))
}
fileMap = nil
runtime.GC()
return result, nil
return collectMFTFiles(" ", f, stat.Size(), defaultMFTRecordSize, fn)
}
func GetFileListsFromMftFile(filepath string) ([]MFTFile, error) {
return GetFileListsFromMftFileFn(filepath, func(string, bool) bool { return true })
}
// WalkRecordsByMFT walks parsed MFT records from a live NTFS volume.
func WalkRecordsByMFT(driver string, fn func(Record) error) error {
reader, size, recordSize, err := openMFTFile(driver)
if err != nil {
return err
}
defer reader.Close()
return walkRecords(reader, size, recordSize, ParseRecord, fn)
}
// WalkRecordsFromMFTFile walks parsed MFT records from a dumped $MFT file.
func WalkRecordsFromMFTFile(filepath string, fn func(Record) error) error {
f, err := os.Open(filepath)
if err != nil {
return err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return err
}
return walkRecords(f, stat.Size(), defaultMFTRecordSize, ParseRecord, fn)
}
func GetFullUsnPath(diskName string, fileMap map[uint64]FileEntry, id uint64) (name string) {
for id != 0 {
fe := fileMap[id]
@@ -289,3 +107,222 @@ func GetFullUsnPath(diskName string, fileMap map[uint64]FileEntry, id uint64) (n
name = diskName[:len(diskName)-1] + name
return
}
type extendedData struct {
Size uint64
AllocatedSize uint64
}
func collectMFTFiles(diskName string, reader io.Reader, size int64, recordSize int64, fn func(string, bool) bool) ([]MFTFile, error) {
if fn == nil {
fn = func(string, bool) bool { return true }
}
extendMFTRecord := make(map[uint64]extendedData)
fileMap := make(map[uint64]FileEntry)
result := make([]MFTFile, 0)
err := walkRecords(reader, size, recordSize, ParseRecord, func(record Record) error {
appendExtendedData(extendMFTRecord, record)
file, ok := FileFromRecord(record)
if !ok {
return nil
}
canAdd := fn(file.Name, file.IsDir)
if canAdd {
result = append(result, file)
}
if canAdd || file.IsDir {
fileMap[file.Node] = FileEntry{
Name: file.Name,
Parent: file.Parent,
}
}
return nil
})
if err != nil {
return nil, err
}
for i := range result {
if attrs, ok := extendMFTRecord[result[i].Node]; ok {
if result[i].Aszie == 0 {
applyExtendedData(&result[i], attrs)
}
delete(extendMFTRecord, result[i].Node)
}
result[i].Path = GetFullUsnPath(diskName, fileMap, result[i].Node)
}
return result, nil
}
func walkRecords(reader io.Reader, size int64, recordSize int64, parser func([]byte) (Record, error), visit func(Record) error) error {
if recordSize <= 0 {
return fmt.Errorf("invalid MFT record size %d", recordSize)
}
if recordSize > maxInt {
return fmt.Errorf("MFT record size %d overflows maximum int value %d", recordSize, maxInt)
}
if parser == nil {
return fmt.Errorf("nil MFT record parser")
}
if visit == nil {
return fmt.Errorf("nil MFT record visitor")
}
chunkSize := recordSize * maxMFTBatchRecords
if chunkSize <= 0 {
chunkSize = recordSize
}
if size > 0 && chunkSize > size {
chunkSize = size
}
if chunkSize <= 0 {
chunkSize = recordSize
}
intRecordSize := int(recordSize)
buf := make([]byte, int(chunkSize))
for {
got, err := io.ReadFull(reader, buf)
if err != nil {
if errors.Is(err, io.EOF) && got == 0 {
return nil
}
if !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return err
}
}
if got == 0 {
return nil
}
usable := got - got%intRecordSize
for offset := 0; offset < usable; offset += intRecordSize {
record, err := parser(buf[offset : offset+intRecordSize])
if err != nil {
continue
}
if err := visit(record); err != nil {
return err
}
}
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
return nil
}
}
}
func appendExtendedData(extended map[uint64]extendedData, record Record) {
baseRecord := record.BaseRecordReference.ToUint64()
if baseRecord == 0 {
return
}
for _, attr := range record.Attributes {
if attr.Type == AttributeTypeData && attr.ActualSize != 0 {
extended[baseRecord] = extendedData{
Size: attr.ActualSize,
AllocatedSize: attr.AllocatedSize,
}
}
}
}
// FileFromRecord extracts a high-level file entry from a parsed MFT record.
func FileFromRecord(record Record) (MFTFile, bool) {
if record.Flags&RecordFlagInUse == 0 || record.Flags&RecordFlagIsIndex != 0 {
return MFTFile{}, false
}
file := MFTFile{
IsDir: record.Flags&RecordFlagIsDirectory != 0,
Node: record.FileReference.ToUint64(),
}
bestNamespace := FileNameNamespace(0)
for _, attr := range record.Attributes {
switch attr.Type {
case AttributeTypeData:
file.Size = attr.ActualSize
file.Aszie = attr.AllocatedSize
case AttributeTypeStandardInformation:
info, err := ParseStandardInformation(attr.Data)
if err == nil {
file.ModTime = info.FileLastModified
}
case AttributeTypeFileName:
name, nameParent, namespace, ok := bestFileName(file.Name, bestNamespace, attr.Data)
if ok {
file.Name = name
file.Parent = nameParent
bestNamespace = namespace
}
}
}
if file.Name == "" {
return MFTFile{}, false
}
return file, true
}
func bestFileName(current string, currentNamespace FileNameNamespace, data []byte) (string, uint64, FileNameNamespace, bool) {
fileName, err := ParseFileName(data)
if err != nil || fileName.Name == "" {
return current, 0, currentNamespace, false
}
if !shouldPreferFileNameWithNamespace(current, currentNamespace, fileName.Name, fileName.Namespace) {
return current, 0, currentNamespace, false
}
return fileName.Name, fileName.ParentFileReference.ToUint64(), fileName.Namespace, true
}
func shouldPreferFileName(current string, candidate string) bool {
return shouldPreferFileNameWithNamespace(current, 0, candidate, 0)
}
func shouldPreferFileNameWithNamespace(current string, currentNamespace FileNameNamespace, candidate string, candidateNamespace FileNameNamespace) bool {
if candidate == "" {
return false
}
if current == "" {
return true
}
currentRank := fileNameNamespaceRank(currentNamespace)
candidateRank := fileNameNamespaceRank(candidateNamespace)
if currentRank != candidateRank {
return candidateRank > currentRank
}
currentShort := strings.Contains(current, "~")
candidateShort := strings.Contains(candidate, "~")
if currentShort != candidateShort {
return currentShort && !candidateShort
}
return len(candidate) > len(current)
}
func fileNameNamespaceRank(namespace FileNameNamespace) int {
switch namespace {
case FileNameNamespaceWin32, FileNameNamespaceWin32Dos:
return 3
case FileNameNamespacePosix:
return 2
case FileNameNamespaceDos:
return 1
default:
return 0
}
}
func applyExtendedData(file *MFTFile, data extendedData) {
file.Size = data.Size
file.Aszie = data.AllocatedSize
}