mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
Add ordered content sources and targets to the shared copy pipeline. Observe fs.File metadata before target creation, bind fs.FS sources, and expose fatal-stop notification while preserving native copy behavior.
279 lines
9.5 KiB
Go
279 lines
9.5 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
type testContentFS func(string) (fs.File, error)
|
|
|
|
func (f testContentFS) Open(name string) (fs.File, error) { return f(name) }
|
|
|
|
func TestContentSourceFromFSObservesOpenedFileOnce(t *testing.T) {
|
|
for _, data := range []string{"", "observed bytes"} {
|
|
t.Run(data, func(t *testing.T) {
|
|
// Binding is lazy, and filesystem names remain distinct from display identifiers.
|
|
var lock sync.Mutex
|
|
var events []string
|
|
record := func(event string) []string {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
events = append(events, event)
|
|
return append([]string(nil), events...)
|
|
}
|
|
attrs := map[string][]byte{"user.binary": {0, 255}, "user.empty": {}, "acp.signature": {1}}
|
|
var reader io.Reader = strings.NewReader(data)
|
|
if data == "" {
|
|
reader = contentReadFunc(func([]byte) (int, error) {
|
|
t.Error("empty member attempted a payload read")
|
|
return 0, errors.New("empty member has no payload")
|
|
})
|
|
}
|
|
file := &testContentFile{
|
|
ReadCloser: &testContentReader{Reader: reader, close: func() error {
|
|
record("close")
|
|
return nil
|
|
}},
|
|
stat: func() (fs.FileInfo, error) {
|
|
record("stat")
|
|
return contentForeignInfo{contentInfo{name: "actual", size: int64(len(data)), mode: 0640}}, nil
|
|
},
|
|
xattrs: func() (map[string][]byte, error) {
|
|
record("xattrs")
|
|
return attrs, nil
|
|
},
|
|
}
|
|
source := SourceFromFS(testContentFS(func(name string) (fs.File, error) {
|
|
record("open")
|
|
if name != "docs/file" {
|
|
t.Errorf("filesystem name=%q", name)
|
|
}
|
|
return file, nil
|
|
}), "docs/file")
|
|
if len(events) != 0 {
|
|
t.Fatalf("binding opened filesystem: %v", events)
|
|
}
|
|
|
|
// The target receives observed portable facts and its own attribute byte copies.
|
|
var output bytes.Buffer
|
|
item := &testContentItem{SimpleJob: &SimpleJob{Path: "display/a/../file", Dsts: []string{"target"}},
|
|
source: source}
|
|
item.targets = []ContentTarget{testContentTarget(func(
|
|
_ context.Context, metadata ContentMetadata,
|
|
) (ContentWriter, error) {
|
|
observed := record("target")
|
|
if !reflect.DeepEqual(observed[:3], []string{"open", "stat", "xattrs"}) ||
|
|
metadata.Info.Name() != "actual" || metadata.Info.Sys() != nil ||
|
|
metadata.Info.Size() != int64(len(data)) || len(metadata.Xattrs) != 2 {
|
|
t.Errorf("events=%v, metadata=%#v", observed, metadata)
|
|
}
|
|
metadata.Xattrs["user.binary"][0] = 9
|
|
if attrs["user.binary"][0] != 0 {
|
|
t.Error("target mutated source attributes")
|
|
}
|
|
return &testContentWriter{Writer: &output, finish: func(cause error) error {
|
|
record("finish")
|
|
if cause != nil {
|
|
t.Errorf("Finish cause=%v", cause)
|
|
}
|
|
return nil
|
|
}}, nil
|
|
})}
|
|
callback, batches := collectResults()
|
|
if err := runStream(context.Background(), callback, []Item{item},
|
|
SetFromDevice(LinearDevice(true)), SetToDevice(LinearDevice(true)), WithHashPolicy(HashRead)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Empty members still observe, close and finish; both sizes hash their actual bytes.
|
|
digest := sha256.Sum256([]byte(data))
|
|
result := batches()[0][0]
|
|
if output.String() != data || result.Err != nil || result.Targets[0].Err != nil ||
|
|
!bytes.Equal(result.SHA256, digest[:]) || result.SignatureCacheHit || len(events) != 6 ||
|
|
events[5] != "finish" {
|
|
t.Fatalf("output=%q, events=%v, result=%#v", output.String(), events, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContentSourceFromFSUsesStandardFiles(t *testing.T) {
|
|
// A plain fs.File needs neither random access nor the optional xattr capability.
|
|
root := t.TempDir()
|
|
for _, name := range []string{"nested/source", "empty"} {
|
|
data := []byte("standard filesystem")
|
|
if name == "empty" {
|
|
data = nil
|
|
}
|
|
fsys := fstest.MapFS{name: &fstest.MapFile{Data: data, Mode: 0600}}
|
|
item := &testContentItem{SimpleJob: &SimpleJob{Path: "display " + name,
|
|
Dsts: []string{filepath.Join(root, filepath.Base(name))}}, source: SourceFromFS(fsys, name)}
|
|
callback, batches := collectResults()
|
|
if err := runStream(context.Background(), callback, []Item{item},
|
|
SetFromDevice(LinearDevice(true)), WithHashPolicy(HashRead)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := os.ReadFile(item.Dsts[0])
|
|
if result := batches()[0][0]; err != nil || !bytes.Equal(got, data) ||
|
|
result.Err != nil || result.Targets[0].Err != nil || result.Size != int64(len(data)) {
|
|
t.Fatalf("output=%q, err=%v, result=%#v", got, err, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContentSourceObservationFailuresCloseAcquiredFiles(t *testing.T) {
|
|
for _, stage := range []string{
|
|
"open", "stat", "xattrs", "nil info", "directory", "negative size", "oversized attrs",
|
|
"stat panic", "xattrs panic", "info panic", "stat cancel", "xattrs cancel", "fs open cancel",
|
|
} {
|
|
t.Run(stage, func(t *testing.T) {
|
|
// Every failed observation retains a handle for one Close and never starts a target.
|
|
captureLogs(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
failure, closeFailure := errors.New(stage), errors.New("source close failed")
|
|
closes, reads, targetOpens := 0, 0, 0
|
|
info := fs.FileInfo(contentInfo{name: "member", size: 1, mode: 0600})
|
|
if stage == "info panic" {
|
|
info = contentPanicInfo{contentInfo: contentInfo{name: "member", mode: 0600}, cause: failure}
|
|
}
|
|
file := &testContentFile{ReadCloser: &testContentReader{
|
|
Reader: contentReadFunc(func([]byte) (int, error) { reads++; return 0, io.EOF }),
|
|
close: func() error { closes++; return closeFailure },
|
|
}}
|
|
file.stat = func() (fs.FileInfo, error) {
|
|
switch stage {
|
|
case "stat":
|
|
return nil, failure
|
|
case "stat panic":
|
|
panic(failure)
|
|
case "stat cancel":
|
|
cancel()
|
|
return nil, failure
|
|
case "nil info":
|
|
return nil, nil
|
|
case "directory":
|
|
return contentInfo{mode: fs.ModeDir}, nil
|
|
case "negative size":
|
|
return contentInfo{size: -1}, nil
|
|
}
|
|
return info, nil
|
|
}
|
|
file.xattrs = func() (map[string][]byte, error) {
|
|
switch stage {
|
|
case "xattrs":
|
|
return nil, failure
|
|
case "xattrs panic":
|
|
panic(failure)
|
|
case "xattrs cancel":
|
|
cancel()
|
|
return nil, failure
|
|
case "oversized attrs":
|
|
return map[string][]byte{"attr": make([]byte, ContentMaxXattrValueBytes+1)}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
fsys := testContentFS(func(string) (fs.File, error) {
|
|
if stage == "open" || stage == "fs open cancel" {
|
|
if stage == "fs open cancel" {
|
|
cancel()
|
|
}
|
|
return file, failure
|
|
}
|
|
return file, nil
|
|
})
|
|
root := t.TempDir()
|
|
item := &testContentItem{SimpleJob: &SimpleJob{Path: "member", Dsts: []string{
|
|
filepath.Join(root, "native"), "custom",
|
|
}}, source: SourceFromFS(fsys, "member"), targets: []ContentTarget{nil,
|
|
testContentTarget(func(context.Context, ContentMetadata) (ContentWriter, error) {
|
|
targetOpens++
|
|
return nil, errors.New("unexpected target Open")
|
|
}),
|
|
}}
|
|
|
|
// Failed observation and failed cleanup remain identifiable on the terminal item.
|
|
callback, batches := collectResults()
|
|
stream, err := NewStream(ctx, callback, SetFromDevice(LinearDevice(true)),
|
|
SetToDevice(LinearDevice(true)), WithHashPolicy(HashRead))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = stream.Submit(item)
|
|
_ = stream.Close()
|
|
err = stream.Wait()
|
|
result := batches()[0][0]
|
|
if err == nil || result.Err == nil || !errors.Is(result.Err, closeFailure) ||
|
|
closes != 1 || reads != 0 || targetOpens != 0 {
|
|
t.Fatalf("run=%v, result=%#v, closes=%d, reads=%d, targets=%d",
|
|
err, result, closes, reads, targetOpens)
|
|
}
|
|
if strings.Contains(stage, "cancel") && !errors.Is(result.Err, context.Canceled) {
|
|
t.Fatalf("cancellation lost: %v", result.Err)
|
|
}
|
|
if stage != "nil info" && stage != "directory" && stage != "negative size" &&
|
|
stage != "oversized attrs" && !errors.Is(result.Err, failure) {
|
|
t.Fatalf("observation cause lost: %v", result.Err)
|
|
}
|
|
if entries, err := os.ReadDir(root); err != nil || len(entries) != 0 {
|
|
t.Fatalf("output created before observation: %v, %v", entries, err)
|
|
}
|
|
select {
|
|
case <-stream.HardStopped():
|
|
t.Fatal("caller-owned metadata observation became a fatal pipeline failure")
|
|
default:
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type contentReadFunc func([]byte) (int, error)
|
|
|
|
func (f contentReadFunc) Read(data []byte) (int, error) { return f(data) }
|
|
|
|
type contentPanicInfo struct {
|
|
contentInfo
|
|
cause error
|
|
}
|
|
|
|
func (i contentPanicInfo) Size() int64 { panic(i.cause) }
|
|
|
|
func TestContentSourceFromFSBindingErrorsDoNotOpen(t *testing.T) {
|
|
// Logical paths follow io/fs without cleaning or converting native path syntax.
|
|
for _, name := range []string{"", "/absolute", "a/../b", "a//b", "bad\xff"} {
|
|
source := SourceFromFS(testContentFS(func(string) (fs.File, error) {
|
|
t.Error("invalid binding opened filesystem")
|
|
return nil, nil
|
|
}), name)
|
|
file, err := source.Open(context.Background())
|
|
if file != nil || !errors.Is(err, fs.ErrInvalid) {
|
|
t.Fatalf("name=%q, file=%v, err=%v", name, file, err)
|
|
}
|
|
}
|
|
if _, err := SourceFromFS(nil, "valid").Open(context.Background()); !errors.Is(err, fs.ErrInvalid) {
|
|
t.Fatalf("nil filesystem error=%v", err)
|
|
}
|
|
|
|
// Already-cancelled contexts never call the standard filesystem.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
source := SourceFromFS(testContentFS(func(string) (fs.File, error) {
|
|
t.Error("cancelled binding opened filesystem")
|
|
return nil, nil
|
|
}), "valid")
|
|
if _, err := source.Open(ctx); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancelled Open=%v", err)
|
|
}
|
|
}
|