mirror of
https://github.com/samuelncui/acp.git
synced 2026-09-04 07:07:19 +00:00
500 lines
16 KiB
Go
500 lines
16 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCachedSignatureCodec(t *testing.T) {
|
|
// Round-trip every signed and fixed-width field through the stable codec.
|
|
want := CachedSignature{
|
|
Size: 42,
|
|
MtimeNS: -123456789,
|
|
SHA256: sha256.Sum256([]byte("fixture")),
|
|
}
|
|
encoded := encodeCachedSignature(want)
|
|
got, err := DecodeCachedSignature(encoded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("decoded signature = %#v, want %#v", got, want)
|
|
}
|
|
|
|
// Reject each independent header invariant without accepting partial data.
|
|
tests := []struct {
|
|
name string
|
|
mutate func([]byte) []byte
|
|
}{
|
|
{name: "size", mutate: func(value []byte) []byte { return value[:len(value)-1] }},
|
|
{name: "magic", mutate: func(value []byte) []byte { value[0] ^= 0xff; return value }},
|
|
{name: "version", mutate: func(value []byte) []byte { value[4]++; return value }},
|
|
{name: "algorithm", mutate: func(value []byte) []byte { value[5]++; return value }},
|
|
{name: "reserved", mutate: func(value []byte) []byte { value[6] = 1; return value }},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
invalid := tt.mutate(append([]byte(nil), encoded...))
|
|
if _, err := DecodeCachedSignature(invalid); err == nil {
|
|
t.Fatal("DecodeCachedSignature() error = nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSignatureCacheWritePreservesACPSnapshot(t *testing.T) {
|
|
// Build a signature from ACP's completed result rather than live file metadata.
|
|
content := []byte("snapshot fixture")
|
|
indexed := &stat{
|
|
size: int64(len(content)),
|
|
modTime: time.Unix(100, 123),
|
|
}
|
|
hash := sha256.Sum256(content)
|
|
want, err := newCachedSignature(hash[:], indexed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Enqueueing must preserve that immutable snapshot even when the path differs.
|
|
path := filepath.Join(t.TempDir(), "changed.bin")
|
|
if err := os.WriteFile(path, bytes.Repeat([]byte("x"), len(content)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := time.Unix(200, 456)
|
|
if err := os.Chtimes(path, changed, changed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache := &signatureCache{queue: make(chan signatureWrite, 1)}
|
|
cache.enqueue(path, want)
|
|
write := <-cache.queue
|
|
if write.signature != want {
|
|
t.Fatalf("queued signature = %#v, want %#v", write.signature, want)
|
|
}
|
|
|
|
// The writer must reject a path that no longer matches ACP's result.
|
|
cache.write(write)
|
|
if cache.summary.Writes != 0 || cache.summary.Failures != 1 {
|
|
t.Fatalf("signature summary = %#v", cache.summary)
|
|
}
|
|
}
|
|
|
|
func TestRunStreamSignatureCacheHitStaleAndForce(t *testing.T) {
|
|
// Create one stable source whose cache lifecycle can be observed across runs.
|
|
content := []byte("cache fixture")
|
|
input := filepath.Join(t.TempDir(), "source.txt")
|
|
if err := os.WriteFile(input, content, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantHash := sha256.Sum256(content)
|
|
|
|
// The first hash populates the cache before RunStream returns.
|
|
first, firstSummary := runSignatureHash(t, input, false)
|
|
if first.SignatureCacheHit {
|
|
t.Fatal("first hash unexpectedly hit the signature cache")
|
|
}
|
|
if first.SHA256 != hex.EncodeToString(wantHash[:]) {
|
|
t.Fatalf("first SHA256 = %q, want %q", first.SHA256, hex.EncodeToString(wantHash[:]))
|
|
}
|
|
signature, valid, err := ReadCachedSignature(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !valid {
|
|
t.Skip("temporary filesystem does not support signature xattrs")
|
|
}
|
|
if signature.SHA256 != wantHash || firstSummary.Writes != 1 {
|
|
t.Fatalf("populated signature = %#v, summary = %#v", signature, firstSummary)
|
|
}
|
|
|
|
// Unchanged metadata uses the cached SHA-256 without reading content.
|
|
second, secondSummary := runSignatureHash(t, input, false)
|
|
if !second.SignatureCacheHit || second.SHA256 != first.SHA256 {
|
|
t.Fatalf("cached job = %#v, want cache hit with SHA256 %q", second, first.SHA256)
|
|
}
|
|
if secondSummary.Hits != 1 || secondSummary.Writes != 0 {
|
|
t.Fatalf("cache-hit summary = %#v", secondSummary)
|
|
}
|
|
|
|
// A metadata change makes the old signature stale and refreshes it.
|
|
info, err := os.Stat(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := info.ModTime().Add(2 * time.Second)
|
|
if err := os.Chtimes(input, changed, changed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stale, staleSummary := runSignatureHash(t, input, false)
|
|
if stale.SignatureCacheHit || staleSummary.Stale != 1 || staleSummary.Writes != 1 {
|
|
t.Fatalf("stale job = %#v, summary = %#v", stale, staleSummary)
|
|
}
|
|
|
|
// Force rehash bypasses the now-valid cache and still refreshes it.
|
|
forced, forcedSummary := runSignatureHash(t, input, true)
|
|
if forced.SignatureCacheHit || forcedSummary.Hits != 0 || forcedSummary.Writes != 1 {
|
|
t.Fatalf("forced job = %#v, summary = %#v", forced, forcedSummary)
|
|
}
|
|
}
|
|
|
|
func TestRunStreamTransferAlwaysHashesAndRefreshesTargets(t *testing.T) {
|
|
// Prepare an existing target so the transfer exercises overwrite invalidation.
|
|
content := []byte("transfer fixture")
|
|
root := t.TempDir()
|
|
input := filepath.Join(root, "source.txt")
|
|
target := filepath.Join(root, "target.txt")
|
|
if err := os.WriteFile(input, content, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Seed a metadata-valid but incorrect cache entry to prove transfer ignores it.
|
|
info, err := os.Stat(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrong := CachedSignature{Size: info.Size(), MtimeNS: info.ModTime().UnixNano(), SHA256: sha256.Sum256([]byte("wrong"))}
|
|
file, err := os.Open(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = writeSignatureXattr(file, encodeCachedSignature(wrong))
|
|
_ = file.Close()
|
|
if err != nil {
|
|
t.Skipf("temporary filesystem does not support signature xattrs: %v", err)
|
|
}
|
|
targetInfo, err := os.Stat(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
targetFile, err := os.Open(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = writeSignatureXattr(targetFile, encodeCachedSignature(CachedSignature{
|
|
Size: targetInfo.Size(), MtimeNS: targetInfo.ModTime().UnixNano(), SHA256: sha256.Sum256([]byte("old")),
|
|
}))
|
|
_ = targetFile.Close()
|
|
if err != nil {
|
|
t.Skipf("temporary filesystem does not support target signature xattrs: %v", err)
|
|
}
|
|
|
|
// Copy real content while the source advertises a deliberately incorrect cache entry.
|
|
sink := new(collectingStreamSink)
|
|
var summary SignatureCacheSummary
|
|
handler := func(event Event) {
|
|
if update, ok := event.(*EventSignatureCacheSummary); ok {
|
|
summary = update.Summary
|
|
}
|
|
}
|
|
err = RunStream(
|
|
context.Background(),
|
|
&sliceStreamSource{requests: []*StreamRequest{{ID: 1, Source: input, Targets: []string{target}}}},
|
|
sink,
|
|
Overwrite(true),
|
|
WithSignatureCache(true),
|
|
WithEventHandler(handler),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantHash := sha256.Sum256(content)
|
|
job := sink.results[0].Job
|
|
if job.SignatureCacheHit || job.SHA256 != hex.EncodeToString(wantHash[:]) {
|
|
t.Fatalf("transfer job = %#v", job)
|
|
}
|
|
|
|
// Verify both bytes and refreshed source and target signature facts.
|
|
got, err := os.ReadFile(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, content) {
|
|
t.Fatalf("target content = %q, want %q", got, content)
|
|
}
|
|
for _, path := range []string{input, target} {
|
|
signature, valid, err := ReadCachedSignature(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !valid || signature.SHA256 != wantHash {
|
|
t.Fatalf("signature for %q = %#v, valid=%t", path, signature, valid)
|
|
}
|
|
}
|
|
if summary.Hits != 0 || summary.Writes != 2 {
|
|
t.Fatalf("transfer summary = %#v", summary)
|
|
}
|
|
}
|
|
|
|
func TestOverwriteInvalidatesSignatureWithoutCache(t *testing.T) {
|
|
// Give the old and new content identical metadata so only explicit invalidation is safe.
|
|
root := t.TempDir()
|
|
source := filepath.Join(root, "source.txt")
|
|
target := filepath.Join(root, "target.txt")
|
|
modified := time.Unix(100, 123)
|
|
if err := os.WriteFile(source, []byte("new"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range []string{source, target} {
|
|
if err := os.Chtimes(path, modified, modified); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Attach the old signature after the shared metadata has been fixed.
|
|
file, err := os.Open(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = writeSignatureXattr(file, encodeCachedSignature(CachedSignature{
|
|
Size: 3, MtimeNS: modified.UnixNano(), SHA256: sha256.Sum256([]byte("old")),
|
|
}))
|
|
_ = file.Close()
|
|
if err != nil {
|
|
t.Skipf("temporary filesystem does not support signature xattrs: %v", err)
|
|
}
|
|
|
|
// Overwrite without enabling cache writes and require the old xattr to disappear.
|
|
err = RunStream(
|
|
context.Background(),
|
|
&sliceStreamSource{requests: []*StreamRequest{{ID: 1, Source: source, Targets: []string{target}}}},
|
|
new(collectingStreamSink),
|
|
Overwrite(true),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if signature, valid, err := ReadCachedSignature(target); err != nil {
|
|
t.Fatal(err)
|
|
} else if valid {
|
|
t.Fatalf("overwritten target retained a valid signature: %#v", signature)
|
|
}
|
|
}
|
|
|
|
func TestRunStreamCorruptSignatureIsWarning(t *testing.T) {
|
|
// Seed an undecodable managed xattr on an otherwise valid source.
|
|
input := filepath.Join(t.TempDir(), "source.txt")
|
|
if err := os.WriteFile(input, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file, err := os.Open(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = writeSignatureXattr(file, []byte("corrupt"))
|
|
_ = file.Close()
|
|
if err != nil {
|
|
t.Skipf("temporary filesystem does not support signature xattrs: %v", err)
|
|
}
|
|
|
|
// Fall back to content hashing while surfacing only an aggregate warning.
|
|
job, summary := runSignatureHash(t, input, false)
|
|
if job.SignatureCacheHit || summary.Failures != 1 || summary.Misses != 1 || summary.Writes != 1 {
|
|
t.Fatalf("job = %#v, summary = %#v", job, summary)
|
|
}
|
|
if summary.FirstError == "" || len(summary.Samples) != 1 || summary.Samples[0] != input {
|
|
t.Fatalf("warning details = %#v", summary)
|
|
}
|
|
}
|
|
|
|
func TestRunStreamSignatureCacheZeroLength(t *testing.T) {
|
|
// Populate the valid SHA-256 signature of an empty file.
|
|
input := filepath.Join(t.TempDir(), "empty")
|
|
if err := os.WriteFile(input, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Hash the empty content and require the asynchronous cache write to drain.
|
|
first, _ := runSignatureHash(t, input, false)
|
|
if first.SHA256 != hex.EncodeToString(sha256.New().Sum(nil)) {
|
|
t.Fatalf("empty SHA256 = %q", first.SHA256)
|
|
}
|
|
_, valid, err := ReadCachedSignature(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !valid {
|
|
t.Skip("temporary filesystem does not support signature xattrs")
|
|
}
|
|
|
|
// Reuse the zero-length cache entry without reopening content.
|
|
second, summary := runSignatureHash(t, input, false)
|
|
if !second.SignatureCacheHit || summary.Hits != 1 {
|
|
t.Fatalf("empty cache job = %#v, summary = %#v", second, summary)
|
|
}
|
|
}
|
|
|
|
func TestSignatureCacheWarningSamplesAreBounded(t *testing.T) {
|
|
// Inject more failures than the diagnostic sample limit.
|
|
cache := newSignatureCache(1)
|
|
for idx := 0; idx < signatureSampleLimit+3; idx++ {
|
|
cache.recordFailure(fmt.Sprintf("path-%d", idx), errors.New("injected failure"))
|
|
}
|
|
summary := cache.closeAndWait()
|
|
|
|
// Preserve the aggregate count while bounding retained paths.
|
|
if summary.Failures != signatureSampleLimit+3 {
|
|
t.Fatalf("failures = %d, want %d", summary.Failures, signatureSampleLimit+3)
|
|
}
|
|
if len(summary.Samples) != signatureSampleLimit {
|
|
t.Fatalf("samples = %d, want %d", len(summary.Samples), signatureSampleLimit)
|
|
}
|
|
}
|
|
|
|
func TestRunStreamSignatureCacheDrainsConcurrentWrites(t *testing.T) {
|
|
// Build more sources than the writer pool can process at once.
|
|
root := t.TempDir()
|
|
source := new(sliceStreamSource)
|
|
want := make(map[string][32]byte)
|
|
for idx := 0; idx < 32; idx++ {
|
|
path := filepath.Join(root, fmt.Sprintf("%02d.bin", idx))
|
|
content := []byte(fmt.Sprintf("concurrent signature %d", idx))
|
|
if err := os.WriteFile(path, content, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source.requests = append(source.requests, &StreamRequest{ID: int64(idx + 1), Source: path})
|
|
want[path] = sha256.Sum256(content)
|
|
}
|
|
|
|
// Run concurrent hashing and require every asynchronous xattr write to drain.
|
|
sink := new(collectingStreamSink)
|
|
if err := RunStream(
|
|
context.Background(), source, sink,
|
|
WithSignatureCache(true),
|
|
SetFromDevice(DeviceThreads(4)),
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sink.results) != len(want) {
|
|
t.Fatalf("received %d results, want %d", len(sink.results), len(want))
|
|
}
|
|
|
|
// Confirm all cache writes are visible after RunStream returns.
|
|
for path, expected := range want {
|
|
signature, valid, err := ReadCachedSignature(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !valid {
|
|
t.Skip("temporary filesystem does not support signature xattrs")
|
|
}
|
|
if signature.SHA256 != expected {
|
|
t.Fatalf("signature for %q = %x, want %x", path, signature.SHA256, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
type cancelingSignatureSink struct {
|
|
cancel context.CancelFunc
|
|
path string
|
|
}
|
|
|
|
func (s *cancelingSignatureSink) Write(_ context.Context, result *StreamResult) error {
|
|
s.path = result.Job.FullPath
|
|
s.cancel()
|
|
return nil
|
|
}
|
|
|
|
func (*cancelingSignatureSink) Flush(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func TestRunStreamSignatureCacheDrainsAfterCancellation(t *testing.T) {
|
|
// Cancel only after the first completed result has entered the Sink.
|
|
path := filepath.Join(t.TempDir(), "cancellation.bin")
|
|
if err := os.WriteFile(path, []byte("cancellation fixture"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Run a blocking source so the Sink controls the cancellation boundary.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
sink := &cancelingSignatureSink{cancel: cancel}
|
|
err := RunStream(ctx, &blockingStreamSource{
|
|
request: &StreamRequest{ID: 1, Source: path},
|
|
}, sink, WithSignatureCache(true))
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("RunStream() error = %v, want context cancellation", err)
|
|
}
|
|
|
|
// The completed result's cache write must drain despite caller cancellation.
|
|
if sink.path == "" {
|
|
t.Fatal("cancellation sink received no completed result")
|
|
}
|
|
_, valid, err := ReadCachedSignature(sink.path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !valid {
|
|
t.Skip("temporary filesystem does not support signature xattrs")
|
|
}
|
|
}
|
|
|
|
func TestRunStreamSignatureCacheIsBestEffortForReadOnlyFile(t *testing.T) {
|
|
// Hash content successfully even if the filesystem rejects the cache write.
|
|
path := filepath.Join(t.TempDir(), "readonly.bin")
|
|
content := []byte("read-only signature fixture")
|
|
if err := os.WriteFile(path, content, 0o444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
job, summary := runSignatureHash(t, path, true)
|
|
want := sha256.Sum256(content)
|
|
if job.SHA256 != hex.EncodeToString(want[:]) {
|
|
t.Fatalf("SHA256 = %q, want %q", job.SHA256, hex.EncodeToString(want[:]))
|
|
}
|
|
|
|
// Accept either a valid owner-writable xattr or a non-fatal warning.
|
|
signature, valid, err := ReadCachedSignature(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if valid && signature.SHA256 != want {
|
|
t.Fatalf("signature = %x, want %x", signature.SHA256, want)
|
|
}
|
|
if !valid && summary.Failures == 0 {
|
|
t.Fatalf("read-only cache miss had no warning: %#v", summary)
|
|
}
|
|
}
|
|
|
|
func runSignatureHash(t *testing.T, input string, force bool) (*Job, SignatureCacheSummary) {
|
|
t.Helper()
|
|
|
|
// Capture the aggregate event while collecting the single stream result.
|
|
sink := new(collectingStreamSink)
|
|
var summary SignatureCacheSummary
|
|
handler := func(event Event) {
|
|
if update, ok := event.(*EventSignatureCacheSummary); ok {
|
|
summary = update.Summary
|
|
}
|
|
}
|
|
|
|
// Run one targetless hash with the requested cache-read policy.
|
|
if err := RunStream(
|
|
context.Background(),
|
|
&sliceStreamSource{requests: []*StreamRequest{{ID: 1, Source: input}}},
|
|
sink,
|
|
WithSignatureCache(true),
|
|
ForceRehash(force),
|
|
WithEventHandler(handler),
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sink.results) != 1 {
|
|
t.Fatalf("received %d results, want 1", len(sink.results))
|
|
}
|
|
|
|
// Return only after RunStream has drained cache writes and emitted its summary.
|
|
return sink.results[0].Job, summary
|
|
}
|