Preserve staging I/O failure causes (T-615)
This commit is contained in:
@@ -83,6 +83,12 @@ type DiskSpaceChecker interface {
|
|||||||
AvailableBytes(appRoot string) (int64, error)
|
AvailableBytes(appRoot string) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StorageFailureClassifier lets the platform identify a preserved staging I/O
|
||||||
|
// failure as insufficient storage without importing platform APIs into core.
|
||||||
|
type StorageFailureClassifier interface {
|
||||||
|
IsDiskFull(err error) bool
|
||||||
|
}
|
||||||
|
|
||||||
// TargetStateChecker reports whether the verified current entrypoint is still
|
// TargetStateChecker reports whether the verified current entrypoint is still
|
||||||
// running. It never starts, waits for, or terminates a process.
|
// running. It never starts, waits for, or terminates a process.
|
||||||
type TargetStateChecker interface {
|
type TargetStateChecker interface {
|
||||||
@@ -106,24 +112,26 @@ type InstallResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InstallServiceConfig makes all external installation dependencies explicit.
|
// InstallServiceConfig makes all external installation dependencies explicit.
|
||||||
// Disk and target-state checks are mandatory so no caller can silently bypass
|
// Disk, storage-failure and target-state checks are mandatory so no caller can
|
||||||
// the pre-extract safety boundary.
|
// silently bypass the pre-extract safety boundary or disk-full diagnosis.
|
||||||
type InstallServiceConfig struct {
|
type InstallServiceConfig struct {
|
||||||
Extractor installer.Extractor
|
Extractor installer.Extractor
|
||||||
Records InstallRecordStore
|
Records InstallRecordStore
|
||||||
Health installer.HealthCheck
|
Health installer.HealthCheck
|
||||||
DiskSpace DiskSpaceChecker
|
DiskSpace DiskSpaceChecker
|
||||||
TargetState TargetStateChecker
|
StorageFailures StorageFailureClassifier
|
||||||
|
TargetState TargetStateChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallService implements the core-only verified package installation use
|
// InstallService implements the core-only verified package installation use
|
||||||
// case. The caller must supply entries produced by catalog.Client.
|
// case. The caller must supply entries produced by catalog.Client.
|
||||||
type InstallService struct {
|
type InstallService struct {
|
||||||
extractor installer.Extractor
|
extractor installer.Extractor
|
||||||
records InstallRecordStore
|
records InstallRecordStore
|
||||||
health installer.HealthCheck
|
health installer.HealthCheck
|
||||||
diskSpace DiskSpaceChecker
|
diskSpace DiskSpaceChecker
|
||||||
targetState TargetStateChecker
|
storageFailures StorageFailureClassifier
|
||||||
|
targetState TargetStateChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
|
func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
|
||||||
@@ -136,15 +144,19 @@ func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
|
|||||||
if config.DiskSpace == nil {
|
if config.DiskSpace == nil {
|
||||||
return nil, fmt.Errorf("%w: disk space checker is required", ErrInstallServiceConfig)
|
return nil, fmt.Errorf("%w: disk space checker is required", ErrInstallServiceConfig)
|
||||||
}
|
}
|
||||||
|
if config.StorageFailures == nil {
|
||||||
|
return nil, fmt.Errorf("%w: storage failure classifier is required", ErrInstallServiceConfig)
|
||||||
|
}
|
||||||
if config.TargetState == nil {
|
if config.TargetState == nil {
|
||||||
return nil, fmt.Errorf("%w: target state checker is required", ErrInstallServiceConfig)
|
return nil, fmt.Errorf("%w: target state checker is required", ErrInstallServiceConfig)
|
||||||
}
|
}
|
||||||
return &InstallService{
|
return &InstallService{
|
||||||
extractor: config.Extractor,
|
extractor: config.Extractor,
|
||||||
records: config.Records,
|
records: config.Records,
|
||||||
health: config.Health,
|
health: config.Health,
|
||||||
diskSpace: config.DiskSpace,
|
diskSpace: config.DiskSpace,
|
||||||
targetState: config.TargetState,
|
storageFailures: config.StorageFailures,
|
||||||
|
targetState: config.TargetState,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,16 +166,16 @@ func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
|
|||||||
func (service *InstallService) Install(request InstallRequest) (InstallResult, error) {
|
func (service *InstallService) Install(request InstallRequest) (InstallResult, error) {
|
||||||
expectation, record, err := resolveInstallRequest(request)
|
expectation, record, err := resolveInstallRequest(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return InstallResult{}, installError(InstallStageVerify, err)
|
return InstallResult{}, service.installError(InstallStageVerify, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
appRoot, err := service.records.EnsureAppRoot(record.ID)
|
appRoot, err := service.records.EnsureAppRoot(record.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return InstallResult{}, installError(InstallStageRecover, err)
|
return InstallResult{}, service.installError(InstallStageRecover, err)
|
||||||
}
|
}
|
||||||
recovery, err := installer.Recover(appRoot)
|
recovery, err := installer.Recover(appRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return InstallResult{}, installError(InstallStageRecover, err)
|
return InstallResult{}, service.installError(InstallStageRecover, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
extracted, err := service.extractor.ExtractVerifiedFileWithCheck(
|
extracted, err := service.extractor.ExtractVerifiedFileWithCheck(
|
||||||
@@ -173,7 +185,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
|
|||||||
service.preExtractCheck(appRoot, record.ID),
|
service.preExtractCheck(appRoot, record.ID),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return InstallResult{}, installError(stageForPackageError(err), err)
|
return InstallResult{}, service.installError(stageForPackageError(err), err)
|
||||||
}
|
}
|
||||||
record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles))
|
record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles))
|
||||||
for _, file := range extracted.PayloadFiles {
|
for _, file := range extracted.PayloadFiles {
|
||||||
@@ -196,7 +208,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err := switcher.Switch(appRoot); err != nil {
|
if err := switcher.Switch(appRoot); err != nil {
|
||||||
return InstallResult{}, installError(stageForSwitchError(err, recordWriteErr), err)
|
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
|
||||||
}
|
}
|
||||||
return InstallResult{
|
return InstallResult{
|
||||||
AppID: record.ID,
|
AppID: record.ID,
|
||||||
@@ -298,8 +310,15 @@ func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func installError(stage InstallStage, err error) error {
|
func (service *InstallService) installError(stage InstallStage, err error) error {
|
||||||
return &InstallError{Stage: stage, Code: failureCodeFor(err), Err: err}
|
return &InstallError{Stage: stage, Code: service.failureCodeFor(err), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *InstallService) failureCodeFor(err error) FailureCode {
|
||||||
|
if errors.Is(err, installer.ErrStagingOutput) && service.storageFailures.IsDiskFull(err) {
|
||||||
|
return FailureCodeDiskFull
|
||||||
|
}
|
||||||
|
return failureCodeFor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stageForPackageError(err error) InstallStage {
|
func stageForPackageError(err error) InstallStage {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -196,6 +197,9 @@ func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
|
|||||||
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
|
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
return StagingDiskReserveBytes, nil
|
return StagingDiskReserveBytes, nil
|
||||||
}),
|
}),
|
||||||
|
StorageFailures: storageFailureClassifierFunc(func(error) bool {
|
||||||
|
return false
|
||||||
|
}),
|
||||||
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
|
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}),
|
}),
|
||||||
@@ -217,6 +221,12 @@ func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
|
|||||||
config.TargetState = nil
|
config.TargetState = nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "storage failure classifier",
|
||||||
|
modify: func(config *InstallServiceConfig) {
|
||||||
|
config.StorageFailures = nil
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -248,6 +258,62 @@ func TestFailureCodeForPackageFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceClassifiesStagingOutputFailures(t *testing.T) {
|
||||||
|
errDiskFull := errors.New("injected disk full")
|
||||||
|
errOutput := errors.New("injected output failure")
|
||||||
|
service := &InstallService{
|
||||||
|
storageFailures: storageFailureClassifierFunc(func(err error) bool {
|
||||||
|
return errors.Is(err, errDiskFull)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
code FailureCode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "write disk full",
|
||||||
|
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||||
|
code: FailureCodeDiskFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sync disk full",
|
||||||
|
err: fmt.Errorf("%w: sync staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||||
|
code: FailureCodeDiskFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "close disk full",
|
||||||
|
err: fmt.Errorf("%w: close staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||||
|
code: FailureCodeDiskFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic output I O",
|
||||||
|
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errOutput),
|
||||||
|
code: FailureCodeInstallFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ZIP input remains corrupt",
|
||||||
|
err: fmt.Errorf("%w: %w", installer.ErrArchiveCorrupt, errDiskFull),
|
||||||
|
code: FailureCodeZIPCorrupt,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
wrapped := service.installError(InstallStageExtract, test.err)
|
||||||
|
var installErr *InstallError
|
||||||
|
if !errors.As(wrapped, &installErr) {
|
||||||
|
t.Fatalf("install error = %v, want InstallError", wrapped)
|
||||||
|
}
|
||||||
|
if installErr.Code != test.code {
|
||||||
|
t.Fatalf("code = %q, want %q", installErr.Code, test.code)
|
||||||
|
}
|
||||||
|
if !errors.Is(wrapped, test.err) {
|
||||||
|
t.Fatalf("install error = %v, want preserved cause", wrapped)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
|
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
|
||||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
@@ -555,10 +621,13 @@ func newInstallServiceWithCheckers(
|
|||||||
t.Fatalf("NewExtractor() error = %v", err)
|
t.Fatalf("NewExtractor() error = %v", err)
|
||||||
}
|
}
|
||||||
service, err := NewInstallService(InstallServiceConfig{
|
service, err := NewInstallService(InstallServiceConfig{
|
||||||
Extractor: extractor,
|
Extractor: extractor,
|
||||||
Records: store,
|
Records: store,
|
||||||
Health: health,
|
Health: health,
|
||||||
DiskSpace: diskSpace,
|
DiskSpace: diskSpace,
|
||||||
|
StorageFailures: storageFailureClassifierFunc(func(error) bool {
|
||||||
|
return false
|
||||||
|
}),
|
||||||
TargetState: targetState,
|
TargetState: targetState,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -573,6 +642,12 @@ func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error)
|
|||||||
return check(appRoot)
|
return check(appRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type storageFailureClassifierFunc func(error) bool
|
||||||
|
|
||||||
|
func (classifier storageFailureClassifierFunc) IsDiskFull(err error) bool {
|
||||||
|
return classifier(err)
|
||||||
|
}
|
||||||
|
|
||||||
type targetStateCheckerFunc func(string, string) (bool, error)
|
type targetStateCheckerFunc func(string, string) (bool, error)
|
||||||
|
|
||||||
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
|
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ func effectiveDurability(fence durabilityFence) durabilityFence {
|
|||||||
|
|
||||||
func syncFileWithFence(fence durabilityFence, file *os.File, description string) error {
|
func syncFileWithFence(fence durabilityFence, file *os.File, description string) error {
|
||||||
if err := effectiveDurability(fence).syncFile(file); err != nil {
|
if err := effectiveDurability(fence).syncFile(file); err != nil {
|
||||||
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
|
return fmt.Errorf("%w: sync %s: %w", ErrDurability, description, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncDirectoryWithFence(fence durabilityFence, path, description string) error {
|
func syncDirectoryWithFence(fence durabilityFence, path, description string) error {
|
||||||
if err := effectiveDurability(fence).syncDirectory(path); err != nil {
|
if err := effectiveDurability(fence).syncDirectory(path); err != nil {
|
||||||
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
|
return fmt.Errorf("%w: sync %s: %w", ErrDurability, description, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-29
@@ -33,12 +33,15 @@ var (
|
|||||||
ErrAppManifestMissing = errors.New("package app.json is missing")
|
ErrAppManifestMissing = errors.New("package app.json is missing")
|
||||||
ErrDestinationExists = errors.New("staging destination already exists")
|
ErrDestinationExists = errors.New("staging destination already exists")
|
||||||
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
|
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
|
||||||
|
ErrStagingOutput = errors.New("staging output failed")
|
||||||
|
ErrStagingCleanup = errors.New("staging cleanup failed")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Extractor writes only payload/ contents from a pre-verified package ZIP.
|
// Extractor writes only payload/ contents from a pre-verified package ZIP.
|
||||||
type Extractor struct {
|
type Extractor struct {
|
||||||
limits Limits
|
limits Limits
|
||||||
durability durabilityFence
|
durability durabilityFence
|
||||||
|
files stagingFileOperations
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtractResult struct {
|
type ExtractResult struct {
|
||||||
@@ -87,7 +90,11 @@ func NewExtractor(limits Limits) (Extractor, error) {
|
|||||||
if err := limits.validate(); err != nil {
|
if err := limits.validate(); err != nil {
|
||||||
return Extractor{}, err
|
return Extractor{}, err
|
||||||
}
|
}
|
||||||
return Extractor{limits: limits, durability: defaultDurability()}, nil
|
return Extractor{
|
||||||
|
limits: limits,
|
||||||
|
durability: defaultDurability(),
|
||||||
|
files: defaultStagingFileOperations(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractFile requires expectedPackageSize from the verified Catalog package.
|
// ExtractFile requires expectedPackageSize from the verified Catalog package.
|
||||||
@@ -136,6 +143,7 @@ func (extractor Extractor) extractPlan(
|
|||||||
plan []plannedEntry,
|
plan []plannedEntry,
|
||||||
) (result ExtractResult, err error) {
|
) (result ExtractResult, err error) {
|
||||||
fence := effectiveDurability(extractor.durability)
|
fence := effectiveDurability(extractor.durability)
|
||||||
|
files := effectiveStagingFileOperations(extractor.files)
|
||||||
destinationRoot, entrypointPath, err := planOutputPaths(
|
destinationRoot, entrypointPath, err := planOutputPaths(
|
||||||
destination,
|
destination,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
@@ -145,19 +153,21 @@ func (extractor Extractor) extractPlan(
|
|||||||
return ExtractResult{}, err
|
return ExtractResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil {
|
if err := files.mkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil {
|
||||||
return ExtractResult{}, fmt.Errorf("create staging parent: %w", err)
|
return ExtractResult{}, stagingOutputError("create staging parent", err)
|
||||||
}
|
}
|
||||||
if err := os.Mkdir(destinationRoot, 0o700); err != nil {
|
if err := files.mkdir(destinationRoot, 0o700); err != nil {
|
||||||
if os.IsExist(err) {
|
if os.IsExist(err) {
|
||||||
return ExtractResult{}, ErrDestinationExists
|
return ExtractResult{}, ErrDestinationExists
|
||||||
}
|
}
|
||||||
return ExtractResult{}, fmt.Errorf("create staging destination: %w", err)
|
return ExtractResult{}, stagingOutputError("create staging destination", err)
|
||||||
}
|
}
|
||||||
complete := false
|
complete := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if !complete {
|
if !complete {
|
||||||
_ = os.RemoveAll(destinationRoot)
|
if cleanupErr := files.removeAll(destinationRoot); cleanupErr != nil {
|
||||||
|
err = errors.Join(err, stagingCleanupError(cleanupErr))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -167,31 +177,37 @@ func (extractor Extractor) extractPlan(
|
|||||||
if entry.outputPath == "" {
|
if entry.outputPath == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(entry.targetPath, 0o700); err != nil {
|
if err := files.mkdirAll(entry.targetPath, 0o700); err != nil {
|
||||||
return ExtractResult{}, fmt.Errorf("create staging directory: %w", err)
|
return ExtractResult{}, stagingOutputError("create staging directory", err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil {
|
if err := files.mkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil {
|
||||||
return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err)
|
return ExtractResult{}, stagingOutputError("create staging file parent", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
source, err := entry.file.Open()
|
source, err := entry.file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExtractResult{}, fmt.Errorf("%w: open %s: %v", ErrArchiveCorrupt, entry.archivePath, err)
|
return ExtractResult{}, archiveInputError("open", entry.archivePath, err)
|
||||||
}
|
}
|
||||||
mode := os.FileMode(0o600)
|
mode := os.FileMode(0o600)
|
||||||
if entry.file.Mode().Perm()&0o111 != 0 {
|
if entry.file.Mode().Perm()&0o111 != 0 {
|
||||||
mode = 0o700
|
mode = 0o700
|
||||||
}
|
}
|
||||||
output, err := os.OpenFile(
|
output, err := files.openFile(
|
||||||
entry.targetPath,
|
entry.targetPath,
|
||||||
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
|
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
|
||||||
mode,
|
mode,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
source.Close()
|
outputErr := stagingOutputError("create staging file", err)
|
||||||
return ExtractResult{}, fmt.Errorf("create staging file: %w", err)
|
if closeErr := source.Close(); closeErr != nil {
|
||||||
|
return ExtractResult{}, errors.Join(
|
||||||
|
outputErr,
|
||||||
|
archiveInputError("close", entry.archivePath, closeErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ExtractResult{}, outputErr
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := extractor.limits.MaxUncompressedBytes - written
|
remaining := extractor.limits.MaxUncompressedBytes - written
|
||||||
@@ -200,39 +216,45 @@ func (extractor Extractor) extractPlan(
|
|||||||
readLimit++
|
readLimit++
|
||||||
}
|
}
|
||||||
digest := sha256.New()
|
digest := sha256.New()
|
||||||
|
writer := stagingWriter{writer: output}
|
||||||
copied, copyErr := io.Copy(
|
copied, copyErr := io.Copy(
|
||||||
io.MultiWriter(output, digest),
|
io.MultiWriter(&writer, digest),
|
||||||
io.LimitReader(source, readLimit),
|
io.LimitReader(source, readLimit),
|
||||||
)
|
)
|
||||||
closeSourceErr := source.Close()
|
closeSourceErr := source.Close()
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
_ = output.Close()
|
primary := archiveInputError("read", entry.archivePath, copyErr)
|
||||||
return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr)
|
if writer.err != nil {
|
||||||
|
primary = stagingOutputError("write staging file", writer.err)
|
||||||
|
}
|
||||||
|
return ExtractResult{}, joinStagingCloseError(primary, output)
|
||||||
}
|
}
|
||||||
if closeSourceErr != nil {
|
if closeSourceErr != nil {
|
||||||
_ = output.Close()
|
return ExtractResult{}, joinStagingCloseError(
|
||||||
return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr)
|
archiveInputError("close", entry.archivePath, closeSourceErr),
|
||||||
|
output,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if copied > remaining {
|
if copied > remaining {
|
||||||
_ = output.Close()
|
return ExtractResult{}, joinStagingCloseError(ErrExpandedTooLarge, output)
|
||||||
return ExtractResult{}, ErrExpandedTooLarge
|
|
||||||
}
|
}
|
||||||
if uint64(copied) != entry.file.UncompressedSize64 {
|
if uint64(copied) != entry.file.UncompressedSize64 {
|
||||||
_ = output.Close()
|
return ExtractResult{}, joinStagingCloseError(fmt.Errorf(
|
||||||
return ExtractResult{}, fmt.Errorf(
|
|
||||||
"%w: %s expanded to %d bytes, header declares %d",
|
"%w: %s expanded to %d bytes, header declares %d",
|
||||||
ErrArchiveCorrupt,
|
ErrArchiveCorrupt,
|
||||||
entry.archivePath,
|
entry.archivePath,
|
||||||
copied,
|
copied,
|
||||||
entry.file.UncompressedSize64,
|
entry.file.UncompressedSize64,
|
||||||
|
), output)
|
||||||
|
}
|
||||||
|
if err := syncFileWithFence(fence, output.osFile(), "staging payload"); err != nil {
|
||||||
|
return ExtractResult{}, joinStagingCloseError(
|
||||||
|
stagingOutputError("sync staging file", err),
|
||||||
|
output,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err := syncFileWithFence(fence, output, "staging payload"); err != nil {
|
|
||||||
_ = output.Close()
|
|
||||||
return ExtractResult{}, err
|
|
||||||
}
|
|
||||||
if err := output.Close(); err != nil {
|
if err := output.Close(); err != nil {
|
||||||
return ExtractResult{}, fmt.Errorf("close staging file: %w", err)
|
return ExtractResult{}, stagingOutputError("close staging file", err)
|
||||||
}
|
}
|
||||||
written += copied
|
written += copied
|
||||||
result.Files++
|
result.Files++
|
||||||
@@ -243,7 +265,7 @@ func (extractor Extractor) extractPlan(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := syncStagingTree(fence, destinationRoot); err != nil {
|
if err := syncStagingTree(fence, destinationRoot); err != nil {
|
||||||
return ExtractResult{}, err
|
return ExtractResult{}, stagingOutputError("sync staging tree", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Bytes = written
|
result.Bytes = written
|
||||||
@@ -252,6 +274,40 @@ func (extractor Extractor) extractPlan(
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stagingWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *stagingWriter) Write(data []byte) (int, error) {
|
||||||
|
written, err := writer.writer.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
writer.err = err
|
||||||
|
} else if written != len(data) {
|
||||||
|
writer.err = io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveInputError(operation, path string, cause error) error {
|
||||||
|
return fmt.Errorf("%w: %s %s: %w", ErrArchiveCorrupt, operation, path, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stagingOutputError(operation string, cause error) error {
|
||||||
|
return fmt.Errorf("%w: %s: %w", ErrStagingOutput, operation, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stagingCleanupError(cause error) error {
|
||||||
|
return fmt.Errorf("%w: remove staging: %w", ErrStagingCleanup, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinStagingCloseError(primary error, output stagingOutputFile) error {
|
||||||
|
if closeErr := output.Close(); closeErr != nil {
|
||||||
|
return errors.Join(primary, stagingOutputError("close staging file", closeErr))
|
||||||
|
}
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
|
||||||
func planOutputPaths(
|
func planOutputPaths(
|
||||||
destination string,
|
destination string,
|
||||||
entrypoint string,
|
entrypoint string,
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractorKeepsStagingOutputFailuresDistinctFromZIPInput(t *testing.T) {
|
||||||
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{name: "payload/App.exe", body: []byte("executable")},
|
||||||
|
})
|
||||||
|
errDiskFull := errors.New("injected disk full")
|
||||||
|
errOutput := errors.New("injected output failure")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
failure error
|
||||||
|
configure func(*Extractor, error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "write disk full",
|
||||||
|
failure: errDiskFull,
|
||||||
|
configure: func(extractor *Extractor, failure error) {
|
||||||
|
extractor.files = outputFailureOperations{
|
||||||
|
stagingFileOperations: defaultStagingFileOperations(),
|
||||||
|
writeErr: failure,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sync disk full",
|
||||||
|
failure: errDiskFull,
|
||||||
|
configure: func(extractor *Extractor, failure error) {
|
||||||
|
extractor.durability = fileSyncFailureFence{err: failure}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "close disk full",
|
||||||
|
failure: errDiskFull,
|
||||||
|
configure: func(extractor *Extractor, failure error) {
|
||||||
|
extractor.files = outputFailureOperations{
|
||||||
|
stagingFileOperations: defaultStagingFileOperations(),
|
||||||
|
closeErr: failure,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic output I O",
|
||||||
|
failure: errOutput,
|
||||||
|
configure: func(extractor *Extractor, failure error) {
|
||||||
|
extractor.files = outputFailureOperations{
|
||||||
|
stagingFileOperations: defaultStagingFileOperations(),
|
||||||
|
writeErr: failure,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
extractor := mustExtractor(t, testLimits())
|
||||||
|
test.configure(&extractor, test.failure)
|
||||||
|
destination := filepath.Join(t.TempDir(), "staging")
|
||||||
|
|
||||||
|
_, err := extractor.ExtractFile(
|
||||||
|
archivePath,
|
||||||
|
destination,
|
||||||
|
"App.exe",
|
||||||
|
archiveSize(t, archivePath),
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrStagingOutput) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrStagingOutput)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrArchiveCorrupt) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, must not be ZIP corruption", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, test.failure) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, want preserved %v", err, test.failure)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("output failure left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorReportsCleanupFailureAndRecoveryRemovesOnlyManagedStaging(t *testing.T) {
|
||||||
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{name: "payload/App.exe", body: []byte("verified bytes"), method: zip.Store},
|
||||||
|
})
|
||||||
|
corruptZIPEntryData(t, archivePath, "payload/App.exe")
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
currentPath := filepath.Join(root, "current", "App.exe")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(currentPath), 0o700); err != nil {
|
||||||
|
t.Fatalf("create current: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(currentPath, []byte("old executable"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write current: %v", err)
|
||||||
|
}
|
||||||
|
recordPath := filepath.Join(root, "installed-app.json")
|
||||||
|
oldRecord := []byte(`{"version":"1.0.0"}`)
|
||||||
|
if err := os.WriteFile(recordPath, oldRecord, 0o600); err != nil {
|
||||||
|
t.Fatalf("write record: %v", err)
|
||||||
|
}
|
||||||
|
for _, preserved := range []string{"data", "licenses"} {
|
||||||
|
path := filepath.Join(root, preserved, "keep.txt")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", preserved, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(preserved), 0o600); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", preserved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errCleanup := errors.New("injected cleanup failure")
|
||||||
|
extractor := mustExtractor(t, testLimits())
|
||||||
|
extractor.files = outputFailureOperations{
|
||||||
|
stagingFileOperations: defaultStagingFileOperations(),
|
||||||
|
cleanupErr: errCleanup,
|
||||||
|
}
|
||||||
|
destination := filepath.Join(root, "staging")
|
||||||
|
_, err := extractor.ExtractFile(
|
||||||
|
archivePath,
|
||||||
|
destination,
|
||||||
|
"App.exe",
|
||||||
|
archiveSize(t, archivePath),
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrArchiveCorrupt) || !errors.Is(err, ErrStagingCleanup) || !errors.Is(err, errCleanup) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, want ZIP and cleanup causes", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(destination); statErr != nil {
|
||||||
|
t.Fatalf("cleanup failure removed staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Recover(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Action != RecoveryAborted {
|
||||||
|
t.Fatalf("recovery action = %q, want %q", result.Action, RecoveryAborted)
|
||||||
|
}
|
||||||
|
if got := mustReadTestFile(t, currentPath); got != "old executable" {
|
||||||
|
t.Fatalf("current after recovery = %q", got)
|
||||||
|
}
|
||||||
|
if record, readErr := os.ReadFile(recordPath); readErr != nil || string(record) != string(oldRecord) {
|
||||||
|
t.Fatalf("record after recovery = %q error = %v", record, readErr)
|
||||||
|
}
|
||||||
|
for _, preserved := range []string{"data", "licenses"} {
|
||||||
|
if got := mustReadTestFile(t, filepath.Join(root, preserved, "keep.txt")); got != preserved {
|
||||||
|
t.Fatalf("%s after recovery = %q", preserved, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("Recover() left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type outputFailureOperations struct {
|
||||||
|
stagingFileOperations
|
||||||
|
writeErr error
|
||||||
|
closeErr error
|
||||||
|
cleanupErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (operations outputFailureOperations) openFile(
|
||||||
|
name string,
|
||||||
|
flag int,
|
||||||
|
perm os.FileMode,
|
||||||
|
) (stagingOutputFile, error) {
|
||||||
|
file, err := operations.stagingFileOperations.openFile(name, flag, perm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &outputFailureFile{
|
||||||
|
stagingOutputFile: file,
|
||||||
|
writeErr: operations.writeErr,
|
||||||
|
closeErr: operations.closeErr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (operations outputFailureOperations) removeAll(path string) error {
|
||||||
|
if operations.cleanupErr != nil {
|
||||||
|
return operations.cleanupErr
|
||||||
|
}
|
||||||
|
return operations.stagingFileOperations.removeAll(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
type outputFailureFile struct {
|
||||||
|
stagingOutputFile
|
||||||
|
writeErr error
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *outputFailureFile) Write(data []byte) (int, error) {
|
||||||
|
if file.writeErr != nil {
|
||||||
|
return 0, file.writeErr
|
||||||
|
}
|
||||||
|
return file.stagingOutputFile.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *outputFailureFile) Close() error {
|
||||||
|
closeErr := file.stagingOutputFile.Close()
|
||||||
|
if file.closeErr != nil {
|
||||||
|
return file.closeErr
|
||||||
|
}
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *outputFailureFile) osFile() *os.File {
|
||||||
|
return file.stagingOutputFile.osFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSyncFailureFence struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fence fileSyncFailureFence) syncFile(*os.File) error {
|
||||||
|
return fence.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fence fileSyncFailureFence) syncDirectory(string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadTestFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
@@ -401,6 +401,9 @@ func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, ErrStagingOutput) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, must not be a staging output failure", err)
|
||||||
|
}
|
||||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||||
t.Fatalf("copy failure left staging, stat error = %v", statErr)
|
t.Fatalf("copy failure left staging, stat error = %v", statErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ func recoverWithFence(root string, fence durabilityFence) (RecoveryResult, error
|
|||||||
ErrRecoveryInconsistent,
|
ErrRecoveryInconsistent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if state.staging {
|
||||||
|
if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
return RecoveryResult{Action: RecoveryAborted}, nil
|
||||||
|
}
|
||||||
return RecoveryResult{Action: RecoveryNone}, nil
|
return RecoveryResult{Action: RecoveryNone}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// stagingOutputFile is the narrow output seam used by extraction. Keeping it
|
||||||
|
// internal lets tests inject write and close failures without exposing a file
|
||||||
|
// abstraction to package consumers.
|
||||||
|
type stagingOutputFile interface {
|
||||||
|
Write([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
osFile() *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
type stagingFileOperations interface {
|
||||||
|
mkdirAll(path string, perm os.FileMode) error
|
||||||
|
mkdir(path string, perm os.FileMode) error
|
||||||
|
openFile(name string, flag int, perm os.FileMode) (stagingOutputFile, error)
|
||||||
|
removeAll(path string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type filesystemStagingFileOperations struct{}
|
||||||
|
|
||||||
|
type filesystemStagingOutputFile struct {
|
||||||
|
file *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (operations filesystemStagingFileOperations) mkdirAll(path string, perm os.FileMode) error {
|
||||||
|
return os.MkdirAll(path, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (operations filesystemStagingFileOperations) mkdir(path string, perm os.FileMode) error {
|
||||||
|
return os.Mkdir(path, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (operations filesystemStagingFileOperations) openFile(
|
||||||
|
name string,
|
||||||
|
flag int,
|
||||||
|
perm os.FileMode,
|
||||||
|
) (stagingOutputFile, error) {
|
||||||
|
file, err := os.OpenFile(name, flag, perm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return filesystemStagingOutputFile{file: file}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (operations filesystemStagingFileOperations) removeAll(path string) error {
|
||||||
|
return os.RemoveAll(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file filesystemStagingOutputFile) Write(data []byte) (int, error) {
|
||||||
|
return file.file.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file filesystemStagingOutputFile) Close() error {
|
||||||
|
return file.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file filesystemStagingOutputFile) osFile() *os.File {
|
||||||
|
return file.file
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultStagingFileOperations() stagingFileOperations {
|
||||||
|
return filesystemStagingFileOperations{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveStagingFileOperations(operations stagingFileOperations) stagingFileOperations {
|
||||||
|
if operations == nil {
|
||||||
|
return defaultStagingFileOperations()
|
||||||
|
}
|
||||||
|
return operations
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
当前项目已完成 Phase 0~2、T-301~T-303 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core。T-615 已正式落成,下一步先关闭 staging 输出 I/O 根因与磁盘满诊断,再正式落成 T-401 进程检测与软件启动。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败恢复落实到 core。下一步正式落成 T-401 进程检测与软件启动。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
@@ -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-614:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序和 Catalog 静态签名向量;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。下一步执行 T-615,关闭 staging 输出 I/O 根因保留与磁盘满诊断,再继续 T-401 与 Phase 4-6。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。下一步正式落成并执行 T-401,再继续 Phase 4-6。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
||||||
|
|
||||||
## 领取任务规则
|
## 领取任务规则
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ T-301 下载队列:
|
|||||||
|
|
||||||
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
|
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
|
||||||
|
|
||||||
Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。core 不包含 Windows API、进程枚举、等待、强杀或启动,具体 Toolhelp 适配与进程退出协议由 T-401 在 `platform/windows`/命令装配时实现。
|
Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。core 不包含 Windows API、进程枚举、等待、强杀或启动,具体 Toolhelp 适配与进程退出协议由 T-401 在 `platform/windows`/命令装配时实现。
|
||||||
|
|
||||||
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
-1
@@ -85,7 +85,7 @@ Phase 3 的 T-301 → T-302 → T-303 是安全关键依赖链,任务之间保
|
|||||||
|
|
||||||
#### Phase 3 交叉复核整改
|
#### Phase 3 交叉复核整改
|
||||||
|
|
||||||
`docs/review/phase3-review.md` 的交叉复核裁定:在继续落成 T-401 前,先关闭 T-303 未覆盖的 staging 输出 I/O 根因保留与磁盘满统一诊断;运行状态切换临界区复查随 T-401 落成。
|
`docs/review/phase3-review.md` 的交叉复核裁定已由 T-615 关闭 T-303 未覆盖的 staging 输出 I/O 根因保留与磁盘满统一诊断;运行状态切换临界区复查仍随 T-401 落成。
|
||||||
|
|
||||||
| ID | 任务 | 依赖 | 验收要点 |
|
| ID | 任务 | 依赖 | 验收要点 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|||||||
+6
-3
@@ -160,6 +160,7 @@ T-102 Phase 1 原型进一步固定:
|
|||||||
- entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。
|
- entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。
|
||||||
- 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。
|
- 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。
|
||||||
- `app.json` 必须是 ZIP 根目录唯一普通文件,读取上限 1 MiB;严格 JSON 解析拒绝未知字段和尾随值,完整 v1 字段/常量必须通过运行时校验。`id`、`version`、`channel`、`min_os`、`architecture`、`entrypoint`、`requires_admin` 必须与可信 Catalog selection 一致;只有这一比对成功后才可创建 staging 并提取 payload。
|
- `app.json` 必须是 ZIP 根目录唯一普通文件,读取上限 1 MiB;严格 JSON 解析拒绝未知字段和尾随值,完整 v1 字段/常量必须通过运行时校验。`id`、`version`、`channel`、`min_os`、`architecture`、`entrypoint`、`requires_admin` 必须与可信 Catalog selection 一致;只有这一比对成功后才可创建 staging 并提取 payload。
|
||||||
|
- T-615 将 ZIP 输入与 staging 输出错误分开:ZIP entry 的 open/read/CRC/close 失败保留为 `ErrArchiveCorrupt`;创建目录/文件、write、sync、close 与 staging-tree sync 失败保留 `ErrStagingOutput` 和底层错误链,不能误报为 ZIP 损坏。提取失败后的本次 staging 清理必须可观察;主失败和清理失败同时发生时,两者都可由 `errors.Is` 识别。
|
||||||
|
|
||||||
### 2.4 安装记录 installed-app.json(本地)
|
### 2.4 安装记录 installed-app.json(本地)
|
||||||
|
|
||||||
@@ -218,13 +219,15 @@ T-613 为该原型建立了 fail-closed 的耐久顺序:每个 payload 先完成
|
|||||||
|
|
||||||
T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再原子写入新 `installed-app.json`;health 或记录写失败都必须触发既有 rollback,使旧 current/记录保持可用。只有 health 与记录均成功后才写 committed 并清理 backup/journal。
|
T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再原子写入新 `installed-app.json`;health 或记录写失败都必须触发既有 rollback,使旧 current/记录保持可用。只有 health 与记录均成功后才写 committed 并清理 backup/journal。
|
||||||
|
|
||||||
|
若无 transaction 但存在残留 `staging/`,Recover 只会在 app root 已通过真实目录布局校验后删除该受控同级目录并返回 `aborted`;`staging` 不是目录或布局不安全时 fail closed。此恢复不得覆盖 `current/`、`installed-app.json`、`data/` 或 `licenses/`。
|
||||||
|
|
||||||
这些栅栏与注入失败测试只证明代码层面的调用顺序和 fail-closed 行为,不证明断电后硬件/驱动缓存、网络文件系统、文件锁或杀毒软件的物理表现。T-302 已完成代码整合;T-601 仍须在目标 Windows VM/真机执行断电与干扰故障注入。
|
这些栅栏与注入失败测试只证明代码层面的调用顺序和 fail-closed 行为,不证明断电后硬件/驱动缓存、网络文件系统、文件锁或杀毒软件的物理表现。T-302 已完成代码整合;T-601 仍须在目标 Windows VM/真机执行断电与干扰故障注入。
|
||||||
|
|
||||||
### 2.6 安装预检与失败码
|
### 2.6 安装预检与失败码
|
||||||
|
|
||||||
在同句柄 size/SHA、ZIP 预扫描和严格 `app.json` 身份比对均通过后,提取器会在创建 `staging/` 前提供已规划 payload 的准确展开字节数、普通文件数和安全 entrypoint。安装 use case 必须以 `payload_bytes + 64 MiB` 查询 app root 所在卷的可用空间;可用空间不足时不创建 staging。预检不能替代写入、同步、切换或回滚阶段的 fail-closed I/O 错误处理。
|
在同句柄 size/SHA、ZIP 预扫描和严格 `app.json` 身份比对均通过后,提取器会在创建 `staging/` 前提供已规划 payload 的准确展开字节数、普通文件数和安全 entrypoint。安装 use case 必须以 `payload_bytes + 64 MiB` 查询 app root 所在卷的可用空间;可用空间不足时不创建 staging。预检不能替代写入、同步、切换或回滚阶段的 fail-closed I/O 错误处理。
|
||||||
|
|
||||||
安装 use case 同时在上述位置检查当前 `current/<entrypoint>` 是否正在运行。容量与运行状态均通过 core 接口注入;检查失败按不可安全继续处理。v1 不强杀、不启动、不等待进程退出。Windows Toolhelp 枚举、正常退出等待与启动协议属于后续 T-401,不能进入 core。
|
安装 use case 同时在上述位置检查当前 `current/<entrypoint>` 是否正在运行。容量、storage failure classifier 与运行状态均通过 core 接口注入,且均为必需依赖;classifier 只对已带 `ErrStagingOutput` 的原始 I/O 链识别磁盘满,core 不导入 Windows API。检查失败按不可安全继续处理。v1 不强杀、不启动、不等待进程退出。Windows Toolhelp 枚举、正常退出等待与启动协议属于后续 T-401,不能进入 core。
|
||||||
|
|
||||||
安装结果面向调用方的错误码为下表的稳定英文枚举;UI 负责本地化,原始错误只保留给 `errors.Is`、日志和诊断,不得进入 UI payload。
|
安装结果面向调用方的错误码为下表的稳定英文枚举;UI 负责本地化,原始错误只保留给 `errors.Is`、日志和诊断,不得进入 UI payload。
|
||||||
|
|
||||||
@@ -234,11 +237,11 @@ T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再
|
|||||||
| `zip_path_escape` | ZIP/app manifest 路径违反共享 Windows 安全相对路径规则 |
|
| `zip_path_escape` | ZIP/app manifest 路径违反共享 Windows 安全相对路径规则 |
|
||||||
| `zip_corrupt` | ZIP 结构、CRC 或受限读取/提取不完整,不能作为有效包 |
|
| `zip_corrupt` | ZIP 结构、CRC 或受限读取/提取不完整,不能作为有效包 |
|
||||||
| `package_invalid` | package/app manifest 身份或协议不符合可信 selection |
|
| `package_invalid` | package/app manifest 身份或协议不符合可信 selection |
|
||||||
| `disk_full` | 可用空间小于 `payload_bytes + 64 MiB` |
|
| `disk_full` | 可用空间小于 `payload_bytes + 64 MiB`,或平台 classifier 识别 staging write/sync/close 的底层 I/O 为磁盘满 |
|
||||||
| `disk_check_failed` | 无法可靠取得可用空间或得到非法容量值 |
|
| `disk_check_failed` | 无法可靠取得可用空间或得到非法容量值 |
|
||||||
| `app_running` | 当前目标程序仍在运行,更新不能替换 |
|
| `app_running` | 当前目标程序仍在运行,更新不能替换 |
|
||||||
| `target_state_unavailable` | 无法可靠取得目标运行状态 |
|
| `target_state_unavailable` | 无法可靠取得目标运行状态 |
|
||||||
| `install_failed` | 其他未细分的安装、健康、记录、切换或回滚失败 |
|
| `install_failed` | 其他未细分的安装、健康、记录、切换、回滚或非磁盘满 staging 输出 I/O 失败 |
|
||||||
|
|
||||||
上述任一预检或验证失败都发生在 switch 前;已有版本的 `current` 和 `installed-app.json` 必须保持可用,首次安装不得留下 executable `current`。
|
上述任一预检或验证失败都发生在 switch 前;已有版本的 `current` 和 `installed-app.json` 必须保持可用,首次安装不得留下 executable `current`。
|
||||||
|
|
||||||
|
|||||||
@@ -13,22 +13,22 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-19
|
- 日期:2026-07-19
|
||||||
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合与 T-303 失败处理/磁盘预检查已完成;审核整改 T-604~T-614 已完成;T-615 已正式落成,下一步领取并执行 staging 输出 I/O 根因保留与磁盘满诊断整改
|
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 已完成;下一步正式落成并执行 T-401 进程检测与软件启动
|
||||||
- 技术栈:根 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 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,失败返回稳定 code 且不触发 switch;每个实际 payload 文件 hash 写入 installed-app;health 或记录写失败经 Switcher 回滚),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 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 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 写入 installed-app;health 或记录写失败经 Switcher 回滚),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 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 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、payload hash 记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
|
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/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/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
|
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`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-615 必须先关闭 staging 输出 I/O 错误链、磁盘满分类和清理失败恢复语义,才可落成 T-401;T-614 已以静态 corpus 冻结客户端 Catalog canonicalization/签名行为,外部 `softbox-catalog` 消费 corpus 的 CI 证据仍需跨仓库协调,但不阻止 T-615。物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入
|
- 当前 blocker:T-615 已关闭 staging 输出 I/O 错误链、磁盘满分类和清理失败恢复语义;T-401 尚未正式落成,必须先定义其消费 T-303 的 `TargetStateChecker`、切换临界区最后复查与启动边界。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调,但不阻止 T-401。物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
||||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303 与 T-604~T-614 已完成;T-615 已正式落成,待领取执行;之后落成 T-401 进程检测与软件启动任务 |
|
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615 已完成;下一步落成 T-401 进程检测与软件启动任务 |
|
||||||
| `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-303`;审核整改 `T-604`~`T-614`。
|
- 已完成: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-303` 与 `T-615`;审核整改 `T-604`~`T-614`。
|
||||||
- 正在进行:无。
|
- 正在进行:无。
|
||||||
- 下一个可领取任务:T-615(依赖 T-303 已完成);T-401 需等待 T-615 完成后再正式落成。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
- 下一个动作:正式落成 T-401(其依赖 T-303、T-615 均已完成),然后按单 Agent 流程领取执行。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -3,12 +3,12 @@ id: T-615
|
|||||||
title: 保留安装输出 I/O 根因并统一磁盘满诊断
|
title: 保留安装输出 I/O 根因并统一磁盘满诊断
|
||||||
phase: 3
|
phase: 3
|
||||||
deps: [T-303]
|
deps: [T-303]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-19
|
created: 2026-07-19
|
||||||
issue: null
|
issue: null
|
||||||
context_ref: null
|
context_ref: d1603c52b6c2df645db11a808ced9a3cc8b3e764
|
||||||
claim_branch: null
|
claim_branch: null
|
||||||
work_branch: null
|
work_branch: agent/codex/T-615
|
||||||
write_paths:
|
write_paths:
|
||||||
- docs/tasks/T-615.md
|
- docs/tasks/T-615.md
|
||||||
- core/installer/
|
- core/installer/
|
||||||
@@ -57,3 +57,6 @@ T-303 的磁盘预检只能 fail-fast,不能消除“查询后被其他进程
|
|||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 2026-07-19:正式落成。根据 Phase 3 交叉复核冻结输出 I/O/ZIP 输入的错误分界、可注入磁盘满分类、清理失败恢复语义及 T-401/O1 与后续编排/O3 的边界。
|
- 2026-07-19:正式落成。根据 Phase 3 交叉复核冻结输出 I/O/ZIP 输入的错误分界、可注入磁盘满分类、清理失败恢复语义及 T-401/O1 与后续编排/O3 的边界。
|
||||||
|
- 2026-07-19:领取任务,基于 `d1603c52b6c2df645db11a808ced9a3cc8b3e764` 在 `agent/codex/T-615` 执行;先运行基线验证,再实现与复核。
|
||||||
|
- 2026-07-19:完成。`Extractor` 以内部 file-operation seam 区分 ZIP 输入与 staging 创建/write/sync/close 输出,新增可保留原始错误链的 `ErrStagingOutput`;清理失败以 `ErrStagingCleanup` 与主失败共同返回。无 journal 的残留 staging 仅在通过 app layout 校验后由 Recover 删除,不影响 current、installed-app、data 或 licenses。`InstallService` 强制注入 `StorageFailureClassifier`,只对 `ErrStagingOutput` 中的平台可识别磁盘满返回 `disk_full`,其他输出 I/O 返回 `install_failed`。
|
||||||
|
- 2026-07-19:验证通过:基线 `./init.ps1`;`go -C core vet ./...`;`go -C core test -count=1 ./...`;`go -C core test -count=10 ./installer ./application/install`;`./scripts/verify_phase0.ps1`;`python scripts/validate_agent_context.py`;`python scripts/validate_harness_governance.py`。新增写/sync/close ENOSPC、普通输出 I/O、CRC 分界、清理失败及后续恢复测试。
|
||||||
|
|||||||
Reference in New Issue
Block a user