package archive import ( "archive/tar" "bytes" "crypto/ecdh" "crypto/rand" "crypto/sha256" "encoding/binary" "encoding/hex" "errors" "io" "io/fs" "os" "path/filepath" "runtime" "strings" "sync" "testing" "time" "github.com/klauspost/compress/zstd" "github.com/carlosframework/platform/internal/appconfig" ) func filesFixture(t *testing.T) (dir, env string) { t.Helper() dir = filepath.Join(t.TempDir(), "h") if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(dir, "a.txt"), []byte("alpha"), 0o644) mustWrite(t, filepath.Join(dir, "sub", "b.bin"), []byte{1, 2, 3}, 0o600) mustWrite(t, filepath.Join(dir, ".files-gen"), []byte(`{"op":"x"}`), 0o644) // A legal application file named env, and a directory named env with // a file in it: both must survive beside the .env overlay. mustWrite(t, filepath.Join(dir, "env"), []byte("app-env"), 0o644) if err := os.MkdirAll(filepath.Join(dir, "sub", "env"), 0o755); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(dir, "sub", "env", "x"), []byte("x"), 0o644) env = filepath.Join(t.TempDir(), "h.env") mustWrite(t, env, []byte("CARLOS_ADMIN_TOKEN=t\n"), 0o600) return dir, env } func mustWrite(t *testing.T, p string, b []byte, mode fs.FileMode) { t.Helper() if err := os.WriteFile(p, b, mode); err != nil { t.Fatal(err) } // WriteFile's mode passes through the umask; the fixtures want it exact. if err := os.Chmod(p, mode); err != nil { t.Fatal(err) } } // §8.1: equal content gives equal plaintext; the marker is excluded. func TestBuildIsDeterministicAndExcludesTheMarker(t *testing.T) { dir, env := filesFixture(t) var b1, b2 bytes.Buffer p1, entries, n, err := Build(dir, env, &b1) if err != nil { t.Fatal(err) } if err := os.Chtimes(filepath.Join(dir, "a.txt"), time.Unix(1, 0), time.Unix(1, 0)); err != nil { t.Fatal(err) } p2, _, _, err := Build(dir, env, &b2) if err != nil || p1 != p2 || !bytes.Equal(b1.Bytes(), b2.Bytes()) { t.Fatalf("not deterministic: %q %q %v", p1, p2, err) } paths := map[string]bool{} for _, e := range entries { paths[e.Path] = true } if paths["files/.files-gen"] || !paths["files/a.txt"] || !paths["files/sub/b.bin"] || !paths["env"] || !paths["files/env"] || !paths["files/sub/env/x"] || n != 5+3+7+1+int64(len("CARLOS_ADMIN_TOKEN=t\n")) { t.Fatalf("entries %v bytes %d", entries, n) } // An application file and directory named "env" are legal and carried // under files/, never confused with the overlay (round-tripped in // TestSealOpenExtractRoundTrip through the extra fixture entries). plain, bytesN, count, err := PlainDigest(dir, env) if err != nil || plain != p1 || bytesN != n || count != len(entries) { t.Fatalf("PlainDigest %q %d %d %v, want %q %d %d", plain, bytesN, count, err, p1, n, len(entries)) } // Owners and times are zeroed and modes kept, in the tar itself. tr := tar.NewReader(mustDecode(t, b1.Bytes())) for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatal(err) } if hdr.Uid != 0 || hdr.Gid != 0 || hdr.Uname != "" || hdr.Gname != "" || !hdr.ModTime.Equal(time.Unix(0, 0)) { t.Fatalf("%s carries an owner or a time: %+v", hdr.Name, hdr) } if hdr.Name == "files/sub/b.bin" && hdr.Mode != 0o600 { t.Fatalf("mode %o", hdr.Mode) } } } // BuildFiltered over a nested-shaped directory leaves out exactly what // skip accepts (a top-level file, a top-level directory with everything // under it, and a symlink, which unskipped would refuse the walk), never // asks about env, and writes byte for byte the archive Build writes over a // flat directory holding only the app's files; that archive extracts flat // and its extraction digests to the same plaintext. Build is BuildFiltered // with a nil skip. func TestBuildFilteredLeavesOutWhatSkipAccepts(t *testing.T) { flat, env := filesFixture(t) nested := filepath.Join(t.TempDir(), "nested") if err := os.CopyFS(nested, os.DirFS(flat)); err != nil { t.Fatal(err) } for _, rel := range []string{"a.txt", "sub", "sub/b.bin", "env", "sub/env", "sub/env/x", ".files-gen"} { info, err := os.Lstat(filepath.Join(flat, rel)) if err != nil { t.Fatal(err) } if err := os.Chmod(filepath.Join(nested, rel), info.Mode().Perm()); err != nil { t.Fatal(err) } } mustWrite(t, filepath.Join(nested, "h.db"), []byte("database"), 0o600) if err := os.MkdirAll(filepath.Join(nested, ".activator-h.db-litestream", "ltx", "0"), 0o700); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(nested, ".activator-h.db-litestream", "ltx", "0", "f.ltx"), []byte("ltx"), 0o600) if err := os.Symlink("/etc/passwd", filepath.Join(nested, "h.config.env")); err != nil { t.Fatal(err) } // A nested file that shares a skipped name below the top level is kept. mustWrite(t, filepath.Join(nested, "sub", "h.db"), []byte("an app file"), 0o644) mustWrite(t, filepath.Join(flat, "sub", "h.db"), []byte("an app file"), 0o644) platform := map[string]bool{"h.db": true, ".activator-h.db-litestream": true, "h.config.env": true} var asked []string skip := func(rel string) bool { asked = append(asked, rel) return !strings.Contains(rel, "/") && platform[rel] } if _, _, _, err := Build(nested, env, io.Discard); !errors.Is(err, ErrSpecialFile) { t.Fatalf("unfiltered, the symlink should refuse the walk: %v", err) } var got, want bytes.Buffer plain, entries, n, err := BuildFiltered(nested, env, skip, &got) if err != nil { t.Fatal(err) } wplain, wentries, wn, err := Build(flat, env, &want) if err != nil { t.Fatal(err) } if plain != wplain || n != wn || len(entries) != len(wentries) || !bytes.Equal(got.Bytes(), want.Bytes()) { t.Fatalf("filtered %s %d %v, flat %s %d %v", plain, n, entries, wplain, wn, wentries) } for _, rel := range asked { if strings.HasPrefix(rel, ".activator-h.db-litestream/") { t.Fatalf("asked about %q inside a skipped directory", rel) } } if !slicesContains(asked, "sub/h.db") || !slicesContains(asked, "h.db") { t.Fatalf("asked %v", asked) } // Extract flat, then digest the extraction as Build would. parent := t.TempDir() files, outEnv := filepath.Join(parent, "h"), filepath.Join(parent, "h.env") if err := Extract(bytes.NewReader(got.Bytes()), files, outEnv); err != nil { t.Fatal(err) } if p, _, _, err := PlainDigest(files, outEnv); err != nil || p != plain { t.Fatalf("extraction digests to %s (%v), want %s", p, err, plain) } for _, name := range []string{"h.db", ".activator-h.db-litestream", "h.config.env"} { if _, err := os.Lstat(filepath.Join(files, name)); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("%s extracted: %v", name, err) } } // A nil skip is Build. var nilSkip bytes.Buffer if p, _, _, err := BuildFiltered(flat, env, nil, &nilSkip); err != nil || p != wplain || !bytes.Equal(nilSkip.Bytes(), want.Bytes()) { t.Fatalf("nil skip: %s %v", p, err) } } func slicesContains(s []string, v string) bool { for _, x := range s { if x == v { return true } } return false } func mustDecode(t *testing.T, b []byte) io.Reader { t.Helper() zr, err := zstd.NewReader(bytes.NewReader(b)) if err != nil { t.Fatal(err) } t.Cleanup(zr.Close) return zr } // Review Focus 3: a symlink refuses with its path, and so does a files // directory that is itself a symlink (WalkDir would otherwise archive // nothing beneath it and the move would carry an empty tree). func TestArchiveRefusesSpecialFiles(t *testing.T) { dir, env := filesFixture(t) if err := os.Symlink("a.txt", filepath.Join(dir, "link")); err != nil { t.Fatal(err) } _, _, _, err := Build(dir, env, &bytes.Buffer{}) if !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "link") { t.Fatalf("symlink: %v", err) } dir2, env2 := filesFixture(t) linked := filepath.Join(t.TempDir(), "linked") if err := os.Symlink(dir2, linked); err != nil { t.Fatal(err) } if _, _, _, err := Build(linked, env2, &bytes.Buffer{}); !errors.Is(err, ErrSpecialFile) { t.Fatalf("symlinked files directory: %v", err) } envLink := filepath.Join(t.TempDir(), "h.env") if err := os.Symlink(env2, envLink); err != nil { t.Fatal(err) } if _, _, _, err := Build(dir2, envLink, &bytes.Buffer{}); !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "h.env") { t.Fatalf("symlinked env: %v", err) } } // tarZst writes a hand-built archive: what a hostile sealer could produce. type rawEntry struct { hdr tar.Header body []byte } func tarZst(t *testing.T, entries []rawEntry, trailer []byte) []byte { t.Helper() var buf bytes.Buffer zw, err := zstd.NewWriter(&buf) if err != nil { t.Fatal(err) } tw := tar.NewWriter(zw) for _, e := range entries { hdr := e.hdr if err := tw.WriteHeader(&hdr); err != nil { t.Fatal(err) } if _, err := tw.Write(e.body); err != nil { t.Fatal(err) } } if err := tw.Close(); err != nil { t.Fatal(err) } if _, err := zw.Write(trailer); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func reg(name, body string) rawEntry { return rawEntry{hdr: tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(body)), Mode: 0o600}, body: []byte(body)} } func dirEntry(name string) rawEntry { return rawEntry{hdr: tar.Header{Name: name, Typeflag: tar.TypeDir, Mode: 0o755}} } // extractTarget is a destination whose parent holds nothing else, so a // test can prove a refused archive left no byte behind. func extractTarget(t *testing.T) (parent, files, env string) { t.Helper() parent = t.TempDir() envDir := t.TempDir() return parent, filepath.Join(parent, "out"), filepath.Join(envDir, "out.env") } func assertUntouched(t *testing.T, parent, files, env string) { t.Helper() left, err := os.ReadDir(parent) if err != nil { t.Fatal(err) } if len(left) != 0 { t.Fatalf("a refused archive left %v in the destination's parent", left) } if _, err := os.Lstat(files); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("files directory written: %v", err) } envLeft, _ := os.ReadDir(filepath.Dir(env)) if len(envLeft) != 0 { t.Fatalf("a refused archive left %v beside the env file", envLeft) } } // duplicateStreams name one destination twice. func duplicateStreams() map[string][]rawEntry { return map[string][]rawEntry{ "env twice": {reg("env", "x"), reg("env", "y")}, "file twice": {reg("files/a", "x"), reg("files/a", "y")}, "dir then file": {dirEntry("files/a/"), reg("files/a", "y")}, "file then env dup": {reg("files/a", "x"), reg("env", "e"), reg("files/a", "z")}, } } // A crafted archive naming one destination twice (two "env" entries, a // file twice, or a directory and then a file of one name) is refused // before any byte reaches the destination: Extract writes into a private // staging directory and moves it into place only once the whole archive // has been read and checked. func TestExtractRefusesDuplicateDestinations(t *testing.T) { for name, entries := range duplicateStreams() { t.Run(name, func(t *testing.T) { parent, files, env := extractTarget(t) err := Extract(bytes.NewReader(tarZst(t, entries, nil)), files, env) if !errors.Is(err, ErrBadPath) { t.Fatalf("duplicate: %v", err) } assertUntouched(t, parent, files, env) }) } } // Traversal, the protocol marker, entries outside files/ and env, and any // type but a regular file or a directory are refused, and nothing lands. // refusedStreamCase is one hostile stream and the refusal every reader owes it. type refusedStreamCase struct { entries []rawEntry want error } // refusedStreams are the hostile streams Extract and Verify both refuse. func refusedStreams() map[string]refusedStreamCase { return map[string]refusedStreamCase{ "dotdot": {[]rawEntry{reg("files/../x", "x")}, ErrBadPath}, "leading dotdot": {[]rawEntry{reg("../x", "x")}, ErrBadPath}, "absolute": {[]rawEntry{reg("/etc/x", "x")}, ErrBadPath}, "dot segment": {[]rawEntry{reg("files/./a", "x")}, ErrBadPath}, "double slash": {[]rawEntry{reg("files//a", "x")}, ErrBadPath}, "files root": {[]rawEntry{dirEntry("files/")}, ErrBadPath}, "outside": {[]rawEntry{reg("other", "x")}, ErrBadPath}, "marker": {[]rawEntry{reg("files/.files-gen", `{"op":"forged"}`)}, ErrBadPath}, "marker dir": {[]rawEntry{dirEntry("files/.files-gen/")}, ErrBadPath}, "marker child": {[]rawEntry{reg("files/.files-gen/child", "x")}, ErrBadPath}, "before its dir": {[]rawEntry{reg("files/a/b", "x")}, ErrBadPath}, "late dir": {[]rawEntry{reg("files/a/b", "x"), dirEntry("files/a/")}, ErrBadPath}, "nested late dir": {[]rawEntry{dirEntry("files/a/"), reg("files/a/b/c", "x"), dirEntry("files/a/b/")}, ErrBadPath}, "env as dir": {[]rawEntry{dirEntry("env/")}, ErrBadPath}, "too long": {[]rawEntry{reg("files/"+strings.Repeat("a", MaxPathLen+1), "x")}, ErrBadPath}, "past tree cap": {[]rawEntry{reg("files/"+strings.Repeat("a", TreePathLen+1), "x")}, ErrBadPath}, "symlink": {[]rawEntry{{hdr: tar.Header{Name: "files/l", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}}}, ErrSpecialFile}, "hard link": {[]rawEntry{reg("files/a", "x"), {hdr: tar.Header{Name: "files/l", Typeflag: tar.TypeLink, Linkname: "files/a"}}}, ErrSpecialFile}, "char device": {[]rawEntry{{hdr: tar.Header{Name: "files/d", Typeflag: tar.TypeChar, Devmajor: 1, Devminor: 3}}}, ErrSpecialFile}, "fifo": {[]rawEntry{{hdr: tar.Header{Name: "files/f", Typeflag: tar.TypeFifo}}}, ErrSpecialFile}, "late bad entry": {[]rawEntry{reg("files/a", "fine"), reg("env", "e"), reg("files/../escape", "x")}, ErrBadPath}, "over cap header": {[]rawEntry{{hdr: tar.Header{Name: "files/big", Typeflag: tar.TypeReg, Size: Cap + 1}}}, ErrOverCap}, } } func TestExtractRefusesTraversalAndSpecialEntries(t *testing.T) { cases := refusedStreams() for name, c := range cases { t.Run(name, func(t *testing.T) { var raw []byte if c.want == ErrOverCap { raw = overCapArchive(t) } else { raw = tarZst(t, c.entries, nil) } parent, files, env := extractTarget(t) if err := Extract(bytes.NewReader(raw), files, env); !errors.Is(err, c.want) { t.Fatalf("%s: %v, want %v", name, err, c.want) } assertUntouched(t, parent, files, env) }) } } // overCapArchive is a tar.zst whose one header claims Cap+1 bytes. Only // the header and a few bytes of content are present: every reader must // refuse on the header, before reading the content. func overCapArchive(t *testing.T) []byte { t.Helper() var tarBuf bytes.Buffer tw := tar.NewWriter(&tarBuf) if err := tw.WriteHeader(&tar.Header{Name: "files/big", Typeflag: tar.TypeReg, Size: Cap + 1, Mode: 0o600}); err != nil { t.Fatal(err) } if _, err := tw.Write([]byte("abc")); err != nil { t.Fatal(err) } // No Close: the content is short on purpose. var buf bytes.Buffer zw, _ := zstd.NewWriter(&buf) zw.Write(tarBuf.Bytes()) zw.Close() return buf.Bytes() } // The destination must be absent or an empty directory, and the env file // may not live inside the files directory (Build would carry it twice). func TestExtractRefusesAnOccupiedDestination(t *testing.T) { dir, env := filesFixture(t) var tarz bytes.Buffer if _, _, _, err := Build(dir, env, &tarz); err != nil { t.Fatal(err) } occupied := t.TempDir() mustWrite(t, filepath.Join(occupied, "stale"), []byte("s"), 0o644) if err := Extract(bytes.NewReader(tarz.Bytes()), occupied, filepath.Join(t.TempDir(), "e.env")); !errors.Is(err, ErrNotEmpty) { t.Fatalf("occupied: %v", err) } inside := t.TempDir() if err := Extract(bytes.NewReader(tarz.Bytes()), inside, filepath.Join(inside, "h.env")); !errors.Is(err, ErrBadPath) { t.Fatalf("env inside files: %v", err) } // An empty existing directory is fine, and keeps its mode. empty := filepath.Join(t.TempDir(), "empty") if err := os.Mkdir(empty, 0o750); err != nil { t.Fatal(err) } if err := os.Chmod(empty, 0o750); err != nil { t.Fatal(err) } if err := Extract(bytes.NewReader(tarz.Bytes()), empty, filepath.Join(t.TempDir(), "e.env")); err != nil { t.Fatal(err) } if info, _ := os.Stat(empty); info.Mode().Perm() != 0o750 { t.Fatalf("root mode %o", info.Mode().Perm()) } } // The stat-time check: a file already over the cap when it is listed is // refused before any content is read (a sparse file, so nothing is read). func TestArchiveCapIsEnforcedAtStat(t *testing.T) { dir, env := filesFixture(t) f, err := os.Create(filepath.Join(dir, "big")) if err != nil { t.Fatal(err) } if err := f.Truncate(Cap + 1); err != nil { t.Fatal(err) } f.Close() if _, _, _, err := Build(dir, env, &bytes.Buffer{}); !errors.Is(err, ErrOverCap) { t.Fatalf("over cap: %v", err) } } // lyingFile is a file that keeps growing after it was listed: it yields // bytes forever and counts how many it was asked for. type lyingFile struct{ read int64 } func (l *lyingFile) Read(p []byte) (int, error) { for i := range p { p[i] = 'g' } l.read += int64(len(p)) return len(p), nil } func (l *lyingFile) Close() error { return nil } // The streaming half of the cap: the tar header commits each file's size // as listed, and every file is read only up to that size. A file that grows // after it was listed (here without end) cannot push the archive past what // was admitted against the cap; it is refused as changed, naming its path, // having been read for at most one byte past its listed size. func TestArchiveCapHoldsWhenAFileGrowsWhileStreaming(t *testing.T) { dir, env := filesFixture(t) liar := &lyingFile{} orig := openItem t.Cleanup(func() { openItem = orig }) openItem = func(it item) (io.ReadCloser, error) { if it.name == "files/a.txt" { return liar, nil } return orig(it) } var out countingWriter _, _, _, err := Build(dir, env, &out) if !errors.Is(err, ErrChanged) || !strings.Contains(err.Error(), "a.txt") { t.Fatalf("growing file: %v", err) } if liar.read > int64(len("alpha"))+1 { t.Fatalf("read %d bytes of a file listed at 5", liar.read) } if _, _, _, err := PlainDigest(dir, env); !errors.Is(err, ErrChanged) { t.Fatalf("PlainDigest of a growing file: %v", err) } } type countingWriter struct{ n int64 } func (c *countingWriter) Write(p []byte) (int, error) { c.n += int64(len(p)); return len(p), nil } // F25: the cap counts content, not tar headers and padding. Content just // under the cap plus a few hundred small files (whose headers alone run // past the old 64 KiB allowance) archives; so do thousands of small files. func TestManySmallFilesUnderTheCapArchive(t *testing.T) { if testing.Short() { t.Skip("reads 256 MiB of a sparse file") } dir, env := filesFixture(t) // Fixture content is 37 bytes; leave room for 400 one-byte files. f, err := os.Create(filepath.Join(dir, "near-cap")) if err != nil { t.Fatal(err) } if err := f.Truncate(Cap - 37 - 400 - 100); err != nil { t.Fatal(err) } f.Close() many := filepath.Join(dir, "many") if err := os.Mkdir(many, 0o755); err != nil { t.Fatal(err) } for i := 0; i < 400; i++ { mustWrite(t, filepath.Join(many, strings.Repeat("f", 40)+string(rune('a'+i%26))+hex.EncodeToString([]byte{byte(i >> 8), byte(i)})), []byte("z"), 0o644) } b, err := build(dir, env, io.Discard) if err != nil { t.Fatalf("content under the cap refused: %v", err) } if b.content > Cap || b.tarBytes <= Cap+64<<10 { t.Fatalf("fixture does not exercise header overhead: content %d tar %d", b.content, b.tarBytes) } } func TestThousandsOfSmallFilesArchive(t *testing.T) { dir, env := filesFixture(t) for i := 0; i < 3000; i++ { mustWrite(t, filepath.Join(dir, "s"+hex.EncodeToString([]byte{byte(i >> 8), byte(i)})), []byte{byte(i)}, 0o644) } var tarz bytes.Buffer plain, entries, _, err := Build(dir, env, &tarz) if err != nil || len(entries) != 3005 { t.Fatalf("%d entries, %v", len(entries), err) } _, files, outEnv := extractTarget(t) if err := Extract(bytes.NewReader(tarz.Bytes()), files, outEnv); err != nil { t.Fatal(err) } if p2, _, _, _ := PlainDigest(files, outEnv); p2 != plain { t.Fatal("round trip changed the plain digest") } } // The per-entry overhead bound behind MaxDecoded holds for the longest // path Build admits, so every archive Build writes is one Extract accepts. func TestTheLongestPathFitsTheEntryOverhead(t *testing.T) { dir := filepath.Join(t.TempDir(), "h") // Components of 200 bytes nested to a relative path of TreePathLen-ish: // the longest Build admits is the files tree's cap (#236 fix round 1). rel := "" for len(rel)+201 <= TreePathLen-10 { if rel != "" { rel += "/" } rel += strings.Repeat("d", 200) } if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } leaf := rel + "/" + strings.Repeat("f", TreePathLen-len(rel)-1) if len(leaf) != TreePathLen { t.Fatalf("leaf %d", len(leaf)) } // Created through a Root (openat), as Build reads it: the absolute // path is past PATH_MAX, the relative one is not. root, err := os.OpenRoot(dir) if err != nil { t.Fatal(err) } defer root.Close() if err := root.MkdirAll(rel, 0o755); err != nil { t.Fatal(err) } if err := root.WriteFile(leaf, []byte("x"), 0o644); err != nil { t.Fatal(err) } b, err := build(dir, "", io.Discard) if err != nil { t.Fatal(err) } if per := (b.tarBytes - 2*512 - b.content) / int64(b.count); per > entryOverhead { t.Fatalf("per-entry overhead %d over the bound %d", per, entryOverhead) } // One byte longer (MaxPathLen) is past the tree's cap: refused by Build // with ErrPastTreeCap, naming the path. if err := root.Rename(leaf, leaf+"x"); err != nil { t.Fatal(err) } var pe *PathError if _, err := build(dir, "", io.Discard); !errors.Is(err, ErrPastTreeCap) || errors.Is(err, ErrBadPath) || !errors.As(err, &pe) || pe.Path != leaf+"x" { t.Fatalf("a path of MaxPathLen: %v", err) } // Past MaxPathLen it is the format's own refusal (MV-N81), as before. if err := root.Rename(leaf+"x", leaf+"xy"); err != nil { t.Fatal(err) } if _, err := build(dir, "", io.Discard); !errors.Is(err, ErrBadPath) || !errors.As(err, &pe) || pe.Path != leaf+"xy" { t.Fatalf("over-long path: %v", err) } } // #236 fix round 1: a path of exactly the files tree's cap (TreePathLen, // hostfiles.MaxPathBytes) goes through Build, Seal, Verify and Extract, into // a destination whose absolute path is past PATH_MAX once the path is // added; one byte longer never leaves the source, and a stream that carries // it (an older source's) is refused by Verify and Extract (the "past tree // cap" hostile stream). func TestATreeCapPathRoundTrips(t *testing.T) { // TreePathLen is hostfiles.MaxPathBytes by definition; MaxPathLen is // one byte more, which the next line fails to compile without. const _ = uint(MaxPathLen-1-TreePathLen) + uint(TreePathLen-(MaxPathLen-1)) dir := filepath.Join(t.TempDir(), "h") if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } leaf := treeCapPath(TreePathLen) root, err := os.OpenRoot(dir) if err != nil { t.Fatal(err) } defer root.Close() if err := root.MkdirAll(filepath.Dir(leaf), 0o755); err != nil { t.Fatal(err) } if err := root.WriteFile(leaf, []byte("deep"), 0o644); err != nil { t.Fatal(err) } var tarz bytes.Buffer plain, entries, n, err := Build(dir, "", &tarz) if err != nil { t.Fatal(err) } pub, priv, b := keys(t) var sealed bytes.Buffer key, sealedSHA, err := Seal(bytes.NewReader(tarz.Bytes()), pub, b, &sealed) if err != nil { t.Fatal(err) } m := Manifest{V: ManifestVersion, Op: "mv_x", Incarnation: "i", Plain: plain, Sealed: sealedSHA, Bytes: n, Entries: entries, SealedDataKey: key, Chunk: ChunkSize} if err := Verify(bytes.NewReader(sealed.Bytes()), m, priv, b); err != nil { t.Fatalf("Verify of a tree-cap path: %v", err) } _, files, env := extractTarget(t) if len(files)+1+len(leaf) <= 4096 { t.Fatalf("the destination %s does not put the path past PATH_MAX", files) } if err := Extract(bytes.NewReader(tarz.Bytes()), files, env); err != nil { t.Fatalf("Extract of a tree-cap path: %v", err) } out, err := os.OpenRoot(files) if err != nil { t.Fatal(err) } defer out.Close() if got, err := out.ReadFile(leaf); err != nil || string(got) != "deep" { t.Fatalf("extracted %q, %v", got, err) } } // treeCapPath is a relative path of exactly n bytes, 200-byte directory // components and a leaf. func treeCapPath(n int) string { var b strings.Builder for b.Len()+201 <= n-10 { if b.Len() > 0 { b.WriteByte('/') } b.WriteString(strings.Repeat("d", 200)) } b.WriteByte('/') b.WriteString(strings.Repeat("f", n-b.Len())) return b.String() } // Build, Verify and Extract share one entry-count limit, and Extract and // Verify bound the decoded stream, so a zstd bomb that passed the sealing // is still bounded on the target. func TestEntryAndDecodedLimitsAreShared(t *testing.T) { dir, env := filesFixture(t) var tarz bytes.Buffer plain, entries, n, err := Build(dir, env, &tarz) if err != nil { t.Fatal(err) } pub, priv, b := keys(t) var sealed bytes.Buffer key, sealedSHA, err := Seal(bytes.NewReader(tarz.Bytes()), pub, b, &sealed) if err != nil { t.Fatal(err) } m := Manifest{V: ManifestVersion, Op: "mv_x", Incarnation: "i", Plain: plain, Sealed: sealedSHA, Bytes: n, Entries: entries, SealedDataKey: key, Chunk: ChunkSize} if err := Verify(bytes.NewReader(sealed.Bytes()), m, priv, b); err != nil { t.Fatal(err) } origEntries := entryLimit entryLimit = 4 if _, _, _, err := Build(dir, env, io.Discard); !errors.Is(err, ErrTooManyEntries) { t.Fatalf("Build over the entry limit: %v", err) } if err := Verify(bytes.NewReader(sealed.Bytes()), m, priv, b); !errors.Is(err, ErrTooManyEntries) { t.Fatalf("Verify over the entry limit: %v", err) } parent, files, outEnv := extractTarget(t) if err := Extract(bytes.NewReader(tarz.Bytes()), files, outEnv); !errors.Is(err, ErrTooManyEntries) { t.Fatalf("Extract over the entry limit: %v", err) } assertUntouched(t, parent, files, outEnv) entryLimit = origEntries // A bomb: a valid tar followed by 8 MiB of zeros inside the zstd // stream (a few hundred bytes compressed). bomb := tarZst(t, []rawEntry{reg("files/a", "x")}, make([]byte, 8<<20)) origDecoded := decodedLimit t.Cleanup(func() { decodedLimit = origDecoded; entryLimit = origEntries }) decodedLimit = 1 << 20 parent, files, outEnv = extractTarget(t) if err := Extract(bytes.NewReader(bomb), files, outEnv); !errors.Is(err, ErrTooLarge) { t.Fatalf("bomb: %v", err) } assertUntouched(t, parent, files, outEnv) var bombSealed bytes.Buffer bombKey, bombSHA, err := Seal(bytes.NewReader(bomb), pub, b, &bombSealed) if err != nil { t.Fatal(err) } bm := Manifest{V: ManifestVersion, Plain: "sha256:x", Sealed: bombSHA, Bytes: 1, Entries: []Entry{{Path: "files/a", Size: 1, SHA256: digestBytes([]byte("x"))}}, SealedDataKey: bombKey, Chunk: ChunkSize} if err := Verify(bytes.NewReader(bombSealed.Bytes()), bm, priv, b); !errors.Is(err, ErrTooLarge) { t.Fatalf("Verify of a bomb: %v", err) } } func digestBytes(b []byte) string { sum := sha256.Sum256(b) return "sha256:" + hex.EncodeToString(sum[:]) } func keys(t *testing.T) (*ecdh.PublicKey, *ecdh.PrivateKey, appconfig.Binding) { t.Helper() privB64, pubB64, err := appconfig.GenerateKey() if err != nil { t.Fatal(err) } pk, err := appconfig.ParsePrivateKey(privB64, "test") if err != nil { t.Fatal(err) } pu, err := appconfig.ParsePublicKey(pubB64, "test") if err != nil { t.Fatal(err) } return pu, pk, appconfig.Binding{Account: "bab", App: "app"} } func TestSealOpenExtractRoundTrip(t *testing.T) { dir, env := filesFixture(t) var tarz bytes.Buffer plain, _, _, err := Build(dir, env, &tarz) if err != nil { t.Fatal(err) } pub, priv, b := keys(t) var sealed bytes.Buffer key, sealedSHA, err := Seal(bytes.NewReader(tarz.Bytes()), pub, b, &sealed) if err != nil { t.Fatal(err) } sum := sha256.Sum256(sealed.Bytes()) if sealedSHA != "sha256:"+hex.EncodeToString(sum[:]) { t.Fatal("sealed digest") } var opened bytes.Buffer if err := Open(bytes.NewReader(sealed.Bytes()), key, priv, b, &opened); err != nil { t.Fatal(err) } if !bytes.Equal(opened.Bytes(), tarz.Bytes()) { t.Fatal("open != build") } // A flipped byte fails: GCM, not a checksum. bad := append([]byte{}, sealed.Bytes()...) bad[len(bad)/2] ^= 1 if err := Open(bytes.NewReader(bad), key, priv, b, &bytes.Buffer{}); !errors.Is(err, ErrSealed) { t.Fatalf("tampered archive opened: %v", err) } // Another binding cannot open the data key (F4: SealBound, always v2). if err := Open(bytes.NewReader(sealed.Bytes()), key, priv, appconfig.Binding{Account: "bab", App: "other"}, &bytes.Buffer{}); !errors.Is(err, ErrSealed) { t.Fatalf("wrong binding opened: %v", err) } out := filepath.Join(t.TempDir(), "out") outEnv := filepath.Join(t.TempDir(), "out.env") if err := Extract(bytes.NewReader(opened.Bytes()), out, outEnv); err != nil { t.Fatal(err) } got, _ := os.ReadFile(filepath.Join(out, "sub", "b.bin")) envGot, _ := os.ReadFile(outEnv) appEnv, _ := os.ReadFile(filepath.Join(out, "env")) nested, _ := os.ReadFile(filepath.Join(out, "sub", "env", "x")) if string(got) != "\x01\x02\x03" || string(envGot) != "CARLOS_ADMIN_TOKEN=t\n" || string(appEnv) != "app-env" || string(nested) != "x" { t.Fatal("extract content") } if _, err := os.Lstat(filepath.Join(out, MarkerName)); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("marker carried: %v", err) } if info, _ := os.Stat(outEnv); info.Mode().Perm() != 0o600 { t.Fatalf("env mode %o", info.Mode().Perm()) } p2, _, _, _ := PlainDigest(out, outEnv) if p2 != plain { t.Fatal("extracted tree has another plain digest") } // Encryption is randomized (§8.2). var sealed2 bytes.Buffer if _, _, err := Seal(bytes.NewReader(tarz.Bytes()), pub, b, &sealed2); err != nil { t.Fatal(err) } if bytes.Equal(sealed.Bytes(), sealed2.Bytes()) { t.Fatal("deterministic sealing") } } // F4: a data key sealed as a v1 (unbound) envelope opens under any app's // binding, so a manifest downgraded to one would unbind the archive. Open // refuses it before trying the key. func TestOpenRefusesAnUnboundDataKey(t *testing.T) { dir, env := filesFixture(t) var tarz bytes.Buffer if _, _, _, err := Build(dir, env, &tarz); err != nil { t.Fatal(err) } pub, priv, b := keys(t) var sealed bytes.Buffer key, _, err := Seal(bytes.NewReader(tarz.Bytes()), pub, b, &sealed) if err != nil { t.Fatal(err) } raw, err := appconfig.Open(priv, b, key) if err != nil { t.Fatal(err) } v1, err := appconfig.SealUnbound(pub, raw) if err != nil { t.Fatal(err) } // The v1 key is the right key: appconfig opens it under any binding. if again, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "other"}, v1); err != nil || !bytes.Equal(again, raw) { t.Fatalf("fixture: %v", err) } if err := Open(bytes.NewReader(sealed.Bytes()), v1, priv, b, &bytes.Buffer{}); !errors.Is(err, ErrSealed) { t.Fatalf("v1 data key opened: %v", err) } } // A 0555 directory holding a 0400 file round-trips to exactly those modes, // and the extracted tree digests as the original did. func TestExtractPreservesModes(t *testing.T) { dir, env := filesFixture(t) ro := filepath.Join(dir, "ro") if err := os.Mkdir(ro, 0o755); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(ro, "f"), []byte("secret"), 0o400) if err := os.Chmod(ro, 0o555); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(ro, 0o755) }) var tarz bytes.Buffer plain, _, _, err := Build(dir, env, &tarz) if err != nil { t.Fatal(err) } out := filepath.Join(t.TempDir(), "out") outEnv := filepath.Join(t.TempDir(), "out.env") t.Cleanup(func() { os.Chmod(filepath.Join(out, "ro"), 0o755) }) if err := Extract(bytes.NewReader(tarz.Bytes()), out, outEnv); err != nil { t.Fatal(err) } di, _ := os.Stat(filepath.Join(out, "ro")) fi, _ := os.Stat(filepath.Join(out, "ro", "f")) if di.Mode().Perm() != 0o555 || fi.Mode().Perm() != 0o400 { t.Fatalf("modes %o %o", di.Mode().Perm(), fi.Mode().Perm()) } if p2, _, _, err := PlainDigest(out, outEnv); err != nil || p2 != plain { t.Fatalf("plain after extract %q %v", p2, err) } } // An archive with no env entry removes a stale env file, so the extracted // pair always digests as the archive does. func TestExtractWithoutEnvLeavesNoEnv(t *testing.T) { dir, _ := filesFixture(t) var tarz bytes.Buffer plain, _, _, err := Build(dir, "", &tarz) if err != nil { t.Fatal(err) } out := filepath.Join(t.TempDir(), "out") stale := filepath.Join(t.TempDir(), "out.env") mustWrite(t, stale, []byte("OLD=1\n"), 0o600) if err := Extract(bytes.NewReader(tarz.Bytes()), out, stale); err != nil { t.Fatal(err) } if _, err := os.Lstat(stale); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("stale env kept: %v", err) } if p2, _, _, _ := PlainDigest(out, stale); p2 != plain { t.Fatal("plain differs") } } // A small archive handed over in a *bytes.Buffer (under the decoder's // 128 KiB shortcut for a reader with Bytes() and Len()) is read as a // stream like any other: Extract, the decode Verify runs, and Verify itself // accept it, rather than finding the whole archive still unread after it. func TestASmallArchiveInABytesBufferExtractsAndVerifies(t *testing.T) { dir, env := filesFixture(t) var tarz bytes.Buffer plain, entries, n, err := Build(dir, env, &tarz) if err != nil { t.Fatal(err) } if tarz.Len() >= 128<<10 { t.Fatalf("fixture: %d bytes is not under 128 KiB", tarz.Len()) } raw := append([]byte(nil), tarz.Bytes()...) parent := t.TempDir() files, envOut := filepath.Join(parent, "h"), filepath.Join(parent, "h.env") if err := Extract(bytes.NewBuffer(append([]byte(nil), raw...)), files, envOut); err != nil { t.Fatalf("Extract from a *bytes.Buffer: %v", err) } if got, _, _, err := PlainDigest(files, envOut); err != nil || got != plain { t.Fatalf("the extraction digests to %s (%v), want %s", got, err, plain) } m := Manifest{V: ManifestVersion, Op: "mv_x", Plain: plain, Bytes: n, Entries: entries, Chunk: ChunkSize} if got, err := verifyStream(bytes.NewBuffer(append([]byte(nil), raw...)), m); err != nil || got != plain { t.Fatalf("verifyStream from a *bytes.Buffer: %s, %v", got, err) } pub, priv, b := keys(t) var sealed bytes.Buffer key, sealedSHA, err := Seal(bytes.NewReader(raw), pub, b, &sealed) if err != nil { t.Fatal(err) } m.Sealed, m.SealedDataKey = sealedSHA, key if err := Verify(&sealed, m, priv, b); err != nil { t.Fatalf("Verify from a *bytes.Buffer: %v", err) } } // sealedArchive is a good archive with its manifest, over a tree large // enough to seal into three chunks. type sealedArchive struct { tarz, sealed []byte m Manifest pub *ecdh.PublicKey priv *ecdh.PrivateKey b appconfig.Binding } func threeChunkArchive(t *testing.T) sealedArchive { t.Helper() dir, env := filesFixture(t) big := make([]byte, 2*ChunkSize+ChunkSize/2) if _, err := rand.Read(big); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(dir, "random.bin"), big, 0o644) var tarz bytes.Buffer plain, entries, n, err := Build(dir, env, &tarz) if err != nil { t.Fatal(err) } pub, priv, b := keys(t) var sealed bytes.Buffer key, sealedSHA, err := Seal(bytes.NewReader(tarz.Bytes()), pub, b, &sealed) if err != nil { t.Fatal(err) } if got := len(chunkOffsets(t, sealed.Bytes())); got != 3 { t.Fatalf("fixture seals into %d chunks", got) } return sealedArchive{ tarz: tarz.Bytes(), sealed: sealed.Bytes(), pub: pub, priv: priv, b: b, m: Manifest{V: ManifestVersion, Op: "mv_x", Incarnation: "i", Plain: plain, Sealed: sealedSHA, Bytes: n, Entries: entries, SealedDataKey: key, Chunk: ChunkSize}, } } // chunkOffsets returns where each sealed chunk's length prefix starts. func chunkOffsets(t *testing.T, sealed []byte) []int { t.Helper() var offs []int for off := 0; off < len(sealed); { if off+4 > len(sealed) { t.Fatal("ragged sealed fixture") } offs = append(offs, off) off += 4 + int(binary.BigEndian.Uint32(sealed[off:])) } return offs } // sealRaw seals a hand-built archive, with a manifest naming its regular // entries as given. func (a sealedArchive) sealRaw(t *testing.T, raw []byte, entries []Entry) ([]byte, Manifest) { t.Helper() var sealed bytes.Buffer key, sha, err := Seal(bytes.NewReader(raw), a.pub, a.b, &sealed) if err != nil { t.Fatal(err) } var n int64 for _, e := range entries { n += e.Size } return sealed.Bytes(), Manifest{V: ManifestVersion, Plain: "sha256:unchecked", Sealed: sha, Bytes: n, Entries: entries, SealedDataKey: key, Chunk: ChunkSize} } func TestVerifyRefusesWhatTheManifestDoesNotPromise(t *testing.T) { a := threeChunkArchive(t) if err := Verify(bytes.NewReader(a.sealed), a.m, a.priv, a.b); err != nil { t.Fatalf("good archive: %v", err) } withManifest := func(edit func(m *Manifest)) Manifest { m := a.m m.Entries = append([]Entry(nil), a.m.Entries...) edit(&m) return m } idx := func(m *Manifest, p string) int { for i, e := range m.Entries { if e.Path == p { return i } } t.Fatalf("no %s", p) return -1 } manifestCases := map[string]struct { m Manifest want string }{ "plain": {withManifest(func(m *Manifest) { m.Plain = digestBytes([]byte("other")) }), "plaintext digest"}, "sealed": {withManifest(func(m *Manifest) { m.Sealed = digestBytes([]byte("other")) }), "sealed digest"}, "missing entry": {withManifest(func(m *Manifest) { i := idx(m, "files/a.txt") m.Bytes -= m.Entries[i].Size m.Entries = append(m.Entries[:i], m.Entries[i+1:]...) }), "not in the manifest"}, "entry digest": {withManifest(func(m *Manifest) { m.Entries[idx(m, "files/a.txt")].SHA256 = digestBytes([]byte("beta!")) }), "digest differs"}, "bytes": {withManifest(func(m *Manifest) { m.Bytes++ }), "bytes"}, "version": {withManifest(func(m *Manifest) { m.V = 2 }), "version"}, "chunk": {withManifest(func(m *Manifest) { m.Chunk = 4096 }), "chunk"}, "no key": {withManifest(func(m *Manifest) { m.SealedDataKey = "" }), "no sealed"}, "duplicate manifest entry": {withManifest(func(m *Manifest) { e := m.Entries[idx(m, "files/a.txt")] m.Entries = append(m.Entries, e) m.Bytes += e.Size }), "named twice"}, "traversal in manifest": {withManifest(func(m *Manifest) { m.Entries[idx(m, "files/a.txt")].Path = "files/../a.txt" }), "refused path"}, } for name, c := range manifestCases { t.Run(name, func(t *testing.T) { err := Verify(bytes.NewReader(a.sealed), c.m, a.priv, a.b) if err == nil || !strings.Contains(err.Error(), c.want) { t.Fatalf("%v, want %q", err, c.want) } }) } // Hand-built sealed archives. symlink := tarZst(t, []rawEntry{{hdr: tar.Header{Name: "files/l", Typeflag: tar.TypeSymlink, Linkname: "/etc/shadow"}}}, nil) dup := tarZst(t, []rawEntry{reg("files/a", "x"), reg("files/a", "x")}, nil) marker := tarZst(t, []rawEntry{reg("files/.files-gen", "x")}, nil) xEntry := []Entry{{Path: "files/a", Size: 1, SHA256: digestBytes([]byte("x"))}} rawCases := map[string]struct { raw []byte entries []Entry want error }{ "symlink entry": {symlink, nil, ErrSpecialFile}, "over cap": {overCapArchive(t), nil, ErrOverCap}, "duplicate in tar": {dup, xEntry, ErrBadPath}, "marker in tar": {marker, nil, ErrBadPath}, "marker child": {tarZst(t, []rawEntry{reg("files/.files-gen/child", "x")}, nil), nil, ErrBadPath}, } for name, c := range rawCases { t.Run(name, func(t *testing.T) { sealed, m := a.sealRaw(t, c.raw, c.entries) if err := Verify(bytes.NewReader(sealed), m, a.priv, a.b); !errors.Is(err, c.want) { t.Fatalf("%v, want %v", err, c.want) } }) } // Damage to the sealed object itself: each is Open's verdict. offs := chunkOffsets(t, a.sealed) flipped := append([]byte(nil), a.sealed...) flipped[len(flipped)-1] ^= 1 cut := append([]byte(nil), a.sealed[:offs[len(offs)-1]]...) appended := append(append([]byte(nil), a.sealed...), 0) twice := append(append([]byte(nil), a.sealed...), a.sealed...) damage := map[string][]byte{ "last byte flipped": flipped, "final chunk cut off": cut, "byte appended": appended, "archive concatenated": twice, } for name, sealed := range damage { t.Run(name, func(t *testing.T) { // The manifest's sealed digest is made to match the damaged // object, so only Open's own checks stand between it and nil. m := a.m m.Sealed = digestBytes(sealed) err := Verify(bytes.NewReader(sealed), m, a.priv, a.b) if !errors.Is(err, ErrSealed) { t.Fatalf("%v, want ErrSealed", err) } if (name == "byte appended" || name == "archive concatenated") && !strings.Contains(err.Error(), "after the final chunk") { t.Fatalf("%v", err) } }) } // A downgraded data key in the manifest is refused. raw, err := appconfig.Open(a.priv, a.b, a.m.SealedDataKey) if err != nil { t.Fatal(err) } v1, _ := appconfig.SealUnbound(a.pub, raw) m := a.m m.SealedDataKey = v1 if err := Verify(bytes.NewReader(a.sealed), m, a.priv, a.b); !errors.Is(err, ErrSealed) { t.Fatalf("v1 key in the manifest: %v", err) } } // An early mismatch must not strand Open's goroutine on the pipe for the // rest of the archive (round-5 finding 12). func TestVerifyReleasesTheProducerOnEarlyMismatch(t *testing.T) { a := threeChunkArchive(t) m := a.m m.Entries = append([]Entry(nil), a.m.Entries...) // "env" is the first regular entry in tar order; its size is off by // one, with Bytes kept consistent so the manifest itself passes. for i := range m.Entries { if m.Entries[i].Path == EnvEntry { m.Entries[i].Size++ m.Bytes++ } } runtime.GC() baseline := runtime.NumGoroutine() err := Verify(bytes.NewReader(a.sealed), m, a.priv, a.b) if err == nil || !strings.Contains(err.Error(), "size differs") { t.Fatalf("early mismatch: %v", err) } deadline := time.Now().Add(time.Second) for runtime.NumGoroutine() > baseline { if time.Now().After(deadline) { t.Fatalf("goroutines %d, baseline %d", runtime.NumGoroutine(), baseline) } time.Sleep(10 * time.Millisecond) } } // Seal and Open agree on the chunk framing at the edges: empty input, and // input of exactly one chunk (whose final chunk is then empty). func TestSealChunkEdges(t *testing.T) { pub, priv, b := keys(t) for _, size := range []int{0, 1, ChunkSize - 1, ChunkSize, ChunkSize + 1, 2 * ChunkSize} { plain := make([]byte, size) rand.Read(plain) var sealed, opened bytes.Buffer key, _, err := Seal(bytes.NewReader(plain), pub, b, &sealed) if err != nil { t.Fatal(err) } if err := Open(bytes.NewReader(sealed.Bytes()), key, priv, b, &opened); err != nil || !bytes.Equal(opened.Bytes(), plain) { t.Fatalf("size %d: %v", size, err) } // Every prefix that ends on a chunk boundary is refused. offs := chunkOffsets(t, sealed.Bytes()) for _, off := range offs { if err := Open(bytes.NewReader(sealed.Bytes()[:off]), key, priv, b, io.Discard); !errors.Is(err, ErrSealed) { t.Fatalf("size %d prefix %d opened: %v", size, off, err) } } } } // The final flag is authenticated, not inferred: a holder of the data key // who frames a short chunk without the flag, or a full chunk with it, is // refused, so the length rule and the flag must agree (§8.2). func TestOpenRequiresTheFinalFlag(t *testing.T) { pub, priv, b := keys(t) var sealed bytes.Buffer key, _, err := Seal(bytes.NewReader([]byte("x")), pub, b, &sealed) if err != nil { t.Fatal(err) } raw, err := appconfig.Open(priv, b, key) if err != nil { t.Fatal(err) } gcm, err := newGCM(raw) if err != nil { t.Fatal(err) } frame := func(index uint64, last bool, pt []byte) []byte { nonce, aad := nonceFor(index, last) ct := gcm.Seal(nil, nonce, pt, aad) return append(binary.BigEndian.AppendUint32(nil, uint32(len(ct))), ct...) } full := make([]byte, ChunkSize) forged := map[string][]byte{ "short chunk unflagged": frame(0, false, []byte("x")), "full chunk flagged": frame(0, true, full), "full flagged, short after": append(frame(0, true, full), frame(1, true, []byte("x"))...), "well formed (control case)": append(frame(0, false, full), frame(1, true, []byte("x"))...), } for name, stream := range forged { err := Open(bytes.NewReader(stream), key, priv, b, io.Discard) if name == "well formed (control case)" { if err != nil { t.Fatalf("%s: %v", name, err) } continue } if !errors.Is(err, ErrSealed) { t.Fatalf("%s opened: %v", name, err) } } } // Fix round 1, ruling 2: a directory named .files-gen is skipped whole, // children included, so it neither reaches the archive nor blocks the // marker file the landing writes; and a manifest cannot name a path under // it. func TestBuildSkipsAMarkerDirectory(t *testing.T) { dir, env := filesFixture(t) if err := os.Remove(filepath.Join(dir, MarkerName)); err != nil { t.Fatal(err) } before, _, _, err := PlainDigest(dir, env) if err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dir, MarkerName, "deeper"), 0o755); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(dir, MarkerName, "child"), []byte("c"), 0o644) mustWrite(t, filepath.Join(dir, MarkerName, "deeper", "x"), []byte("x"), 0o644) plain, entries, _, err := Build(dir, env, io.Discard) if err != nil { t.Fatal(err) } for _, e := range entries { if strings.Contains(e.Path, MarkerName) { t.Fatalf("marker subtree archived: %s", e.Path) } } if plain != before { t.Fatal("a marker directory changed the plain digest") } m := Manifest{V: ManifestVersion, Plain: "p", Sealed: "s", SealedDataKey: "k", Chunk: ChunkSize, Bytes: 1, Entries: []Entry{{Path: "files/.files-gen/child", Size: 1}}} if err := m.check(); !errors.Is(err, ErrBadPath) { t.Fatalf("manifest naming a marker child: %v", err) } } // Fix round 1, rulings 3 and 4: every extracted file and directory is // fsynced, each directory already carrying its archived mode when it is, // then the destination's parent (and env's) after the renames. Restrictive // modes (a directory without read permission, a file without any) do not // stop the sync, and are in place once Extract returns. func TestExtractSyncsEverythingAfterRestoringModes(t *testing.T) { raw := tarZst(t, []rawEntry{ {hdr: tar.Header{Name: "files/locked/", Typeflag: tar.TypeDir, Mode: 0o300}}, {hdr: tar.Header{Name: "files/locked/f", Typeflag: tar.TypeReg, Mode: 0o000, Size: 1}, body: []byte("f")}, {hdr: tar.Header{Name: "files/ro/", Typeflag: tar.TypeDir, Mode: 0o555}}, {hdr: tar.Header{Name: "files/ro/inner/", Typeflag: tar.TypeDir, Mode: 0o500}}, {hdr: tar.Header{Name: "files/ro/inner/g", Typeflag: tar.TypeReg, Mode: 0o400, Size: 1}, body: []byte("g")}, {hdr: tar.Header{Name: "env", Typeflag: tar.TypeReg, Mode: 0o600, Size: 2}, body: []byte("E\n")}, }, nil) parent, files, env := extractTarget(t) t.Cleanup(func() { for _, d := range []string{"locked", "ro/inner", "ro"} { os.Chmod(filepath.Join(files, d), 0o755) } }) type synced struct { mode fs.FileMode dir bool } var got = map[string]synced{} var order []string orig := fsyncFile t.Cleanup(func() { fsyncFile = orig }) var mu sync.Mutex fsyncFile = func(f *os.File) error { info, err := f.Stat() if err != nil { return err } mu.Lock() got[f.Name()] = synced{info.Mode().Perm(), info.IsDir()} order = append(order, f.Name()) mu.Unlock() return orig(f) } if err := Extract(bytes.NewReader(raw), files, env); err != nil { t.Fatal(err) } find := func(suffix string) synced { t.Helper() for name, s := range got { if strings.HasSuffix(filepath.ToSlash(name), suffix) { return s } } t.Fatalf("%s never synced; synced %v", suffix, order) return synced{} } want := map[string]fs.FileMode{"/locked": 0o300, "/ro": 0o555, "/ro/inner": 0o500, "/locked/f": 0o000, "/ro/inner/g": 0o400} for suffix, mode := range want { if s := find(suffix); s.mode != mode { t.Fatalf("%s synced with mode %o, archived %o", suffix, s.mode, mode) } } // The staged root (its mode is the destination's, 0755 when absent), // the env file, and both parents after the renames. stageRoot := false for name, s := range got { if s.dir && strings.Contains(filepath.Base(name), ".out.extract-") && s.mode == 0o755 { stageRoot = true } } if !stageRoot { t.Fatalf("staged root not synced with its final mode: %v", order) } envStaged := false for name, s := range got { if !s.dir && strings.HasPrefix(filepath.Base(name), ".out.env.extract-") && s.mode == 0o600 { envStaged = true } } if !envStaged { t.Fatalf("staged env not synced: %v", order) } if s, ok := got[parent]; !ok || !s.dir { t.Fatalf("destination parent not synced: %v", order) } if s, ok := got[filepath.Dir(env)]; !ok || !s.dir { t.Fatalf("env parent not synced: %v", order) } if order[len(order)-1] != filepath.Dir(env) { t.Fatalf("env parent is not the last sync: %v", order) } // Modes in place on return. for suffix, mode := range want { info, err := os.Lstat(filepath.Join(files, filepath.FromSlash(strings.TrimPrefix(suffix, "/")))) if err != nil || info.Mode().Perm() != mode { t.Fatalf("%s: %v %v", suffix, info, err) } } } // Ruling 4's measurement: serial against parallel fsync on this machine's // disk, for many small files and for one large one. Run by hand: // CARLOS_ARCHIVE_FSYNC_TIMING=1 go test -run TestExtractFsyncTiming -v. func TestExtractFsyncTiming(t *testing.T) { if os.Getenv("CARLOS_ARCHIVE_FSYNC_TIMING") == "" { t.Skip("set CARLOS_ARCHIVE_FSYNC_TIMING=1 to measure") } small := make([]rawEntry, 0, 3000) for i := 0; i < 3000; i++ { small = append(small, reg("files/s"+hex.EncodeToString([]byte{byte(i >> 8), byte(i)}), "z")) } big := make([]byte, 200<<20) rand.Read(big) fixtures := map[string][]byte{ "3000 small files": tarZst(t, small, nil), "one 200 MiB file": tarZst(t, []rawEntry{{hdr: tar.Header{Name: "files/big", Typeflag: tar.TypeReg, Size: int64(len(big))}, body: big}}, nil), } orig := syncWorkers t.Cleanup(func() { syncWorkers = orig }) for _, name := range []string{"3000 small files", "one 200 MiB file"} { for _, workers := range []int{1, 16} { syncWorkers = workers _, files, env := extractTarget(t) start := time.Now() if err := Extract(bytes.NewReader(fixtures[name]), files, env); err != nil { t.Fatal(err) } t.Logf("%s, %2d workers: %v", name, workers, time.Since(start).Round(time.Millisecond)) } } } // Fix round 2: Verify and Extract share one set of stream rules, so a // stream Extract would refuse never passes Verify only to fail at landing. // Every refused stream above is sealed with a manifest that names its // valid regular entries correctly, so only the stream rules can refuse it, // and Verify must give the same refusal Extract does. func TestVerifyRefusesEveryStreamExtractRefuses(t *testing.T) { a := threeChunkArchive(t) all := refusedStreams() for name, entries := range duplicateStreams() { all["duplicate: "+name] = refusedStreamCase{entries, ErrBadPath} } for name, c := range all { t.Run(name, func(t *testing.T) { raw := overCapArchive(t) if c.want != ErrOverCap { raw = tarZst(t, c.entries, nil) } var entries []Entry named := map[string]bool{} for _, e := range c.entries { if e.hdr.Typeflag != tar.TypeReg || named[e.hdr.Name] { continue } if _, _, err := checkName(e.hdr.Name, false); err != nil { continue } named[e.hdr.Name] = true entries = append(entries, Entry{Path: e.hdr.Name, Size: int64(len(e.body)), SHA256: digestBytes(e.body)}) } sealed, m := a.sealRaw(t, raw, entries) if err := m.check(); err != nil { t.Fatalf("fixture manifest refused on its own: %v", err) } if err := Verify(bytes.NewReader(sealed), m, a.priv, a.b); !errors.Is(err, c.want) { t.Fatalf("Verify: %v, want %v", err, c.want) } }) } // The two directory-order shapes, named as the review asked. for name, entries := range map[string][]rawEntry{ "missing parent": {reg("files/a/b", "x")}, "late parent": {reg("files/a/b", "x"), dirEntry("files/a/")}, } { sealed, m := a.sealRaw(t, tarZst(t, entries, nil), []Entry{{Path: "files/a/b", Size: 1, SHA256: digestBytes([]byte("x"))}}) if err := Verify(bytes.NewReader(sealed), m, a.priv, a.b); !errors.Is(err, ErrBadPath) || !strings.Contains(err.Error(), "before its directory") { t.Fatalf("%s: %v", name, err) } } }