完善 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
+197 -110
View File
@@ -9,8 +9,14 @@ import (
"b612.me/wincmd/ntfs/utf16"
)
var (
reallyStrangeEpoch = time.Date(1601, time.January, 1, 0, 0, 0, 0, time.UTC)
const (
minStandardInformationLength = 48
minFileNameLength = 66
minAttributeListEntryLength = 26
minIndexRootLength = 32
minIndexEntryLength = 13
indexRootHeaderLength = 16
indexRootEntryOffset = 0x20
)
// StandardInformation represents the data contained in a $STANDARD_INFORMATION attribute.
@@ -33,27 +39,12 @@ type StandardInformation struct {
// AttributeTypeStandardInformation) into StandardInformation. Note that no additional correctness checks are done, so
// it's up to the caller to ensure the passed data actually represents a $STANDARD_INFORMATION attribute's data.
func ParseStandardInformation(b []byte) (StandardInformation, error) {
if len(b) < 48 {
return StandardInformation{}, fmt.Errorf("expected at least %d bytes but got %d", 48, len(b))
if len(b) < minStandardInformationLength {
return StandardInformation{}, fmt.Errorf("expected at least %d bytes but got %d", minStandardInformationLength, len(b))
}
r := binutil.NewLittleEndianReader(b)
ownerId := uint32(0)
securityId := uint32(0)
quotaCharged := uint64(0)
updateSequenceNumber := uint64(0)
if len(b) >= 0x30+4 {
ownerId = r.Uint32(0x30)
}
if len(b) >= 0x34+4 {
securityId = r.Uint32(0x34)
}
if len(b) >= 0x38+8 {
quotaCharged = r.Uint64(0x38)
}
if len(b) >= 0x40+8 {
updateSequenceNumber = r.Uint64(0x40)
}
ownerId, securityId, quotaCharged, updateSequenceNumber := parseStandardInformationTail(r, len(b))
return StandardInformation{
Creation: ConvertFileTime(r.Uint64(0x00)),
FileLastModified: ConvertFileTime(r.Uint64(0x08)),
@@ -70,6 +61,22 @@ func ParseStandardInformation(b []byte) (StandardInformation, error) {
}, nil
}
func parseStandardInformationTail(r *binutil.BinReader, length int) (ownerID uint32, securityID uint32, quotaCharged uint64, updateSequenceNumber uint64) {
if length >= 0x30+4 {
ownerID = r.Uint32(0x30)
}
if length >= 0x34+4 {
securityID = r.Uint32(0x34)
}
if length >= 0x38+8 {
quotaCharged = r.Uint64(0x38)
}
if length >= 0x40+8 {
updateSequenceNumber = r.Uint64(0x40)
}
return ownerID, securityID, quotaCharged, updateSequenceNumber
}
// FileAttribute represents a bit mask of various file attributes.
type FileAttribute uint32
@@ -84,7 +91,7 @@ const (
FileAttributeTemporary FileAttribute = 0x0100
FileAttributeSparseFile FileAttribute = 0x0200
FileAttributeReparsePoint FileAttribute = 0x0400
FileAttributeCompressed FileAttribute = 0x1000
FileAttributeCompressed FileAttribute = 0x0800
FileAttributeOffline FileAttribute = 0x1000
FileAttributeNotContentIndexed FileAttribute = 0x2000
FileAttributeEncrypted FileAttribute = 0x4000
@@ -127,12 +134,12 @@ type FileName struct {
// no additional correctness checks are done, so it's up to the caller to ensure the passed data actually represents a
// $FILE_NAME attribute's data.
func ParseFileName(b []byte) (FileName, error) {
if len(b) < 66 {
return FileName{}, fmt.Errorf("expected at least %d bytes but got %d", 66, len(b))
if len(b) < minFileNameLength {
return FileName{}, fmt.Errorf("expected at least %d bytes but got %d", minFileNameLength, len(b))
}
fileNameLength := int(b[0x40 : 0x40+1][0]) * 2
minExpectedSize := 66 + fileNameLength
minExpectedSize := minFileNameLength + fileNameLength
if len(b) < minExpectedSize {
return FileName{}, fmt.Errorf("expected at least %d bytes but got %d", minExpectedSize, len(b))
}
@@ -172,41 +179,69 @@ type AttributeListEntry struct {
// list of AttributeListEntry. Note that no additional correctness checks are done, so it's up to the caller to ensure
// the passed data actually represents a $ATTRIBUTE_LIST attribute's data.
func ParseAttributeList(b []byte) ([]AttributeListEntry, error) {
if len(b) < 26 {
return []AttributeListEntry{}, fmt.Errorf("expected at least %d bytes but got %d", 26, len(b))
if len(b) < minAttributeListEntryLength {
return []AttributeListEntry{}, fmt.Errorf("expected at least %d bytes but got %d", minAttributeListEntryLength, len(b))
}
entries := make([]AttributeListEntry, 0)
for len(b) > 0 {
r := binutil.NewLittleEndianReader(b)
entryLength := int(r.Uint16(0x04))
if len(b) < entryLength {
return entries, fmt.Errorf("expected at least %d bytes remaining for AttributeList entry but is %d", entryLength, len(b))
}
nameLength := int(r.Byte(0x06))
name := ""
if nameLength != 0 {
nameOffset := int(r.Byte(0x07))
name = utf16.DecodeString(r.Read(nameOffset, nameLength*2), binary.LittleEndian)
}
baseRef, err := ParseFileReference(r.Read(0x10, 8))
entry, entryLength, err := parseAttributeListEntry(b)
if err != nil {
return entries, fmt.Errorf("unable to parse base record reference: %v", err)
}
entry := AttributeListEntry{
Type: AttributeType(r.Uint32(0)),
Name: name,
StartingVCN: r.Uint64(0x08),
BaseRecordReference: baseRef,
AttributeId: r.Uint16(0x18),
return entries, err
}
entries = append(entries, entry)
b = r.ReadFrom(entryLength)
b = b[entryLength:]
}
return entries, nil
}
func parseAttributeListEntry(b []byte) (AttributeListEntry, int, error) {
if len(b) < minAttributeListEntryLength {
return AttributeListEntry{}, 0, fmt.Errorf("expected at least %d bytes but got %d", minAttributeListEntryLength, len(b))
}
r := binutil.NewLittleEndianReader(b)
entryLength := int(r.Uint16(0x04))
if entryLength < minAttributeListEntryLength {
return AttributeListEntry{}, 0, fmt.Errorf("attribute list entry length %d is smaller than minimum %d", entryLength, minAttributeListEntryLength)
}
if len(b) < entryLength {
return AttributeListEntry{}, 0, fmt.Errorf("expected at least %d bytes remaining for AttributeList entry but is %d", entryLength, len(b))
}
name, err := parseAttributeListEntryName(r, b, entryLength)
if err != nil {
return AttributeListEntry{}, 0, err
}
baseRef, err := ParseFileReference(r.Read(0x10, 8))
if err != nil {
return AttributeListEntry{}, 0, fmt.Errorf("unable to parse base record reference: %v", err)
}
return AttributeListEntry{
Type: AttributeType(r.Uint32(0)),
Name: name,
StartingVCN: r.Uint64(0x08),
BaseRecordReference: baseRef,
AttributeId: r.Uint16(0x18),
}, entryLength, nil
}
func parseAttributeListEntryName(r *binutil.BinReader, b []byte, entryLength int) (string, error) {
nameLength := int(r.Byte(0x06))
if nameLength == 0 {
return "", nil
}
nameOffset := int(r.Byte(0x07))
nameEnd := nameOffset + nameLength*2
if nameEnd > entryLength || nameEnd > len(b) {
return "", fmt.Errorf("attribute list entry name exceeds entry boundary: offset=%d length=%d entry=%d", nameOffset, nameLength*2, entryLength)
}
return utf16.DecodeString(r.Read(nameOffset, nameLength*2), binary.LittleEndian), nil
}
// CollationType indicates how the entries in an index should be ordered.
type CollationType uint32
@@ -246,98 +281,150 @@ type IndexEntry struct {
// IndexRoot. Note that no additional correctness checks are done, so it's up to the caller to ensure the passed data
// actually represents a $INDEX_ROOT attribute's data.
func ParseIndexRoot(b []byte) (IndexRoot, error) {
if len(b) < 32 {
return IndexRoot{}, fmt.Errorf("expected at least %d bytes but got %d", 32, len(b))
}
r := binutil.NewLittleEndianReader(b)
attributeType := AttributeType(r.Uint32(0x00))
if attributeType != AttributeTypeFileName {
return IndexRoot{}, fmt.Errorf("unable to handle attribute type %d (%s) in $INDEX_ROOT", attributeType, attributeType.Name())
}
uTotalSize := r.Uint32(0x14)
if int64(uTotalSize) > maxInt {
return IndexRoot{}, fmt.Errorf("index root size %d overflows maximum int value %d", uTotalSize, maxInt)
}
totalSize := int(uTotalSize)
expectedSize := totalSize + 16
if len(b) < expectedSize {
return IndexRoot{}, fmt.Errorf("expected %d bytes in $INDEX_ROOT but is %d", expectedSize, len(b))
header, entryData, err := parseIndexRootHeader(b)
if err != nil {
return IndexRoot{}, err
}
entries := []IndexEntry{}
if totalSize >= 16 {
parsed, err := parseIndexEntries(r.Read(0x20, totalSize-16))
if len(entryData) > 0 {
parsed, err := parseIndexEntries(entryData)
if err != nil {
return IndexRoot{}, fmt.Errorf("error parsing index entries: %v", err)
}
entries = parsed
}
return IndexRoot{
AttributeType: header.AttributeType,
CollationType: header.CollationType,
BytesPerRecord: header.BytesPerRecord,
ClustersPerRecord: header.ClustersPerRecord,
Flags: header.Flags,
Entries: entries,
}, nil
}
func parseIndexRootHeader(b []byte) (IndexRoot, []byte, error) {
if len(b) < minIndexRootLength {
return IndexRoot{}, nil, fmt.Errorf("expected at least %d bytes but got %d", minIndexRootLength, len(b))
}
r := binutil.NewLittleEndianReader(b)
attributeType := AttributeType(r.Uint32(0x00))
if attributeType != AttributeTypeFileName {
return IndexRoot{}, nil, fmt.Errorf("unable to handle attribute type %d (%s) in $INDEX_ROOT", attributeType, attributeType.Name())
}
uTotalSize := r.Uint32(0x14)
if int64(uTotalSize) > maxInt {
return IndexRoot{}, nil, fmt.Errorf("index root size %d overflows maximum int value %d", uTotalSize, maxInt)
}
totalSize := int(uTotalSize)
expectedSize := totalSize + indexRootHeaderLength
if len(b) < expectedSize {
return IndexRoot{}, nil, fmt.Errorf("expected %d bytes in $INDEX_ROOT but is %d", expectedSize, len(b))
}
entryData := []byte{}
if totalSize >= indexRootHeaderLength {
entryData = r.Read(indexRootEntryOffset, totalSize-indexRootHeaderLength)
}
return IndexRoot{
AttributeType: attributeType,
CollationType: CollationType(r.Uint32(0x04)),
BytesPerRecord: r.Uint32(0x08),
ClustersPerRecord: r.Uint32(0x0C),
Flags: r.Uint32(0x1C),
Entries: entries,
}, nil
}, entryData, nil
}
func parseIndexEntries(b []byte) ([]IndexEntry, error) {
if len(b) < 13 {
return []IndexEntry{}, fmt.Errorf("expected at least %d bytes but got %d", 13, len(b))
if len(b) < minIndexEntryLength {
return []IndexEntry{}, fmt.Errorf("expected at least %d bytes but got %d", minIndexEntryLength, len(b))
}
entries := make([]IndexEntry, 0)
for len(b) > 0 {
r := binutil.NewLittleEndianReader(b)
entryLength := int(r.Uint16(0x08))
if len(b) < entryLength {
return entries, fmt.Errorf("index entry length indicates %d bytes but got %d", entryLength, len(b))
}
flags := r.Uint32(0x0C)
pointsToSubNode := flags&0b1 != 0
isLastEntryInNode := flags&0b10 != 0
contentLength := int(r.Uint16(0x0A))
fileName := FileName{}
if contentLength != 0 && !isLastEntryInNode {
parsedFileName, err := ParseFileName(r.Read(0x10, contentLength))
if err != nil {
return entries, fmt.Errorf("error parsing $FILE_NAME record in index entry: %v", err)
}
fileName = parsedFileName
}
subNodeVcn := uint64(0)
if pointsToSubNode {
subNodeVcn = r.Uint64(entryLength - 8)
}
fileReference, err := ParseFileReference(r.Read(0x00, 8))
entry, entryLength, err := parseIndexEntry(b)
if err != nil {
return entries, fmt.Errorf("unable to file reference: %v", err)
}
entry := IndexEntry{
FileReference: fileReference,
Flags: flags,
FileName: fileName,
SubNodeVCN: subNodeVcn,
return entries, err
}
entries = append(entries, entry)
b = r.ReadFrom(entryLength)
b = b[entryLength:]
}
return entries, nil
}
func parseIndexEntry(b []byte) (IndexEntry, int, error) {
if len(b) < minIndexEntryLength {
return IndexEntry{}, 0, fmt.Errorf("expected at least %d bytes but got %d", minIndexEntryLength, len(b))
}
r := binutil.NewLittleEndianReader(b)
entryLength := int(r.Uint16(0x08))
if entryLength < minIndexEntryLength {
return IndexEntry{}, 0, fmt.Errorf("index entry length %d is smaller than minimum %d", entryLength, minIndexEntryLength)
}
if len(b) < entryLength {
return IndexEntry{}, 0, fmt.Errorf("index entry length indicates %d bytes but got %d", entryLength, len(b))
}
flags := r.Uint32(0x0C)
contentLength := int(r.Uint16(0x0A))
fileName, err := parseIndexEntryFileName(r, b, entryLength, contentLength, flags)
if err != nil {
return IndexEntry{}, 0, err
}
subNodeVcn, err := parseIndexEntrySubNodeVCN(r, entryLength, flags)
if err != nil {
return IndexEntry{}, 0, err
}
fileReference, err := ParseFileReference(r.Read(0x00, 8))
if err != nil {
return IndexEntry{}, 0, fmt.Errorf("unable to file reference: %v", err)
}
return IndexEntry{
FileReference: fileReference,
Flags: flags,
FileName: fileName,
SubNodeVCN: subNodeVcn,
}, entryLength, nil
}
func parseIndexEntryFileName(r *binutil.BinReader, b []byte, entryLength int, contentLength int, flags uint32) (FileName, error) {
isLastEntryInNode := flags&0b10 != 0
if contentLength == 0 || isLastEntryInNode {
return FileName{}, nil
}
contentEnd := 0x10 + contentLength
if contentEnd > entryLength || contentEnd > len(b) {
return FileName{}, fmt.Errorf("index entry content exceeds entry boundary: content=%d entry=%d", contentLength, entryLength)
}
fileName, err := ParseFileName(r.Read(0x10, contentLength))
if err != nil {
return FileName{}, fmt.Errorf("error parsing $FILE_NAME record in index entry: %v", err)
}
return fileName, nil
}
func parseIndexEntrySubNodeVCN(r *binutil.BinReader, entryLength int, flags uint32) (uint64, error) {
pointsToSubNode := flags&0b1 != 0
if !pointsToSubNode {
return 0, nil
}
if entryLength < 8 {
return 0, fmt.Errorf("index entry length %d is too small for sub-node VCN", entryLength)
}
return r.Uint64(entryLength - 8), nil
}
// ConvertFileTime converts a Windows "file time" to a time.Time. A "file time" is a 64-bit value that represents the
// number of 100-nanosecond intervals that have elapsed since 12:00 A.M. January 1, 1601 Coordinated Universal Time
// (UTC). See also: https://docs.microsoft.com/en-us/windows/win32/sysinfo/file-times
func ConvertFileTime(timeValue uint64) time.Time {
dur := time.Duration(int64(timeValue))
r := time.Date(1601, time.January, 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < 100; i++ {
r = r.Add(dur)
}
return r
const ticksPerSecond = uint64(10000000)
const unixOffsetSeconds = int64(-11644473600)
seconds := int64(timeValue / ticksPerSecond)
nanoseconds := int64(timeValue%ticksPerSecond) * 100
return time.Unix(unixOffsetSeconds+seconds, nanoseconds).UTC()
}