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
+28 -18
View File
@@ -14,20 +14,23 @@ import (
)
var (
ErrInvalidArchive = errors.New("invalid ZIP archive")
ErrPathEscape = errors.New("ZIP path escapes payload")
ErrUnsupportedEntry = errors.New("unsupported ZIP entry")
ErrUnexpectedEntry = errors.New("unexpected ZIP package entry")
ErrDuplicateEntry = errors.New("duplicate ZIP entry")
ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported")
ErrTooManyEntries = errors.New("ZIP entry limit exceeded")
ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded")
ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded")
ErrEntrypointInvalid = errors.New("invalid package entrypoint")
ErrEntrypointMissing = errors.New("package entrypoint is missing")
ErrAppManifestMissing = errors.New("package app.json is missing")
ErrDestinationExists = errors.New("staging destination already exists")
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
ErrInvalidArchive = errors.New("invalid ZIP archive")
ErrPathEscape = errors.New("ZIP path escapes payload")
ErrUnsupportedEntry = errors.New("unsupported ZIP entry")
ErrUnexpectedEntry = errors.New("unexpected ZIP package entry")
ErrDuplicateEntry = errors.New("duplicate ZIP entry")
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")
ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded")
ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded")
ErrEntrypointInvalid = errors.New("invalid package entrypoint")
ErrEntrypointMissing = errors.New("package entrypoint is missing")
ErrAppManifestMissing = errors.New("package app.json is missing")
ErrDestinationExists = errors.New("staging destination already exists")
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
)
// Extractor writes only payload/ contents from a pre-verified package ZIP.
@@ -56,18 +59,25 @@ func NewExtractor(limits Limits) (Extractor, error) {
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(
zipPath string,
destination string,
entrypoint string,
expectedPackageSize int64,
) (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 {
return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err)
}
defer archive.Close()
return extractor.extract(&archive.Reader, destination, entrypoint)
return extractor.extract(archive, destination, entrypoint)
}
func (extractor Extractor) extract(
+73 -23
View File
@@ -21,7 +21,12 @@ func TestExtractorExtractsPayloadOnly(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
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 {
t.Fatalf("ExtractFile() error = %v", err)
}
@@ -216,9 +221,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 2,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
MaxEntries: 2,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
},
wantErr: ErrTooManyEntries,
},
@@ -230,9 +237,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
},
wantErr: ErrExpandedTooLarge,
},
@@ -248,9 +257,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
},
wantErr: ErrCompressionRatio,
},
@@ -263,7 +274,12 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
destination := filepath.Join(root, "staging")
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) {
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) {
destination := filepath.Join(t.TempDir(), "staging")
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) {
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")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe")
result, err := extractor.ExtractFile(
archivePath,
destination,
"工具/解析器.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
@@ -346,7 +372,12 @@ func TestExtractorRejectsExistingDestination(t *testing.T) {
}
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) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
}
@@ -361,7 +392,12 @@ func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
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) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
}
@@ -380,12 +416,23 @@ type testZIPEntry struct {
func testLimits() Limits {
return Limits{
MaxEntries: 20,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
MaxEntries: 20,
MaxArchiveBytes: 64 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 16 * 1024,
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 {
t.Helper()
extractor, err := NewExtractor(limits)
@@ -483,11 +530,14 @@ func TestDefaultLimitsAreValid(t *testing.T) {
func TestExtractorRejectsInvalidLimits(t *testing.T) {
tests := []Limits{
{MaxEntries: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
{MaxEntries: 0, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 0, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 2, MaxUncompressedBytes: 1, MaxCompressionRatio: 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 {
+6 -1
View File
@@ -19,7 +19,12 @@ func TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem(t *testing.T)
destination := filepath.Join(root, "staging")
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) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape)
}
+24 -9
View File
@@ -9,23 +9,29 @@ import (
var ErrInvalidLimits = errors.New("invalid ZIP extraction limits")
const (
DefaultMaxEntries = 10_000
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCompressionRatio = 200.0
DefaultMaxEntries = 10_000
DefaultMaxArchiveBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCentralDirectoryBytes = int64(64 * 1024 * 1024)
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCompressionRatio = 200.0
)
// Limits bounds archive metadata and decompressed output.
type Limits struct {
MaxEntries int
MaxUncompressedBytes int64
MaxCompressionRatio float64
MaxEntries int
MaxArchiveBytes int64
MaxCentralDirectoryBytes int64
MaxUncompressedBytes int64
MaxCompressionRatio float64
}
func DefaultLimits() Limits {
return Limits{
MaxEntries: DefaultMaxEntries,
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio,
MaxEntries: DefaultMaxEntries,
MaxArchiveBytes: DefaultMaxArchiveBytes,
MaxCentralDirectoryBytes: DefaultMaxCentralDirectoryBytes,
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio,
}
}
@@ -33,6 +39,15 @@ func (limits Limits) validate() error {
if limits.MaxEntries <= 0 {
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 {
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 安全解压、原子切换回滚原型。
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
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。
- 写入句柄的文件身份贯穿 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 等一次性通知丢失后永久停链。
- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 不得把它们当信任根,仍需从已验签 Catalog 重新取得并核对身份/size/hash/signature。
- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 不得把它们当信任根,仍需从已验签 Catalog 重新取得并核对身份/size/hash/signature,并把该 Catalog `size` 传给 `Extractor.ExtractFile` 对同一打开的普通完成文件重新对账。
### 4.3 事件模型
@@ -170,15 +170,16 @@ T-301 下载队列:
```text
读取已签名 Catalog → 选择 OS/架构匹配的 Package → 下载到 downloads
→ 校验 size 与 SHA-256 → 安全读取 app.json → 比对 ID/版本/通道/系统/架构
→ 检查 ZIP 路径与解压上限 → 解压 payload 到 staging → 校验 entry_exe
→ 校验 size 与 SHA-256 → 打开同一普通完成文件,使实际长度 = Catalog size
→ 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 安全读取 app.json
→ 比对 ID/版本/通道/系统/架构 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging → 校验 entry_exe
→ 确认目标软件已退出 → current 改名 backup → staging 原子切换为 current
→ 健康检查 → 成功延迟清理 backup / 失败恢复 backup
```
必须防止:绝对路径、`../` 与 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)。
+1
View File
@@ -35,6 +35,7 @@
- 任何下载内容未通过 SHA-256 + 签名验证,**不得解压执行**;清单验签失败拒绝,不回退到未验证内容。
- 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 解压必须保留文件数、展开体积和压缩比硬上限。
- 安装/更新只走 `staging → current → backup` 原子流程;任何写 `current/` 的捷径都不允许。
- 程序更新不得触碰 `data/` 与 `licenses/`。
+1 -1
View File
@@ -42,7 +42,7 @@
#### 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 | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- |
+3 -1
View File
@@ -153,7 +153,9 @@ T-102 Phase 1 原型进一步固定:
- ZIP 名称只接受 UTF-8 `/` 分隔的规范 Windows 安全相对路径;逐段拒绝反斜杠、盘符、冒号/NTFS ADS、NUL/控制字符、Windows 禁止字符、`.`/`..`、首尾 ASCII 空格、尾随句点、DOS 设备名及大小写折叠后的重复输出路径。
- 顶层只允许必需的 `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 中的普通文件。
- 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。
+7 -7
View File
@@ -13,22 +13,22 @@
## 当前快照
- 日期: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 不交叉解析
- 生产代码: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、列表快照 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/本地状态/存储、共享 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 包含性与 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/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理: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/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 双平台验证入口 |
| `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 职责文件 |
@@ -40,9 +40,9 @@
任务状态以 `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 描述为"无路径穿越"。
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/真机断电注入验证。
4. **[协议冻结前]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。
5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。
@@ -208,3 +208,10 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题
7. **[低优先]** 固定解压落盘 mode,可执行入口由已验证 app.json/Catalog 定义。
> 裁定: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 打开前限制包大小与中央目录元数据
phase: 1
deps: [T-605]
status: TODO
status: DONE
created: 2026-07-18
issue: null
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:代码图确认 `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=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 测试与双目标构建。