This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+414
View File
@@ -0,0 +1,414 @@
package cas
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
)
// LinkMode is how an assembled deployment tree gets at a blob's content.
type LinkMode string
const (
// LinkHard hardlinks. A deployment tree then costs directory entries and
// nothing else, however many deployments share the same files.
LinkHard LinkMode = "hardlink"
// LinkCopy copies. Correct everywhere, at the cost of disk proportional to
// deployed content rather than to unique content.
LinkCopy LinkMode = "copy"
)
// Failures a caller distinguishes. Everything else is an I/O error and is
// returned as it came back from the operating system.
var (
ErrDigestMismatch = errors.New("cas: content does not hash to the declared digest")
ErrSizeMismatch = errors.New("cas: content length does not match the declared size")
ErrTooLarge = errors.New("cas: content exceeds the maximum file size")
ErrNotFound = errors.New("cas: no such blob")
)
const (
// tmpDir holds uploads in progress. Anything in it is unreferenced by
// definition: a blob only becomes reachable by being renamed out of here.
tmpDir = "tmp"
// blobMode is read-only for everyone, and that is load-bearing rather than
// tidy. An assembled deployment file is usually a hardlink to the blob —
// the same inode — so anything that writes through the copy in $WEBROOT
// corrupts the blob itself, and with it every project that shares that
// content. Read-only is the cheap barrier; docs/operations.md carries the
// warning that $WEBROOT is read-only to outside consumers.
blobMode = 0o444
dirMode = 0o755
tmpMode = 0o600
)
// Options configure Open.
type Options struct {
// Mode forces a link mode. The zero value probes and falls back to copying.
Mode LinkMode
// ProbeDir is where the probe tries to place a link. It must be the
// directory deployment trees are assembled in: hardlinks cannot cross
// filesystems, so probing anywhere else answers a different question.
ProbeDir string
Log *slog.Logger
}
// Store is the on-disk blob store.
type Store struct {
dir string
root *os.Root
linkMode LinkMode
log *slog.Logger
}
// Open prepares the store at dir, creating it if necessary.
func Open(dir string, opt Options) (*Store, error) {
if dir == "" {
return nil, errors.New("cas: directory is required")
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(abs, tmpDir), dirMode); err != nil {
return nil, err
}
log := opt.Log
if log == nil {
log = slog.New(slog.DiscardHandler)
}
// Every name handed to root below is derived from a digest, so traversal is
// not the threat being defended against here. What os.Root buys is that a
// symlink planted inside the store — by a restore from a bad backup, by a
// misdirected rsync — cannot make a write land outside it.
root, err := os.OpenRoot(abs)
if err != nil {
return nil, err
}
s := &Store{dir: abs, root: root, log: log}
switch opt.Mode {
case LinkCopy:
s.linkMode = LinkCopy
case LinkHard:
if err := probeLink(abs, opt.ProbeDir); err != nil {
root.Close()
return nil, fmt.Errorf("cas: hardlinks were requested but %s cannot be hardlinked into %s: %w", abs, opt.ProbeDir, err)
}
s.linkMode = LinkHard
case "":
if err := probeLink(abs, opt.ProbeDir); err != nil {
s.linkMode = LinkCopy
log.Warn("hardlinks unavailable, deployment trees will be copies; disk use will be proportional to deployed content rather than to unique content",
"cas_dir", abs, "deployments_dir", opt.ProbeDir, "err", err)
} else {
s.linkMode = LinkHard
}
default:
root.Close()
return nil, fmt.Errorf("cas: unknown link mode %q", opt.Mode)
}
return s, nil
}
// Dir is the store's absolute root directory.
func (s *Store) Dir() string { return s.dir }
// LinkMode reports how LinkInto will place content.
func (s *Store) LinkMode() LinkMode { return s.linkMode }
// Close releases the store's directory handle.
func (s *Store) Close() error { return s.root.Close() }
// Path is where a blob lives on disk. Only for hardlinking, which needs a name
// the kernel resolves from the process's own root; every other operation goes
// through the store's directory handle.
func (s *Store) Path(d Digest) string {
return filepath.Join(s.dir, filepath.FromSlash(d.Rel()))
}
// Put stores r's bytes under want, if and only if they really hash to want.
//
// The verification is the reason cross-project deduplication is safe at all.
// Blobs are shared: if the server took the caller's word for the digest, a
// client could declare the digest of another project's index.html, upload
// whatever it liked, and every project referencing that content would start
// serving the attacker's bytes. So the hash is recomputed over the stream as it
// arrives and the upload is discarded unless it matches — the claimed digest is
// only ever a claim.
//
// declaredSize may be -1 when the caller genuinely does not know the length; any
// other value must match what arrives. maxBytes is a hard ceiling and is
// enforced by reading one byte past it rather than by trusting Content-Length.
//
// Returns the number of bytes stored. A blob that is already present is left
// alone and the upload discarded, which is safe precisely because both are known
// to hash to want.
func (s *Store) Put(ctx context.Context, want Digest, declaredSize, maxBytes int64, r io.Reader) (int64, error) {
if maxBytes <= 0 {
return 0, fmt.Errorf("cas: maxBytes must be positive, got %d", maxBytes)
}
if declaredSize > maxBytes {
return 0, fmt.Errorf("%w: declared %d bytes, limit is %d", ErrTooLarge, declaredSize, maxBytes)
}
tmpRel, f, err := s.newTemp()
if err != nil {
return 0, err
}
// Every path out of this function that is not a successful rename has to
// remove the temporary file, including the ones that are not our error — a
// client that hung up mid-upload, a cancelled context. One defer covers all
// of them.
committed := false
defer func() {
f.Close() // no-op if already closed below
if !committed {
if err := s.root.Remove(tmpRel); err != nil && !errors.Is(err, fs.ErrNotExist) {
s.log.Warn("removing abandoned upload", "path", tmpRel, "err", err)
}
}
}()
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(&ctxReader{ctx: ctx, r: r}, maxBytes+1))
if err != nil {
return 0, err
}
if n > maxBytes {
return 0, fmt.Errorf("%w: limit is %d bytes", ErrTooLarge, maxBytes)
}
if declaredSize >= 0 && n != declaredSize {
return 0, fmt.Errorf("%w: declared %d bytes, received %d", ErrSizeMismatch, declaredSize, n)
}
if got := Digest(h.Sum(nil)); got != want {
return 0, fmt.Errorf("%w: declared %s, computed %s", ErrDigestMismatch, want, got)
}
// Durability before visibility. Losing the content while keeping the
// directory entry would be corruption — the database would say present=1
// and the file would be a hole — whereas losing the rename is merely a blob
// that has to be uploaded again, which deploy.Recover already handles by
// setting present=0 for digests whose file is missing. So the file is
// fsynced and the containing directory deliberately is not.
if err := f.Sync(); err != nil {
return 0, err
}
if err := f.Chmod(blobMode); err != nil {
return 0, err
}
if err := f.Close(); err != nil {
return 0, err
}
rel := want.Rel()
if err := s.root.MkdirAll(path.Dir(rel), dirMode); err != nil {
return 0, err
}
if _, err := s.root.Stat(rel); err == nil {
// Two clients uploading identical content at once is a benign race, not
// a conflict: keep what is there and drop ours. Losing this race to a
// writer that renames between the Stat and here is equally benign,
// since rename is atomic and both files hold the same bytes.
return n, nil
} else if !errors.Is(err, fs.ErrNotExist) {
return 0, err
}
if err := s.root.Rename(tmpRel, rel); err != nil {
return 0, err
}
committed = true
return n, nil
}
// Has reports whether the blob's bytes are on disk.
func (s *Store) Has(d Digest) (bool, error) {
if _, err := s.root.Stat(d.Rel()); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
// Open returns the blob for reading; the caller closes it.
//
// This is the read path of every request the server serves, and it is
// deliberately the only filesystem call on it. The name comes from Rel(), which
// is derived from 32 bytes that came out of an in-memory map, so no byte of
// user input reaches the filesystem here. Traversal on the read path is not so
// much prevented as inexpressible.
func (s *Store) Open(d Digest) (*os.File, error) {
f, err := s.root.Open(d.Rel())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, d)
}
return nil, err
}
return f, nil
}
// LinkInto places the blob's content at destDir/relPath.
//
// destDir is a staging directory the caller has just created and relPath has
// passed pathutil.Validate, so the join cannot leave destDir and there is no
// pre-existing symlink for it to follow.
func (s *Store) LinkInto(d Digest, destDir, relPath string) error {
dest := filepath.Join(destDir, filepath.FromSlash(relPath))
if s.linkMode == LinkHard {
err := os.Link(s.Path(d), dest)
switch {
case err == nil:
return nil
case errors.Is(err, fs.ErrNotExist):
// The blob is gone, or a parent directory was never created. Either
// is a bug here, not a filesystem limitation, and copying would only
// fail again with a less informative error.
return fmt.Errorf("%w: %s: %w", ErrNotFound, d, err)
case errors.Is(err, fs.ErrExist):
// pathutil.Set rejects duplicate paths within a manifest, so two
// files claiming one name means the manifest was not checked.
return fmt.Errorf("cas: %s already exists in the deployment tree: %w", relPath, err)
}
// Anything else — most often EMLINK, since ext4 caps a file at 65,000
// links and a blob shared by enough deployments does reach that — is a
// per-file limitation rather than a reason to fail the deployment.
s.log.Debug("hardlink failed, copying this file instead", "digest", d, "path", relPath, "err", err)
}
return s.copyInto(d, dest)
}
func (s *Store) copyInto(d Digest, dest string) error {
src, err := s.Open(d)
if err != nil {
return err
}
defer src.Close()
// O_EXCL because a collision means two manifest entries claimed one path,
// which pathutil.Set is supposed to have made impossible.
dst, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, blobMode)
if err != nil {
return err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return err
}
// A copy is the only content in the deployment tree that is not already
// durable — a hardlink shares the inode that Put fsynced.
if err := dst.Sync(); err != nil {
return err
}
return dst.Close()
}
// Remove deletes a blob. Missing is success, so GC can be re-run after a crash.
func (s *Store) Remove(d Digest) error {
if err := s.root.Remove(d.Rel()); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
// PurgeTemp deletes every upload in progress and reports how many. Startup
// calls it: an interrupted upload is unreferenced garbage by construction.
func (s *Store) PurgeTemp() (int, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, tmpDir))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return 0, nil
}
return 0, err
}
n := 0
for _, e := range entries {
if err := s.root.RemoveAll(tmpDir + "/" + e.Name()); err != nil {
return n, err
}
n++
}
return n, nil
}
// newTemp creates a uniquely named file under tmp/ and returns its store-relative
// name. 128 bits of randomness make a collision impossible; the retry loop costs
// nothing and means a hypothetical one is not a failed upload.
func (s *Store) newTemp() (string, *os.File, error) {
var buf [16]byte
for attempt := 0; attempt < 3; attempt++ {
if _, err := rand.Read(buf[:]); err != nil {
return "", nil, err
}
name := tmpDir + "/" + hex.EncodeToString(buf[:])
f, err := s.root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, tmpMode)
if err == nil {
return name, f, nil
}
if !errors.Is(err, fs.ErrExist) {
return "", nil, err
}
}
return "", nil, errors.New("cas: could not create a temporary file")
}
// probeLink answers whether a hardlink from the blob store into the deployment
// tree actually works here.
//
// It has to be a real attempt rather than a check of the filesystem type.
// overlayfs — Docker's default, and this machine's /home — can fail or silently
// copy up across layers; a bind mount or a separate volume for the deployment
// tree puts the two directories on different devices and link(2) returns EXDEV;
// some hardened mounts refuse link(2) outright. The only reliable answer is to
// try it against the directory that will actually be used.
func probeLink(casDir, probeDir string) error {
if probeDir == "" {
return errors.New("no deployments directory to probe against")
}
if err := os.MkdirAll(probeDir, dirMode); err != nil {
return err
}
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return err
}
suffix := hex.EncodeToString(buf[:])
src := filepath.Join(casDir, tmpDir, ".link-probe-"+suffix)
dst := filepath.Join(probeDir, ".link-probe-"+suffix)
if err := os.WriteFile(src, []byte("probe"), tmpMode); err != nil {
return err
}
defer os.Remove(src)
if err := os.Link(src, dst); err != nil {
return err
}
return os.Remove(dst)
}
// ctxReader makes a long upload cancellable. The HTTP server closes the body
// when a client disappears, but Put also runs against local readers on recovery
// paths where nothing else would notice a shutdown.
type ctxReader struct {
ctx context.Context
r io.Reader
}
func (c *ctxReader) Read(p []byte) (int, error) {
if err := c.ctx.Err(); err != nil {
return 0, err
}
return c.r.Read(p)
}
+498
View File
@@ -0,0 +1,498 @@
package cas
import (
"bytes"
"context"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func newStore(t *testing.T, mode LinkMode) (*Store, string) {
t.Helper()
base := t.TempDir()
deployDir := filepath.Join(base, "deployments")
s, err := Open(filepath.Join(base, "cas"), Options{
Mode: mode,
ProbeDir: deployDir,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
if mode == LinkHard {
// Not every filesystem supports link(2), which is the whole reason
// Open probes. Skipping is honest here; TestOpenAutoFallsBackToCopy
// covers what happens when it is unavailable.
t.Skipf("hardlinks unavailable under %s: %v", base, err)
}
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s, deployDir
}
// tempFiles is how many uploads are sitting in tmp/. Every test that exercises a
// failure path checks this: a leaked temporary file is a slow disk leak that no
// other test would notice.
func tempFiles(t *testing.T, s *Store) int {
t.Helper()
entries, err := os.ReadDir(filepath.Join(s.Dir(), tmpDir))
if err != nil {
t.Fatal(err)
}
return len(entries)
}
func put(t *testing.T, s *Store, content string) Digest {
t.Helper()
d := Sum([]byte(content))
n, err := s.Put(context.Background(), d, int64(len(content)), 1<<20, strings.NewReader(content))
if err != nil {
t.Fatalf("Put(%q): %v", content, err)
}
if n != int64(len(content)) {
t.Fatalf("Put returned %d bytes, want %d", n, len(content))
}
return d
}
func TestParseDigest(t *testing.T) {
d := Sum([]byte("hello"))
hexForm := d.String()
got, err := ParseDigest(hexForm)
if err != nil {
t.Fatalf("ParseDigest: %v", err)
}
if got != d {
t.Errorf("round trip changed the digest")
}
bad := []string{
"",
hexForm[:HexLen-1],
hexForm + "0",
strings.ToUpper(hexForm), // uppercase is a second spelling; see ParseDigest
strings.Repeat("g", HexLen),
hexForm[:HexLen-2] + "!!",
"../../../etc/passwd",
}
for _, s := range bad {
if _, err := ParseDigest(s); !errors.Is(err, ErrBadDigest) {
t.Errorf("ParseDigest(%q) = %v, want ErrBadDigest", s, err)
}
}
}
func TestDigestRelIsSharded(t *testing.T) {
d := Sum([]byte("hello"))
rel := d.Rel()
hexForm := d.String()
want := hexForm[0:2] + "/" + hexForm[2:4] + "/" + hexForm
if rel != want {
t.Errorf("Rel = %q, want %q", rel, want)
}
if !fs.ValidPath(rel) {
t.Errorf("Rel = %q is not a valid path", rel)
}
}
func TestFromBytes(t *testing.T) {
d := Sum([]byte("hello"))
// database/sql reuses its scan buffers, so a digest must not alias one.
buf := append([]byte(nil), d.Bytes()...)
got, err := FromBytes(buf)
if err != nil {
t.Fatal(err)
}
for i := range buf {
buf[i] = 0
}
if got != d {
t.Error("FromBytes aliased its argument instead of copying")
}
if _, err := FromBytes(buf[:8]); err == nil {
t.Error("a short digest must be rejected")
}
}
func TestPutAndOpen(t *testing.T) {
s, _ := newStore(t, LinkCopy)
const content = "<h1>hello</h1>"
d := put(t, s, content)
ok, err := s.Has(d)
if err != nil || !ok {
t.Fatalf("Has = %v, %v", ok, err)
}
f, err := s.Open(d)
if err != nil {
t.Fatal(err)
}
defer f.Close()
got, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("read back %q, want %q", got, content)
}
if tempFiles(t, s) != 0 {
t.Error("a successful Put left a temporary file behind")
}
}
func TestPutIsIdempotent(t *testing.T) {
s, _ := newStore(t, LinkCopy)
const content = "same bytes"
d := put(t, s, content)
before, err := os.Stat(s.Path(d))
if err != nil {
t.Fatal(err)
}
put(t, s, content)
after, err := os.Stat(s.Path(d))
if err != nil {
t.Fatal(err)
}
if !os.SameFile(before, after) {
t.Error("re-uploading identical content replaced the blob instead of keeping it")
}
if tempFiles(t, s) != 0 {
t.Error("a redundant Put left a temporary file behind")
}
}
func TestOpenMissingBlob(t *testing.T) {
s, _ := newStore(t, LinkCopy)
d := Sum([]byte("never uploaded"))
if ok, err := s.Has(d); err != nil || ok {
t.Fatalf("Has = %v, %v", ok, err)
}
if _, err := s.Open(d); !errors.Is(err, ErrNotFound) {
t.Errorf("Open = %v, want ErrNotFound", err)
}
}
// The heart of the store: a caller's digest is a claim, never a fact.
func TestPutRejectsAClaimedDigest(t *testing.T) {
s, _ := newStore(t, LinkCopy)
ctx := context.Background()
// Stand in for another project's file, already deduplicated into the store.
victim := "the real index.html"
victimDigest := put(t, s, victim)
attack := "<script>document.location='//evil'</script>"
_, err := s.Put(ctx, victimDigest, int64(len(attack)), 1<<20, strings.NewReader(attack))
if !errors.Is(err, ErrDigestMismatch) {
t.Fatalf("Put = %v, want ErrDigestMismatch", err)
}
f, err := s.Open(victimDigest)
if err != nil {
t.Fatal(err)
}
defer f.Close()
got, _ := io.ReadAll(f)
if string(got) != victim {
t.Fatalf("the existing blob was overwritten: %q", got)
}
if tempFiles(t, s) != 0 {
t.Error("the rejected upload left a temporary file behind")
}
}
type errReader struct{ err error }
func (e errReader) Read([]byte) (int, error) { return 0, e.err }
// Every way Put can fail has to clean up after itself, or a store slowly fills
// with the debris of clients that hung up.
func TestPutCleansUpOnEveryFailure(t *testing.T) {
s, _ := newStore(t, LinkCopy)
const content = "some content"
d := Sum([]byte(content))
cancelled, cancel := context.WithCancel(context.Background())
cancel()
cases := []struct {
name string
ctx context.Context
want Digest
size int64
max int64
body io.Reader
wantErr error
}{
{"digest mismatch", context.Background(), Sum([]byte("other")), int64(len(content)), 1 << 20, strings.NewReader(content), ErrDigestMismatch},
{"size mismatch", context.Background(), d, int64(len(content)) + 1, 1 << 20, strings.NewReader(content), ErrSizeMismatch},
{"over the limit", context.Background(), d, -1, 4, strings.NewReader(content), ErrTooLarge},
{"declared over the limit", context.Background(), d, 1 << 30, 4, strings.NewReader(content), ErrTooLarge},
{"reader failed", context.Background(), d, -1, 1 << 20, errReader{errors.New("connection reset")}, nil},
{"context cancelled", cancelled, d, -1, 1 << 20, strings.NewReader(content), context.Canceled},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := s.Put(tc.ctx, tc.want, tc.size, tc.max, tc.body)
if err == nil {
t.Fatal("Put should have failed")
}
if tc.wantErr != nil && !errors.Is(err, tc.wantErr) {
t.Errorf("err = %v, want %v", err, tc.wantErr)
}
if n := tempFiles(t, s); n != 0 {
t.Errorf("%d temporary file(s) left behind", n)
}
if ok, _ := s.Has(tc.want); ok {
t.Error("a failed Put made a blob visible")
}
})
}
}
// A client that lies in Content-Length must not get past the ceiling; the limit
// is enforced against the bytes that arrive, not against the declaration.
func TestPutEnforcesTheLimitAgainstActualBytes(t *testing.T) {
s, _ := newStore(t, LinkCopy)
body := strings.Repeat("x", 100)
d := Sum([]byte(body))
// Declares that it fits, then sends more.
_, err := s.Put(context.Background(), d, 4, 8, strings.NewReader(body))
if err == nil {
t.Fatal("Put should have failed")
}
if tempFiles(t, s) != 0 {
t.Error("temporary file left behind")
}
}
func TestPutExactlyAtTheLimit(t *testing.T) {
s, _ := newStore(t, LinkCopy)
body := strings.Repeat("x", 64)
d := Sum([]byte(body))
if _, err := s.Put(context.Background(), d, 64, 64, strings.NewReader(body)); err != nil {
t.Errorf("a file exactly at the limit must be accepted: %v", err)
}
}
func TestPutEmptyBlob(t *testing.T) {
s, _ := newStore(t, LinkCopy)
d := put(t, s, "")
f, err := s.Open(d)
if err != nil {
t.Fatal(err)
}
defer f.Close()
b, _ := io.ReadAll(f)
if len(b) != 0 {
t.Errorf("read %d bytes from the empty blob", len(b))
}
}
// Concurrent uploads of identical content are expected — two CI jobs deploying
// the same vendored asset — and all of them must succeed with one file left.
func TestConcurrentIdenticalPut(t *testing.T) {
s, _ := newStore(t, LinkCopy)
const content = "shared asset"
d := Sum([]byte(content))
const n = 16
errs := make([]error, n)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, errs[i] = s.Put(context.Background(), d, int64(len(content)), 1<<20, strings.NewReader(content))
}()
}
close(start)
wg.Wait()
for i, err := range errs {
if err != nil {
t.Errorf("goroutine %d: %v", i, err)
}
}
if tempFiles(t, s) != 0 {
t.Error("temporary files left behind")
}
f, err := s.Open(d)
if err != nil {
t.Fatal(err)
}
defer f.Close()
got, _ := io.ReadAll(f)
if string(got) != content {
t.Errorf("blob = %q, want %q", got, content)
}
}
func TestPurgeTemp(t *testing.T) {
s, _ := newStore(t, LinkCopy)
d := put(t, s, "keep me")
for _, name := range []string{"aaaa", "bbbb"} {
if err := os.WriteFile(filepath.Join(s.Dir(), tmpDir, name), []byte("interrupted"), 0o600); err != nil {
t.Fatal(err)
}
}
n, err := s.PurgeTemp()
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("purged %d, want 2", n)
}
if tempFiles(t, s) != 0 {
t.Error("tmp is not empty")
}
if ok, _ := s.Has(d); !ok {
t.Error("PurgeTemp removed a committed blob")
}
if n, err := s.PurgeTemp(); err != nil || n != 0 {
t.Errorf("second PurgeTemp = %d, %v", n, err)
}
}
func TestRemoveIsIdempotent(t *testing.T) {
s, _ := newStore(t, LinkCopy)
d := put(t, s, "temporary")
if err := s.Remove(d); err != nil {
t.Fatal(err)
}
if ok, _ := s.Has(d); ok {
t.Error("blob still present after Remove")
}
// GC re-running after a crash must not fail on what it already deleted.
if err := s.Remove(d); err != nil {
t.Errorf("second Remove: %v", err)
}
}
func TestLinkIntoCopyMode(t *testing.T) {
s, deployDir := newStore(t, LinkCopy)
const content = "console.log(1)\n"
d := put(t, s, content)
dest := filepath.Join(deployDir, "assets")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
if err := s.LinkInto(d, dest, "app.js"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(dest, "app.js"))
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("content = %q, want %q", got, content)
}
}
func TestLinkIntoRejectsAMissingBlob(t *testing.T) {
for _, mode := range []LinkMode{LinkCopy, LinkHard} {
t.Run(string(mode), func(t *testing.T) {
s, deployDir := newStore(t, mode)
if err := os.MkdirAll(deployDir, 0o755); err != nil {
t.Fatal(err)
}
d := Sum([]byte("never uploaded"))
if err := s.LinkInto(d, deployDir, "x.html"); !errors.Is(err, ErrNotFound) {
t.Errorf("LinkInto = %v, want ErrNotFound", err)
}
})
}
}
func TestLinkIntoRejectsADuplicatePath(t *testing.T) {
for _, mode := range []LinkMode{LinkCopy, LinkHard} {
t.Run(string(mode), func(t *testing.T) {
s, deployDir := newStore(t, mode)
if err := os.MkdirAll(deployDir, 0o755); err != nil {
t.Fatal(err)
}
d := put(t, s, "x")
if err := s.LinkInto(d, deployDir, "x.html"); err != nil {
t.Fatal(err)
}
if err := s.LinkInto(d, deployDir, "x.html"); err == nil {
t.Error("writing twice to one path must fail rather than overwrite")
}
})
}
}
func TestOpenRejectsAnUnknownMode(t *testing.T) {
if _, err := Open(t.TempDir(), Options{Mode: "symlink"}); err == nil {
t.Error("an unknown link mode must be refused at startup, not at deploy time")
}
}
// Forcing hardlinks has to fail loudly when they do not work, or an operator who
// asked for them silently gets copies and a full disk.
func TestOpenHardlinkModeFailsWithoutAProbeTarget(t *testing.T) {
if _, err := Open(filepath.Join(t.TempDir(), "cas"), Options{Mode: LinkHard}); err == nil {
t.Error("hardlink mode with nothing to probe against must fail")
}
}
func TestOpenAutoFallsBackToCopy(t *testing.T) {
// No ProbeDir, so the probe cannot succeed and auto must degrade rather than
// refuse to start.
s, err := Open(filepath.Join(t.TempDir(), "cas"), Options{})
if err != nil {
t.Fatalf("auto mode must start anyway: %v", err)
}
defer s.Close()
if s.LinkMode() != LinkCopy {
t.Errorf("LinkMode = %q, want %q", s.LinkMode(), LinkCopy)
}
}
func TestBlobsAreReadOnly(t *testing.T) {
s, _ := newStore(t, LinkCopy)
d := put(t, s, "immutable")
fi, err := os.Stat(s.Path(d))
if err != nil {
t.Fatal(err)
}
// A writable blob is a writable inode shared by every project referencing
// that content; see blobMode.
if perm := fi.Mode().Perm(); perm != blobMode {
t.Errorf("blob mode = %04o, want %04o", perm, blobMode)
}
}
func TestPutLargeStream(t *testing.T) {
s, _ := newStore(t, LinkCopy)
body := bytes.Repeat([]byte("0123456789abcdef"), 1<<16) // 1 MiB
d := Sum(body)
n, err := s.Put(context.Background(), d, int64(len(body)), 4<<20, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
if n != int64(len(body)) {
t.Fatalf("stored %d bytes, want %d", n, len(body))
}
f, err := s.Open(d)
if err != nil {
t.Fatal(err)
}
defer f.Close()
got, _ := io.ReadAll(f)
if !bytes.Equal(got, body) {
t.Error("stored content differs from what was written")
}
}
+115
View File
@@ -0,0 +1,115 @@
//go:build unix
package cas
import (
"os"
"path/filepath"
"syscall"
"testing"
)
func nlink(t *testing.T, path string) uint64 {
t.Helper()
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
t.Skip("no stat_t on this platform")
}
return uint64(st.Nlink)
}
// The deduplication claim, stated as a filesystem fact: a deployed file and its
// blob are the same inode, so a second deployment of the same content costs a
// directory entry and nothing more.
func TestLinkIntoSharesTheInode(t *testing.T) {
s, deployDir := newStore(t, LinkHard)
const content = "shared across deployments\n"
d := put(t, s, content)
if got := nlink(t, s.Path(d)); got != 1 {
t.Fatalf("a fresh blob has %d links, want 1", got)
}
first := filepath.Join(deployDir, "dpl_one")
second := filepath.Join(deployDir, "dpl_two")
for _, dir := range []string{first, second} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := s.LinkInto(d, dir, "index.html"); err != nil {
t.Fatal(err)
}
}
if got := nlink(t, s.Path(d)); got != 3 {
t.Errorf("blob has %d links after two deployments, want 3 (blob + 2)", got)
}
blob, err := os.Stat(s.Path(d))
if err != nil {
t.Fatal(err)
}
for _, dir := range []string{first, second} {
deployed, err := os.Stat(filepath.Join(dir, "index.html"))
if err != nil {
t.Fatal(err)
}
if !os.SameFile(blob, deployed) {
t.Errorf("%s is a copy, not a hardlink to the blob", dir)
}
if got, err := os.ReadFile(filepath.Join(dir, "index.html")); err != nil || string(got) != content {
t.Errorf("%s: content = %q, %v", dir, got, err)
}
}
// Removing a deployment tree must not take the blob with it: the other
// deployment still references it, and so may other projects.
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
if got := nlink(t, s.Path(d)); got != 2 {
t.Errorf("blob has %d links after one tree was removed, want 2", got)
}
}
// The mode is what stops a deployed file from being written through into the
// blob every other project shares.
func TestDeployedFilesAreReadOnly(t *testing.T) {
for _, mode := range []LinkMode{LinkHard, LinkCopy} {
t.Run(string(mode), func(t *testing.T) {
s, deployDir := newStore(t, mode)
d := put(t, s, "content")
if err := os.MkdirAll(deployDir, 0o755); err != nil {
t.Fatal(err)
}
if err := s.LinkInto(d, deployDir, "index.html"); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(filepath.Join(deployDir, "index.html"))
if err != nil {
t.Fatal(err)
}
if perm := fi.Mode().Perm(); perm != blobMode {
t.Errorf("deployed file mode = %04o, want %04o", perm, blobMode)
}
})
}
}
// A copy must be a copy: writing through it may not reach the blob.
func TestCopyModeDoesNotShareTheInode(t *testing.T) {
s, deployDir := newStore(t, LinkCopy)
d := put(t, s, "content")
if err := os.MkdirAll(deployDir, 0o755); err != nil {
t.Fatal(err)
}
if err := s.LinkInto(d, deployDir, "index.html"); err != nil {
t.Fatal(err)
}
if got := nlink(t, s.Path(d)); got != 1 {
t.Errorf("blob has %d links in copy mode, want 1", got)
}
}
+88
View File
@@ -0,0 +1,88 @@
// Package cas is the content-addressed blob store. Every file any deployment
// contains is stored once, named by the SHA-256 of its bytes, and shared by
// every deployment and every project that references that content.
//
// Sharing is what makes redeploying a mostly-unchanged site nearly free, and it
// is only safe because the store never takes a caller's word for a digest: see
// Store.Put.
package cas
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
)
const (
// Size is a digest's length in bytes.
Size = sha256.Size
// HexLen is a digest's length in its hex form.
HexLen = 2 * Size
)
// Digest is the SHA-256 of a blob's contents.
//
// An array rather than a slice, so it can be a map key, compared with ==, and
// copied out of a database buffer instead of aliasing one — database/sql reuses
// the byte slices it scans into.
type Digest [Size]byte
// ErrBadDigest rejects anything that is not a digest in the canonical form.
var ErrBadDigest = errors.New("cas: not a 64-character lowercase hex sha-256")
// ParseDigest decodes the hex form used on the wire and in URLs.
//
// Strict about case rather than normalising, because a digest is simultaneously
// a primary key in the blobs table and a component of a filesystem path.
// Accepting two spellings of one value would mean two rows, two files, and
// deduplication that quietly stops deduplicating.
func ParseDigest(s string) (Digest, error) {
var d Digest
if len(s) != HexLen {
return d, ErrBadDigest
}
for i := 0; i < len(s); i++ {
// encoding/hex accepts uppercase; this loop is what makes lowercase the
// only accepted spelling.
c := s[i]
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
continue
}
return d, ErrBadDigest
}
if _, err := hex.Decode(d[:], []byte(s)); err != nil {
return Digest{}, ErrBadDigest
}
return d, nil
}
// FromBytes converts the raw 32 bytes stored in the database. It copies, so the
// result does not alias the scan buffer it came from.
func FromBytes(b []byte) (Digest, error) {
var d Digest
if len(b) != Size {
return d, fmt.Errorf("cas: digest is %d bytes, want %d", len(b), Size)
}
copy(d[:], b)
return d, nil
}
// String is the canonical hex form.
func (d Digest) String() string { return hex.EncodeToString(d[:]) }
// Bytes is the raw form stored in the database. The slice belongs to the
// caller's copy of the digest, so writing to it cannot affect anything else.
func (d Digest) Bytes() []byte { return d[:] }
// Rel is the blob's path inside the store, sharded two levels deep:
// "ab/cd/abcd…". Two hex characters per level gives 65,536 leaf directories, so
// even a store with millions of blobs keeps every directory small enough that
// readdir and lookup stay fast on ext4 and xfs alike.
func (d Digest) Rel() string {
s := d.String()
return s[0:2] + "/" + s[2:4] + "/" + s
}
// Sum digests b.
func Sum(b []byte) Digest { return sha256.Sum256(b) }