Preflight ZIP central directory metadata (T-612)

This commit is contained in:
ila
2026-07-18 15:40:44 +08:00
parent 65ff7a3f23
commit 0f69fa330e
14 changed files with 702 additions and 68 deletions
+14 -4
View File
@@ -20,6 +20,9 @@ var (
ErrUnexpectedEntry = errors.New("unexpected ZIP package entry") ErrUnexpectedEntry = errors.New("unexpected ZIP package entry")
ErrDuplicateEntry = errors.New("duplicate ZIP entry") ErrDuplicateEntry = errors.New("duplicate ZIP entry")
ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported") ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported")
ErrArchiveSizeMismatch = errors.New("ZIP archive size does not match expected package size")
ErrArchiveTooLarge = errors.New("ZIP archive size limit exceeded")
ErrCentralDirectoryTooLarge = errors.New("ZIP central directory size limit exceeded")
ErrTooManyEntries = errors.New("ZIP entry limit exceeded") ErrTooManyEntries = errors.New("ZIP entry limit exceeded")
ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded") ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded")
ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded") ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded")
@@ -56,18 +59,25 @@ func NewExtractor(limits Limits) (Extractor, error) {
return Extractor{limits: limits}, nil return Extractor{limits: limits}, nil
} }
// ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks. // ExtractFile requires expectedPackageSize from the verified Catalog package.
// The completed download file must have precisely that size before any ZIP data is parsed.
func (extractor Extractor) ExtractFile( func (extractor Extractor) ExtractFile(
zipPath string, zipPath string,
destination string, destination string,
entrypoint string, entrypoint string,
expectedPackageSize int64,
) (ExtractResult, error) { ) (ExtractResult, error) {
archive, err := zip.OpenReader(zipPath) file, size, err := extractor.openAndScanArchive(zipPath, expectedPackageSize)
if err != nil {
return ExtractResult{}, err
}
defer file.Close()
archive, err := zip.NewReader(file, size)
if err != nil { if err != nil {
return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err) return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err)
} }
defer archive.Close() return extractor.extract(archive, destination, entrypoint)
return extractor.extract(&archive.Reader, destination, entrypoint)
} }
func (extractor Extractor) extract( func (extractor Extractor) extract(
+61 -11
View File
@@ -21,7 +21,12 @@ func TestExtractorExtractsPayloadOnly(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging") destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits()) extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe") result, err := extractor.ExtractFile(
archivePath,
destination,
"bin/App.exe",
archiveSize(t, archivePath),
)
if err != nil { if err != nil {
t.Fatalf("ExtractFile() error = %v", err) t.Fatalf("ExtractFile() error = %v", err)
} }
@@ -217,6 +222,8 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
entrypoint: "App.exe", entrypoint: "App.exe",
limits: Limits{ limits: Limits{
MaxEntries: 2, MaxEntries: 2,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 1024, MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100, MaxCompressionRatio: 100,
}, },
@@ -231,6 +238,8 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
entrypoint: "App.exe", entrypoint: "App.exe",
limits: Limits{ limits: Limits{
MaxEntries: 10, MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8, MaxUncompressedBytes: 8,
MaxCompressionRatio: 100, MaxCompressionRatio: 100,
}, },
@@ -249,6 +258,8 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
entrypoint: "App.exe", entrypoint: "App.exe",
limits: Limits{ limits: Limits{
MaxEntries: 10, MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8192, MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2, MaxCompressionRatio: 2,
}, },
@@ -263,7 +274,12 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
destination := filepath.Join(root, "staging") destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, test.limits) extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint) _, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) { if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr) t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
} }
@@ -303,7 +319,12 @@ func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
t.Run(test.entrypoint, func(t *testing.T) { t.Run(test.entrypoint, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging") destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits()) extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint) _, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) { if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr) t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
} }
@@ -323,7 +344,12 @@ func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging") destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits()) extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe") result, err := extractor.ExtractFile(
archivePath,
destination,
"工具/解析器.exe",
archiveSize(t, archivePath),
)
if err != nil { if err != nil {
t.Fatalf("ExtractFile() error = %v", err) t.Fatalf("ExtractFile() error = %v", err)
} }
@@ -346,7 +372,12 @@ func TestExtractorRejectsExistingDestination(t *testing.T) {
} }
extractor := mustExtractor(t, testLimits()) extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe") _, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrDestinationExists) { if !errors.Is(err, ErrDestinationExists) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists) t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
} }
@@ -361,7 +392,12 @@ func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging") destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits()) extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe") _, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrArchiveCorrupt) { if !errors.Is(err, ErrArchiveCorrupt) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt) t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
} }
@@ -381,11 +417,22 @@ type testZIPEntry struct {
func testLimits() Limits { func testLimits() Limits {
return Limits{ return Limits{
MaxEntries: 20, MaxEntries: 20,
MaxArchiveBytes: 64 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 16 * 1024, MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100, MaxCompressionRatio: 100,
} }
} }
func archiveSize(t *testing.T, archivePath string) int64 {
t.Helper()
info, err := os.Stat(archivePath)
if err != nil {
t.Fatalf("stat archive: %v", err)
}
return info.Size()
}
func mustExtractor(t *testing.T, limits Limits) Extractor { func mustExtractor(t *testing.T, limits Limits) Extractor {
t.Helper() t.Helper()
extractor, err := NewExtractor(limits) extractor, err := NewExtractor(limits)
@@ -483,11 +530,14 @@ func TestDefaultLimitsAreValid(t *testing.T) {
func TestExtractorRejectsInvalidLimits(t *testing.T) { func TestExtractorRejectsInvalidLimits(t *testing.T) {
tests := []Limits{ tests := []Limits{
{MaxEntries: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1}, {MaxEntries: 0, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1}, {MaxEntries: 1, MaxArchiveBytes: 0, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 2, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
} }
for index, limits := range tests { for index, limits := range tests {
+6 -1
View File
@@ -19,7 +19,12 @@ func TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem(t *testing.T)
destination := filepath.Join(root, "staging") destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, testLimits()) extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe") _, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrPathEscape) { if !errors.Is(err, ErrPathEscape) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape) t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape)
} }
+15
View File
@@ -10,6 +10,8 @@ var ErrInvalidLimits = errors.New("invalid ZIP extraction limits")
const ( const (
DefaultMaxEntries = 10_000 DefaultMaxEntries = 10_000
DefaultMaxArchiveBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCentralDirectoryBytes = int64(64 * 1024 * 1024)
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024) DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCompressionRatio = 200.0 DefaultMaxCompressionRatio = 200.0
) )
@@ -17,6 +19,8 @@ const (
// Limits bounds archive metadata and decompressed output. // Limits bounds archive metadata and decompressed output.
type Limits struct { type Limits struct {
MaxEntries int MaxEntries int
MaxArchiveBytes int64
MaxCentralDirectoryBytes int64
MaxUncompressedBytes int64 MaxUncompressedBytes int64
MaxCompressionRatio float64 MaxCompressionRatio float64
} }
@@ -24,6 +28,8 @@ type Limits struct {
func DefaultLimits() Limits { func DefaultLimits() Limits {
return Limits{ return Limits{
MaxEntries: DefaultMaxEntries, MaxEntries: DefaultMaxEntries,
MaxArchiveBytes: DefaultMaxArchiveBytes,
MaxCentralDirectoryBytes: DefaultMaxCentralDirectoryBytes,
MaxUncompressedBytes: DefaultMaxUncompressedBytes, MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio, MaxCompressionRatio: DefaultMaxCompressionRatio,
} }
@@ -33,6 +39,15 @@ func (limits Limits) validate() error {
if limits.MaxEntries <= 0 { if limits.MaxEntries <= 0 {
return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits) return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits)
} }
if limits.MaxArchiveBytes <= 0 {
return fmt.Errorf("%w: MaxArchiveBytes must be positive", ErrInvalidLimits)
}
if limits.MaxCentralDirectoryBytes <= 0 {
return fmt.Errorf("%w: MaxCentralDirectoryBytes must be positive", ErrInvalidLimits)
}
if limits.MaxCentralDirectoryBytes > limits.MaxArchiveBytes {
return fmt.Errorf("%w: MaxCentralDirectoryBytes exceeds MaxArchiveBytes", ErrInvalidLimits)
}
if limits.MaxUncompressedBytes <= 0 { if limits.MaxUncompressedBytes <= 0 {
return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits) return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits)
} }
+240
View File
@@ -0,0 +1,240 @@
package installer
import (
"encoding/binary"
"fmt"
"io"
"os"
)
const (
endOfCentralDirectorySignature = 0x06054b50
zip64EndSignature = 0x06064b50
zip64LocatorSignature = 0x07064b50
endOfCentralDirectoryLength = 22
zip64EndLength = 56
zip64LocatorLength = 20
maxZIPCommentLength = 1<<16 - 1
maxEOCDSearchLength = endOfCentralDirectoryLength + maxZIPCommentLength
maxInt64 = 1<<63 - 1
)
type endOfCentralDirectory struct {
offset int64
diskNumber uint32
centralDirectoryDisk uint32
entriesOnThisDisk uint64
centralDirectoryEntries uint64
centralDirectorySize uint64
centralDirectoryOffset uint64
}
func (extractor Extractor) openAndScanArchive(
zipPath string,
expectedPackageSize int64,
) (*os.File, int64, error) {
if err := extractor.limits.validate(); err != nil {
return nil, 0, err
}
if expectedPackageSize <= 0 {
return nil, 0, fmt.Errorf("%w: expected size must be positive", ErrArchiveSizeMismatch)
}
pathInfo, err := os.Lstat(zipPath)
if err != nil {
return nil, 0, fmt.Errorf("%w: inspect package: %v", ErrInvalidArchive, err)
}
if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() {
return nil, 0, fmt.Errorf("%w: package is not a regular file", ErrInvalidArchive)
}
file, err := os.Open(zipPath)
if err != nil {
return nil, 0, fmt.Errorf("%w: open package: %v", ErrInvalidArchive, err)
}
closeWithError := func(err error) (*os.File, int64, error) {
_ = file.Close()
return nil, 0, err
}
info, err := file.Stat()
if err != nil {
return closeWithError(fmt.Errorf("%w: stat package: %v", ErrInvalidArchive, err))
}
if !info.Mode().IsRegular() {
return closeWithError(fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive))
}
size := info.Size()
if size != expectedPackageSize {
return closeWithError(fmt.Errorf(
"%w: got %d, expected %d",
ErrArchiveSizeMismatch,
size,
expectedPackageSize,
))
}
if size > extractor.limits.MaxArchiveBytes {
return closeWithError(fmt.Errorf(
"%w: got %d, limit %d",
ErrArchiveTooLarge,
size,
extractor.limits.MaxArchiveBytes,
))
}
if err := scanCentralDirectory(file, size, extractor.limits); err != nil {
return closeWithError(err)
}
return file, size, nil
}
func scanCentralDirectory(file *os.File, size int64, limits Limits) error {
end, err := findEndOfCentralDirectory(file, size)
if err != nil {
return err
}
if requiresZIP64(end) {
if err := readZIP64EndOfCentralDirectory(file, &end); err != nil {
return err
}
}
if end.diskNumber != 0 || end.centralDirectoryDisk != 0 ||
end.entriesOnThisDisk != end.centralDirectoryEntries {
return fmt.Errorf("%w: multi-disk archives are unsupported", ErrInvalidArchive)
}
if end.centralDirectoryEntries > uint64(limits.MaxEntries) {
return fmt.Errorf(
"%w: got %d, limit %d",
ErrTooManyEntries,
end.centralDirectoryEntries,
limits.MaxEntries,
)
}
if end.centralDirectorySize > uint64(limits.MaxCentralDirectoryBytes) {
return fmt.Errorf(
"%w: got %d, limit %d",
ErrCentralDirectoryTooLarge,
end.centralDirectorySize,
limits.MaxCentralDirectoryBytes,
)
}
if end.centralDirectorySize > uint64(maxInt64) ||
end.centralDirectoryOffset > uint64(maxInt64) {
return fmt.Errorf("%w: central directory exceeds int64", ErrInvalidArchive)
}
centralDirectorySize := int64(end.centralDirectorySize)
centralDirectoryOffset := int64(end.centralDirectoryOffset)
if centralDirectorySize > end.offset {
return fmt.Errorf("%w: central directory exceeds end record", ErrInvalidArchive)
}
centralDirectoryStart := end.offset - centralDirectorySize
if centralDirectoryOffset > centralDirectoryStart {
return fmt.Errorf("%w: central directory offset is outside archive", ErrInvalidArchive)
}
baseOffset := centralDirectoryStart - centralDirectoryOffset
if baseOffset < 0 || centralDirectoryStart > size ||
centralDirectoryStart+centralDirectorySize != end.offset {
return fmt.Errorf("%w: central directory bounds are inconsistent", ErrInvalidArchive)
}
return nil
}
func findEndOfCentralDirectory(file *os.File, size int64) (endOfCentralDirectory, error) {
if size < endOfCentralDirectoryLength {
return endOfCentralDirectory{}, fmt.Errorf("%w: archive is shorter than EOCD", ErrInvalidArchive)
}
readLength := int64(maxEOCDSearchLength)
if readLength > size {
readLength = size
}
buffer := make([]byte, int(readLength))
readOffset := size - readLength
if err := readAtExactly(file, buffer, readOffset); err != nil {
return endOfCentralDirectory{}, err
}
for offset := len(buffer) - endOfCentralDirectoryLength; offset >= 0; offset-- {
if binary.LittleEndian.Uint32(buffer[offset:]) != endOfCentralDirectorySignature {
continue
}
commentLength := int(binary.LittleEndian.Uint16(buffer[offset+20:]))
if offset+endOfCentralDirectoryLength+commentLength != len(buffer) {
continue
}
return endOfCentralDirectory{
offset: readOffset + int64(offset),
diskNumber: uint32(binary.LittleEndian.Uint16(buffer[offset+4:])),
centralDirectoryDisk: uint32(binary.LittleEndian.Uint16(buffer[offset+6:])),
entriesOnThisDisk: uint64(binary.LittleEndian.Uint16(buffer[offset+8:])),
centralDirectoryEntries: uint64(binary.LittleEndian.Uint16(buffer[offset+10:])),
centralDirectorySize: uint64(binary.LittleEndian.Uint32(buffer[offset+12:])),
centralDirectoryOffset: uint64(binary.LittleEndian.Uint32(buffer[offset+16:])),
}, nil
}
return endOfCentralDirectory{}, fmt.Errorf("%w: EOCD is missing or malformed", ErrInvalidArchive)
}
func requiresZIP64(end endOfCentralDirectory) bool {
return end.entriesOnThisDisk == 0xffff ||
end.centralDirectoryEntries == 0xffff ||
end.centralDirectorySize == 0xffffffff ||
end.centralDirectoryOffset == 0xffffffff
}
func readZIP64EndOfCentralDirectory(file *os.File, end *endOfCentralDirectory) error {
locatorOffset := end.offset - zip64LocatorLength
if locatorOffset < 0 {
return fmt.Errorf("%w: ZIP64 locator is missing", ErrInvalidArchive)
}
locator := make([]byte, zip64LocatorLength)
if err := readAtExactly(file, locator, locatorOffset); err != nil {
return err
}
if binary.LittleEndian.Uint32(locator) != zip64LocatorSignature ||
binary.LittleEndian.Uint32(locator[4:]) != 0 ||
binary.LittleEndian.Uint32(locator[16:]) != 1 {
return fmt.Errorf("%w: ZIP64 locator is invalid", ErrInvalidArchive)
}
zip64EndOffset, ok := uint64AsInt64(binary.LittleEndian.Uint64(locator[8:]))
if !ok || zip64EndOffset < 0 || zip64EndOffset > locatorOffset-zip64EndLength {
return fmt.Errorf("%w: ZIP64 end offset is invalid", ErrInvalidArchive)
}
zip64End := make([]byte, zip64EndLength)
if err := readAtExactly(file, zip64End, zip64EndOffset); err != nil {
return err
}
if binary.LittleEndian.Uint32(zip64End) != zip64EndSignature {
return fmt.Errorf("%w: ZIP64 end record is invalid", ErrInvalidArchive)
}
recordSize := binary.LittleEndian.Uint64(zip64End[4:])
if recordSize < 44 || recordSize > uint64(locatorOffset-zip64EndOffset-12) {
return fmt.Errorf("%w: ZIP64 end record length is invalid", ErrInvalidArchive)
}
end.offset = zip64EndOffset
end.diskNumber = binary.LittleEndian.Uint32(zip64End[16:])
end.centralDirectoryDisk = binary.LittleEndian.Uint32(zip64End[20:])
end.entriesOnThisDisk = binary.LittleEndian.Uint64(zip64End[24:])
end.centralDirectoryEntries = binary.LittleEndian.Uint64(zip64End[32:])
end.centralDirectorySize = binary.LittleEndian.Uint64(zip64End[40:])
end.centralDirectoryOffset = binary.LittleEndian.Uint64(zip64End[48:])
return nil
}
func readAtExactly(file *os.File, buffer []byte, offset int64) error {
count, err := file.ReadAt(buffer, offset)
if err == nil && count == len(buffer) {
return nil
}
if err == nil {
err = io.ErrUnexpectedEOF
}
return fmt.Errorf("%w: read ZIP metadata: %v", ErrInvalidArchive, err)
}
func uint64AsInt64(value uint64) (int64, bool) {
if value > uint64(maxInt64) {
return 0, false
}
return int64(value), true
}
+300
View File
@@ -0,0 +1,300 @@
package installer
import (
"encoding/binary"
"errors"
"os"
"path/filepath"
"testing"
)
func TestExtractorRejectsArchiveSizeBeforeZIPReader(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
size := archiveSize(t, archivePath)
tests := []struct {
name string
limits Limits
expected int64
wantErr error
}{
{
name: "expected size mismatch",
limits: testLimits(),
expected: size + 1,
wantErr: ErrArchiveSizeMismatch,
},
{
name: "expected size is unknown",
limits: testLimits(),
expected: 0,
wantErr: ErrArchiveSizeMismatch,
},
{
name: "archive exceeds raw size limit",
limits: Limits{
MaxEntries: 20,
MaxArchiveBytes: size - 1,
MaxCentralDirectoryBytes: size - 1,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
},
expected: size,
wantErr: ErrArchiveTooLarge,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
test.expected,
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorRejectsCentralDirectoryMetadataBeforeZIPReader(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, data []byte, eocdOffset int)
want error
}{
{
name: "declared entries exceed limit",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint16(data[eocdOffset+8:], 21)
binary.LittleEndian.PutUint16(data[eocdOffset+10:], 21)
},
want: ErrTooManyEntries,
},
{
name: "central directory exceeds limit",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint32(data[eocdOffset+12:], 1025)
},
want: ErrCentralDirectoryTooLarge,
},
{
name: "multi disk archive",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint16(data[eocdOffset+4:], 1)
},
want: ErrInvalidArchive,
},
{
name: "central directory offset is outside archive",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint32(data[eocdOffset+16:], 0xfffffffe)
},
want: ErrInvalidArchive,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read archive: %v", err)
}
eocdOffset := len(data) - endOfCentralDirectoryLength
test.mutate(t, data, eocdOffset)
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err = extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, test.want) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.want)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorRejectsTruncatedEOCD(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
if err := os.Truncate(archivePath, archiveSize(t, archivePath)-1); err != nil {
t.Fatalf("truncate archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
}
func TestExtractorRejectsMissingOrInvalidZIP64End(t *testing.T) {
tests := []struct {
name string
mutate func(data []byte)
}{
{
name: "missing locator",
mutate: func(data []byte) {
eocdOffset := len(data) - endOfCentralDirectoryLength
data[eocdOffset-zip64LocatorLength] ^= 0xff
},
},
{
name: "invalid record length",
mutate: func(data []byte) {
eocdOffset := len(data) - endOfCentralDirectoryLength
zip64EndOffset := int(binary.LittleEndian.Uint64(data[eocdOffset-zip64LocatorLength+8:]))
binary.LittleEndian.PutUint64(data[zip64EndOffset+4:], 43)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeZIP64TestZIP(t)
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read archive: %v", err)
}
test.mutate(data)
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err = extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorAcceptsZIP64EndOfCentralDirectory(t *testing.T) {
archivePath := writeZIP64TestZIP(t)
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
if result.Files != 1 {
t.Fatalf("Files = %d, want 1", result.Files)
}
}
func TestExtractorRejectsNonRegularArchive(t *testing.T) {
directory := t.TempDir()
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(directory, destination, "App.exe", 1)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
}
func writeZIP64TestZIP(t *testing.T) string {
t.Helper()
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read ZIP: %v", err)
}
eocdOffset := len(data) - endOfCentralDirectoryLength
entries := binary.LittleEndian.Uint16(data[eocdOffset+10:])
centralDirectorySize := binary.LittleEndian.Uint32(data[eocdOffset+12:])
centralDirectoryOffset := binary.LittleEndian.Uint32(data[eocdOffset+16:])
zip64EndOffset := len(data) - endOfCentralDirectoryLength
zip64End := make([]byte, zip64EndLength)
binary.LittleEndian.PutUint32(zip64End, zip64EndSignature)
binary.LittleEndian.PutUint64(zip64End[4:], 44)
binary.LittleEndian.PutUint16(zip64End[12:], 45)
binary.LittleEndian.PutUint16(zip64End[14:], 45)
binary.LittleEndian.PutUint64(zip64End[24:], uint64(entries))
binary.LittleEndian.PutUint64(zip64End[32:], uint64(entries))
binary.LittleEndian.PutUint64(zip64End[40:], uint64(centralDirectorySize))
binary.LittleEndian.PutUint64(zip64End[48:], uint64(centralDirectoryOffset))
locator := make([]byte, zip64LocatorLength)
binary.LittleEndian.PutUint32(locator, zip64LocatorSignature)
binary.LittleEndian.PutUint64(locator[8:], uint64(zip64EndOffset))
binary.LittleEndian.PutUint32(locator[16:], 1)
classicEnd := make([]byte, endOfCentralDirectoryLength)
binary.LittleEndian.PutUint32(classicEnd, endOfCentralDirectorySignature)
binary.LittleEndian.PutUint16(classicEnd[8:], 0xffff)
binary.LittleEndian.PutUint16(classicEnd[10:], 0xffff)
binary.LittleEndian.PutUint32(classicEnd[12:], 0xffffffff)
binary.LittleEndian.PutUint32(classicEnd[16:], 0xffffffff)
zip64Data := make([]byte, 0, len(data)+zip64EndLength+zip64LocatorLength)
zip64Data = append(zip64Data, data[:eocdOffset]...)
zip64Data = append(zip64Data, zip64End...)
zip64Data = append(zip64Data, locator...)
zip64Data = append(zip64Data, classicEnd...)
if err := os.WriteFile(archivePath, zip64Data, 0o600); err != nil {
t.Fatalf("write ZIP64 archive: %v", err)
}
return archivePath
}
func assertStagingAbsent(t *testing.T, destination string) {
t.Helper()
if _, err := os.Stat(destination); !os.IsNotExist(err) {
t.Fatalf("rejected archive left staging, stat error = %v", err)
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
## 当前阶段 ## 当前阶段
当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-611`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分以及 unsafe cache 安全诊断/runbook 均已关闭;`T-612` 已落成为中央目录/EOCD 预扫描边界任务并是当前唯一待领取项,T-302 暂后置。 当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-612`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook 以及 ZIP 中央目录/EOCD(含 ZIP64)预扫描均已关闭;下一步按 Phase 1 审核顺序落成断电耐久性整改,T-302 暂后置。
优先路径: 优先路径:
@@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
5. 已完成 T-606~T-611:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分和 unsafe cache 诊断/人工恢复指引;下一步执行 T-612 的 Phase 1 中央目录预扫描整改,再继续 T-302/T-303 与 Phase 4-6。 5. 已完成 T-606~T-612:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引和 ZIP 中央目录/EOCD 预扫描;下一步串行处理 Phase 1 断电耐久性整改,再继续 T-302/T-303 与 Phase 4-6。
## 领取任务规则 ## 领取任务规则
+5 -4
View File
@@ -158,7 +158,7 @@ T-301 下载队列:
- cancel 一旦先于 completion 取得线性化点,即使 body close/sync 报错也记录后继续精确清理;若 completion 先完成,后续 cancel 明确拒绝。known-total 完整 part 在同进程 resume/retry 与启动恢复中都直接 finalize,不发送 offset==total 的 Range。 - cancel 一旦先于 completion 取得线性化点,即使 body close/sync 报错也记录后继续精确清理;若 completion 先完成,后续 cancel 明确拒绝。known-total 完整 part 在同进程 resume/retry 与启动恢复中都直接 finalize,不发送 offset==total 的 Range。
- 写入句柄的文件身份贯穿 sync/close 与 rename 前后核对,防止活跃 `.part` 路径被替换后发布错误文件。崩溃恢复对账 metadata、part、final 三份事实:完整 part 可 finalize,已 rename 的 final 可补 completed metadata;缺 final 的 completed、part+final、超出 expected/unknown 上限均 fail closed。 - 写入句柄的文件身份贯穿 sync/close 与 rename 前后核对,防止活跃 `.part` 路径被替换后发布错误文件。崩溃恢复对账 metadata、part、final 三份事实:完整 part 可 finalize,已 rename 的 final 可补 completed metadata;缺 final 的 completed、part+final、超出 expected/unknown 上限均 fail closed。
- 事件投递失败通过 `OnObserverError` 显式报告,不改变 durable transfer 结果;application/UI 启动或重连后用 `Queue.Tasks()` 对账终态,避免 DownloadCompleted 等一次性通知丢失后永久停链。 - 事件投递失败通过 `OnObserverError` 显式报告,不改变 durable transfer 结果;application/UI 启动或重连后用 `Queue.Tasks()` 对账终态,避免 DownloadCompleted 等一次性通知丢失后永久停链。
- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 不得把它们当信任根,仍需从已验签 Catalog 重新取得并核对身份/size/hash/signature。 - DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 不得把它们当信任根,仍需从已验签 Catalog 重新取得并核对身份/size/hash/signature,并把该 Catalog `size` 传给 `Extractor.ExtractFile` 对同一打开的普通完成文件重新对账。
### 4.3 事件模型 ### 4.3 事件模型
@@ -170,15 +170,16 @@ T-301 下载队列:
```text ```text
读取已签名 Catalog → 选择 OS/架构匹配的 Package → 下载到 downloads 读取已签名 Catalog → 选择 OS/架构匹配的 Package → 下载到 downloads
→ 校验 size 与 SHA-256 → 安全读取 app.json → 比对 ID/版本/通道/系统/架构 → 校验 size 与 SHA-256 → 打开同一普通完成文件,使实际长度 = Catalog size
→ 检查 ZIP 路径与解压上限 → 解压 payload 到 staging → 校验 entry_exe → 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 安全读取 app.json
→ 比对 ID/版本/通道/系统/架构 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging → 校验 entry_exe
→ 确认目标软件已退出 → current 改名 backup → staging 原子切换为 current → 确认目标软件已退出 → current 改名 backup → staging 原子切换为 current
→ 健康检查 → 成功延迟清理 backup / 失败恢复 backup → 健康检查 → 成功延迟清理 backup / 失败恢复 backup
``` ```
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与资源上限,再规划并确认所有 native 输出路径仍在 destination 内,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。 Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md);T-302 仍需把已验签 Catalog、SHA-256、T-301 的完成文件和此 API 串成生产安装链,并按真实包分布复核限额。
Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。 Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。
+1
View File
@@ -35,6 +35,7 @@
- 任何下载内容未通过 SHA-256 + 签名验证,**不得解压执行**;清单验签失败拒绝,不回退到未验证内容。 - 任何下载内容未通过 SHA-256 + 签名验证,**不得解压执行**;清单验签失败拒绝,不回退到未验证内容。
- ZIP 与软件包路径必须使用 `core/internal/safepath` 的共享 Windows 安全相对路径策略,拒绝绝对路径、`../`/dot-space 归一化穿越、首尾 ASCII 空格、尾随句点、DOS 设备名、Windows 禁止字符、符号链接逃逸和 entrypoint 指向 payload 外;native 输出路径还必须验证仍在 destination 内。Catalog/storage/app.json/files.json 不得各自复制一套路径规则。 - ZIP 与软件包路径必须使用 `core/internal/safepath` 的共享 Windows 安全相对路径策略,拒绝绝对路径、`../`/dot-space 归一化穿越、首尾 ASCII 空格、尾随句点、DOS 设备名、Windows 禁止字符、符号链接逃逸和 entrypoint 指向 payload 外;native 输出路径还必须验证仍在 destination 内。Catalog/storage/app.json/files.json 不得各自复制一套路径规则。
- 任意 `zip.Reader` 构造前,必须以同一普通文件句柄核对已验签 Catalog 的 exact package `size` 与完成下载实际长度,并有界预扫描 EOCD/ZIP64:原始包、中央目录字节数和声明条目数超过硬上限,跨盘/截断/边界不一致元数据一律拒绝;不得先扫描路径后按该路径重新打开,不得仅依赖 `len(archive.File)`。
- ZIP 解压必须保留文件数、展开体积和压缩比硬上限。 - ZIP 解压必须保留文件数、展开体积和压缩比硬上限。
- 安装/更新只走 `staging → current → backup` 原子流程;任何写 `current/` 的捷径都不允许。 - 安装/更新只走 `staging → current → backup` 原子流程;任何写 `current/` 的捷径都不允许。
- 程序更新不得触碰 `data/` 与 `licenses/`。 - 程序更新不得触碰 `data/` 与 `licenses/`。
+1 -1
View File
@@ -42,7 +42,7 @@
#### Phase 1 交叉审核加固 #### Phase 1 交叉审核加固
Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605 与 T-612 关闭前不恢复 T-302;后续断电耐久和签名向量任务在前一整改完成并提交后再正式编号。 Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605 与 T-612 已关闭;后续断电耐久和签名向量任务在前一整改完成并提交后再正式编号,T-302 继续后置。
| ID | 任务 | 依赖 | 验收要点 | | ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
+3 -1
View File
@@ -153,7 +153,9 @@ T-102 Phase 1 原型进一步固定:
- ZIP 名称只接受 UTF-8 `/` 分隔的规范 Windows 安全相对路径;逐段拒绝反斜杠、盘符、冒号/NTFS ADS、NUL/控制字符、Windows 禁止字符、`.`/`..`、首尾 ASCII 空格、尾随句点、DOS 设备名及大小写折叠后的重复输出路径。 - ZIP 名称只接受 UTF-8 `/` 分隔的规范 Windows 安全相对路径;逐段拒绝反斜杠、盘符、冒号/NTFS ADS、NUL/控制字符、Windows 禁止字符、`.`/`..`、首尾 ASCII 空格、尾随句点、DOS 设备名及大小写折叠后的重复输出路径。
- 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。 - 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。
- 拒绝符号链接、设备/管道等特殊文件和加密条目。 - 拒绝符号链接、设备/管道等特殊文件和加密条目。
- 原型默认上限:10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。T-302 按真实包体分布复核后再冻结。 - `Extractor.ExtractFile` 必须接收已验签 Catalog `packages[arch].size` 作为 `expectedPackageSize`;同一已打开的普通 `.download` 文件的实际长度必须与它精确相等,否则在读取 ZIP 前拒绝。T-301 的 known-total 完成文件已经以该 Catalog size 限长,但 T-302 仍须重新取得并传入可信 Catalog 身份,不能信任可篡改的下载 metadata。
- 构造 `zip.Reader` 前只读取文件尾部至多 65,557 字节以定位 EOCD,并按需读取固定的 ZIP64 locator/EOCD 记录;校验单磁盘、中央目录 offset/size/entries 的边界及 entries/中央目录大小硬上限。当前默认上限为:原始包 4 GiB、中央目录 64 MiB、10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。大小不一致、原始包超限、中央目录超限、声明条目超限分别保留 `ErrArchiveSizeMismatch`、`ErrArchiveTooLarge`、`ErrCentralDirectoryTooLarge`、`ErrTooManyEntries` 错误链;格式、截断、跨盘或不一致 ZIP64 归入 `ErrInvalidArchive`。
- 预扫描通过后使用**同一文件句柄**和已核对的长度创建 `zip.NewReader`;完整中央目录/路径/entrypoint/类型/CRC/展开量预检仍是第二道防线。合法 ZIP64 被支持,不因 32 位 EOCD 哨兵值误拒绝。T-302 按真实包体分布复核上述暂定限额后再冻结。
- entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。 - entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。
- 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。 - 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。
+7 -7
View File
@@ -13,22 +13,22 @@
## 当前快照 ## 当前快照
- 日期:2026-07-18 - 日期:2026-07-18
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-611 已完成,T-612 已落成为唯一 TODO,T-302 继续暂后置 - 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-612 已完成,T-302 继续暂后置
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 - 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型(Extractor 在同一普通文件句柄上核对 expected Catalog size 后有界预扫 EOCD/ZIP64、中央目录与条目数)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
- 测试:core 覆盖 Catalog、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 - 测试:core 覆盖 Catalog、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:无;T-611 已闭合 unsafe cache 诊断且自动 quarantine 仍需独立威胁模型。`T-612` 已落成,将在 ZIP reader 构造前增加 Catalog size、完成文件长度与中央目录/EOCD(含 ZIP64)预扫描边界;T-302 继续后置到该阻断项关闭 - 当前 blocker:无;T-612 已闭合 ZIP reader 前的 Catalog size/完成文件长度/中央目录 EOCD(含 ZIP64)预扫描边界。下一步按 `docs/review/phase1-security-review.md` 最终处理顺序第 3 项落成断电耐久性整改(预计 T-613);T-302 继续后置到该阻断项关闭
## 当前目录要点 ## 当前目录要点
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-611 已完成;T-612 是当前唯一 TODO 的 Phase 1 中央目录/EOCD 预扫描整改,T-302 暂后置 | | `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-612 已完成;下一项 Phase 1 断电耐久性整改尚未编号,T-302 暂后置 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
@@ -40,9 +40,9 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-611`。 - 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-612`。
- 正在进行:无。 - 正在进行:无。
- 下一个可领取任务:`T-612` — 在 ZIP reader 构造前增加已验签 Catalog size、完成文件长度和中央目录/EOCD(含 ZIP64)预扫描边界。 - 下一个可领取任务:暂无已落成 TODO;下一步按 Phase 1 最终处理顺序第 3 项落成断电耐久性整改(预计 `T-613`)。
## 当前可运行内容 ## 当前可运行内容
+8 -1
View File
@@ -200,7 +200,7 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题
### 最终处理顺序(定稿) ### 最终处理顺序(定稿)
1. **[阻断 T-302 · 最高 · T-605]** 修 Windows 尾部空格/点、DOS 设备名与路径别名:建逐段 Windows 安全路径校验器(各层共用)+ 提取时 destination 包含性兜底检查;补 Win7/10/11 真实文件系统用例。关闭前不得把 Extractor 描述为"无路径穿越"。 1. **[阻断 T-302 · 最高 · T-605]** 修 Windows 尾部空格/点、DOS 设备名与路径别名:建逐段 Windows 安全路径校验器(各层共用)+ 提取时 destination 包含性兜底检查;补 Win7/10/11 真实文件系统用例。关闭前不得把 Extractor 描述为"无路径穿越"。
2. **[阻断 T-302]** `zip.OpenReader` 前增加包大小与中央目录/EOCD 预扫描边界;与 Catalog `size`、下载完成长度三者一致,不只依赖 `len(archive.File)`。 2. **[已关闭 · T-612]** 已在构造 `zip.Reader` 前增加同句柄包大小与中央目录/EOCD(含 ZIP64)预扫描边界;Extractor 强制接收 expected Catalog `size`,核对打开文件长度并限制原始包、中央目录与声明条目数,不只依赖 `len(archive.File)`。T-302 仍须把已验签 Catalog、完成下载文件和 SHA-256 编排为真实安装调用链。
3. **[发布前必做]** 完整断电耐久策略:payload 文件 Sync → staging 目录元数据 → journal 顺序点 → 每次 rename 后父目录顺序点 → committed 耐久后才删 backup/journal;评估 Windows 目录句柄 `FlushFileBuffers`/写穿方案;T-302/T-601 用 VM/真机断电注入验证。 3. **[发布前必做]** 完整断电耐久策略:payload 文件 Sync → staging 目录元数据 → journal 顺序点 → 每次 rename 后父目录顺序点 → committed 耐久后才删 backup/journal;评估 Windows 目录句柄 `FlushFileBuffers`/写穿方案;T-302/T-601 用 VM/真机断电注入验证。
4. **[协议冻结前]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。 4. **[协议冻结前]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。
5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。 5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。
@@ -208,3 +208,10 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题
7. **[低优先]** 固定解压落盘 mode,可执行入口由已验证 app.json/Catalog 定义。 7. **[低优先]** 固定解压落盘 mode,可执行入口由已验证 app.json/Catalog 定义。
> 裁定:Codex 二次复核成立且纠正了原审核的绝对化表述与一处过松建议;M2 调整为"有条件成立"(T-101/T-103 原型目标成立;T-102 须先关闭 Windows 路径语义缺口才能作为正式安装链路可信基线)。上述顺序为双方交叉复核后的共识。 > 裁定:Codex 二次复核成立且纠正了原审核的绝对化表述与一处过松建议;M2 调整为"有条件成立"(T-101/T-103 原型目标成立;T-102 须先关闭 Windows 路径语义缺口才能作为正式安装链路可信基线)。上述顺序为双方交叉复核后的共识。
### T-612 完成记录(2026-07-18)
- `Extractor.ExtractFile` 改为显式接收 `expectedPackageSize`,先打开同一普通文件,以其真实长度核对 Catalog 值和 4 GiB 原始包上限,再以该句柄构造 `zip.NewReader`;没有“预扫 path 后重开 path”的替换窗口。
- 新的 EOCD 预扫描只读取最多 65,557 字节尾部和固定 ZIP64 locator/end 记录,拒绝跨盘、截断、offset/size 溢出或不一致结构,在标准库解析中央目录前限制 64 MiB 中央目录及 10,000 个声明条目。现有完整 `preflight` 保留为第二道 ZIP 语义和展开数据防线。
- installer 回归覆盖 size 不一致、原始包超限、经典 EOCD 条目/目录伪造、跨盘/截断、ZIP64 有效和损坏 locator/end、非普通输入与 staging 未创建;`GOWORK=off go vet ./installer` 和 `go test -count=10 ./installer` 均通过。
- 关闭的是 Extractor 边界,不是 T-302 生产安装整合或真实下载信任链;下一项断电耐久性整改仍独立阻断 T-302。
+4 -1
View File
@@ -3,7 +3,7 @@ id: T-612
title: 在 ZIP 打开前限制包大小与中央目录元数据 title: 在 ZIP 打开前限制包大小与中央目录元数据
phase: 1 phase: 1
deps: [T-605] deps: [T-605]
status: TODO status: DONE
created: 2026-07-18 created: 2026-07-18
issue: null issue: null
context_ref: 320b83d929fde7d51fc5bb3e9fbdaac7beb5101e context_ref: 320b83d929fde7d51fc5bb3e9fbdaac7beb5101e
@@ -69,3 +69,6 @@ T-301 已把 known-total 下载限定为精确长度,并在 final `.download`
- 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 2 项落成任务;现有全局最大任务为 T-611,因此取 T-612,依赖已完成的 T-605。 - 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 2 项落成任务;现有全局最大任务为 T-611,因此取 T-612,依赖已完成的 T-605。
- 2026-07-18:代码图确认 `ExtractFile` 直接调用 `zip.OpenReader`,随后才在 `preflight` 用 `len(archive.File)` 检查条目数;其调用方目前均为 installer 测试。T-301 已对 known-total 下载以已验签 Catalog size 严格限长并发布普通 final 文件,但尚无生产安装编排。因此本任务通过显式 `expectedPackageSize` 和同句柄预扫描建立正确接口,不宣称已完成 T-302 接线。 - 2026-07-18:代码图确认 `ExtractFile` 直接调用 `zip.OpenReader`,随后才在 `preflight` 用 `len(archive.File)` 检查条目数;其调用方目前均为 installer 测试。T-301 已对 known-total 下载以已验签 Catalog size 严格限长并发布普通 final 文件,但尚无生产安装编排。因此本任务通过显式 `expectedPackageSize` 和同句柄预扫描建立正确接口,不宣称已完成 T-302 接线。
- 2026-07-18:默认 `MaxArchiveBytes` 将与 downloader `DefaultMaxUnknownBytes` 的 4 GiB 保持一致;中央目录单独限额及 ZIP64 兼容性必须以测试和文档冻结,真实包采样后的阈值复核仍留给 T-302。 - 2026-07-18:默认 `MaxArchiveBytes` 将与 downloader `DefaultMaxUnknownBytes` 的 4 GiB 保持一致;中央目录单独限额及 ZIP64 兼容性必须以测试和文档冻结,真实包采样后的阈值复核仍留给 T-302。
- 2026-07-18:实现 `MaxArchiveBytes=4 GiB`、`MaxCentralDirectoryBytes=64 MiB` 和对应配置校验;`ExtractFile` 现在强制接收 expected Catalog size,打开同一普通文件后先核对大小并执行有界 EOCD/ZIP64 扫描,再用该句柄构造 `zip.NewReader`。声明 entries/central size 在标准库解析前受限,跨盘、截断、越界与不一致 ZIP64 结构 fail closed,既有完整 `preflight` 保留。
- 2026-07-18:installer 回归新增 size 不一致、原始包超限、经典 EOCD 条目/中央目录伪造、跨盘、越界、截断、ZIP64 成功/损坏及非普通输入矩阵,全部确认拒绝时不创建 staging;既有路径、CRC、展开量、压缩比与 destination 清理回归继续通过。
- 2026-07-18:验证通过:`GOWORK=off go vet ./installer`、`GOWORK=off go test -count=10 ./installer`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前 `git diff --check`;完整 Phase 0 闸门同时复跑 core 全包、modern/Win7 UI 测试与双目标构建。