init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user