moby/pkg/archive/archive_unix_test.go
Cory Snider 6a8a792019 pkg/archive: test tar headers are interoperable
The existing pkg/archive unit tests are primarily round-trip tests which
assert that pkg/archive produces tarballs which pkg/archive can unpack.
While these tests are effective at catching regressions in archiving or
unarchiving, they have a blind spot for regressions in compatibility
with the rest of the ecosystem. For example, a typo in the capabilities
extended attribute constant would result in subtly broken image layer
tarballs, but the existing tests would not catch the bug if both the
archiving and unarchiving implementations have the same typo.

Extend the test for archiving an overlay filesystem layer to assert that
the overlayfs style whiteouts (extended attributes and device files) are
transformed into AUFS-style whiteouts (magic file names).

Extend the test for archiving files with extended attributes to assert
that the extended attribute is encoded into the file's tar header in the
standard, interoperable format compatible with the rest of the
ecosystem.

Signed-off-by: Cory Snider <csnider@mirantis.com>
2023-10-23 16:21:41 -04:00

352 lines
11 KiB
Go

//go:build !windows
package archive // import "github.com/docker/docker/pkg/archive"
import (
"archive/tar"
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/containerd/containerd/pkg/userns"
"github.com/docker/docker/pkg/system"
"golang.org/x/sys/unix"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/skip"
)
func TestCanonicalTarName(t *testing.T) {
cases := []struct {
in string
isDir bool
expected string
}{
{"foo", false, "foo"},
{"foo", true, "foo/"},
{"foo/bar", false, "foo/bar"},
{"foo/bar", true, "foo/bar/"},
}
for _, v := range cases {
if canonicalTarName(v.in, v.isDir) != v.expected {
t.Fatalf("wrong canonical tar name. expected:%s got:%s", v.expected, canonicalTarName(v.in, v.isDir))
}
}
}
func TestChmodTarEntry(t *testing.T) {
cases := []struct {
in, expected os.FileMode
}{
{0o000, 0o000},
{0o777, 0o777},
{0o644, 0o644},
{0o755, 0o755},
{0o444, 0o444},
}
for _, v := range cases {
if out := chmodTarEntry(v.in); out != v.expected {
t.Fatalf("wrong chmod. expected:%v got:%v", v.expected, out)
}
}
}
func TestTarWithHardLink(t *testing.T) {
origin, err := os.MkdirTemp("", "docker-test-tar-hardlink")
assert.NilError(t, err)
defer os.RemoveAll(origin)
err = os.WriteFile(filepath.Join(origin, "1"), []byte("hello world"), 0o700)
assert.NilError(t, err)
err = os.Link(filepath.Join(origin, "1"), filepath.Join(origin, "2"))
assert.NilError(t, err)
var i1, i2 uint64
i1, err = getNlink(filepath.Join(origin, "1"))
assert.NilError(t, err)
// sanity check that we can hardlink
if i1 != 2 {
t.Skipf("skipping since hardlinks don't work here; expected 2 links, got %d", i1)
}
dest, err := os.MkdirTemp("", "docker-test-tar-hardlink-dest")
assert.NilError(t, err)
defer os.RemoveAll(dest)
// we'll do this in two steps to separate failure
fh, err := Tar(origin, Uncompressed)
assert.NilError(t, err)
// ensure we can read the whole thing with no error, before writing back out
buf, err := io.ReadAll(fh)
assert.NilError(t, err)
bRdr := bytes.NewReader(buf)
err = Untar(bRdr, dest, &TarOptions{Compression: Uncompressed})
assert.NilError(t, err)
i1, err = getInode(filepath.Join(dest, "1"))
assert.NilError(t, err)
i2, err = getInode(filepath.Join(dest, "2"))
assert.NilError(t, err)
assert.Check(t, is.Equal(i1, i2))
}
func TestTarWithHardLinkAndRebase(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "docker-test-tar-hardlink-rebase")
assert.NilError(t, err)
defer os.RemoveAll(tmpDir)
origin := filepath.Join(tmpDir, "origin")
err = os.Mkdir(origin, 0o700)
assert.NilError(t, err)
err = os.WriteFile(filepath.Join(origin, "1"), []byte("hello world"), 0o700)
assert.NilError(t, err)
err = os.Link(filepath.Join(origin, "1"), filepath.Join(origin, "2"))
assert.NilError(t, err)
var i1, i2 uint64
i1, err = getNlink(filepath.Join(origin, "1"))
assert.NilError(t, err)
// sanity check that we can hardlink
if i1 != 2 {
t.Skipf("skipping since hardlinks don't work here; expected 2 links, got %d", i1)
}
dest := filepath.Join(tmpDir, "dest")
bRdr, err := TarResourceRebase(origin, "origin")
assert.NilError(t, err)
dstDir, srcBase := SplitPathDirEntry(origin)
_, dstBase := SplitPathDirEntry(dest)
content := RebaseArchiveEntries(bRdr, srcBase, dstBase)
err = Untar(content, dstDir, &TarOptions{Compression: Uncompressed, NoLchown: true, NoOverwriteDirNonDir: true})
assert.NilError(t, err)
i1, err = getInode(filepath.Join(dest, "1"))
assert.NilError(t, err)
i2, err = getInode(filepath.Join(dest, "2"))
assert.NilError(t, err)
assert.Check(t, is.Equal(i1, i2))
}
// TestUntarParentPathPermissions is a regression test to check that missing
// parent directories are created with the expected permissions
func TestUntarParentPathPermissions(t *testing.T) {
skip.If(t, os.Getuid() != 0, "skipping test that requires root")
buf := &bytes.Buffer{}
w := tar.NewWriter(buf)
err := w.WriteHeader(&tar.Header{Name: "foo/bar"})
assert.NilError(t, err)
tmpDir, err := os.MkdirTemp("", t.Name())
assert.NilError(t, err)
defer os.RemoveAll(tmpDir)
err = Untar(buf, tmpDir, nil)
assert.NilError(t, err)
fi, err := os.Lstat(filepath.Join(tmpDir, "foo"))
assert.NilError(t, err)
assert.Equal(t, fi.Mode(), 0o755|os.ModeDir)
}
func getNlink(path string) (uint64, error) {
stat, err := os.Stat(path)
if err != nil {
return 0, err
}
statT, ok := stat.Sys().(*syscall.Stat_t)
if !ok {
return 0, fmt.Errorf("expected type *syscall.Stat_t, got %t", stat.Sys())
}
// We need this conversion on ARM64
//nolint: unconvert
return uint64(statT.Nlink), nil
}
func getInode(path string) (uint64, error) {
stat, err := os.Stat(path)
if err != nil {
return 0, err
}
statT, ok := stat.Sys().(*syscall.Stat_t)
if !ok {
return 0, fmt.Errorf("expected type *syscall.Stat_t, got %t", stat.Sys())
}
return statT.Ino, nil
}
func TestTarWithBlockCharFifo(t *testing.T) {
skip.If(t, os.Getuid() != 0, "skipping test that requires root")
skip.If(t, userns.RunningInUserNS(), "skipping test that requires initial userns")
origin, err := os.MkdirTemp("", "docker-test-tar-hardlink")
assert.NilError(t, err)
defer os.RemoveAll(origin)
err = os.WriteFile(filepath.Join(origin, "1"), []byte("hello world"), 0o700)
assert.NilError(t, err)
err = system.Mknod(filepath.Join(origin, "2"), unix.S_IFBLK, int(system.Mkdev(int64(12), int64(5))))
assert.NilError(t, err)
err = system.Mknod(filepath.Join(origin, "3"), unix.S_IFCHR, int(system.Mkdev(int64(12), int64(5))))
assert.NilError(t, err)
err = system.Mknod(filepath.Join(origin, "4"), unix.S_IFIFO, int(system.Mkdev(int64(12), int64(5))))
assert.NilError(t, err)
dest, err := os.MkdirTemp("", "docker-test-tar-hardlink-dest")
assert.NilError(t, err)
defer os.RemoveAll(dest)
// we'll do this in two steps to separate failure
fh, err := Tar(origin, Uncompressed)
assert.NilError(t, err)
// ensure we can read the whole thing with no error, before writing back out
buf, err := io.ReadAll(fh)
assert.NilError(t, err)
bRdr := bytes.NewReader(buf)
err = Untar(bRdr, dest, &TarOptions{Compression: Uncompressed})
assert.NilError(t, err)
changes, err := ChangesDirs(origin, dest)
assert.NilError(t, err)
if len(changes) > 0 {
t.Fatalf("Tar with special device (block, char, fifo) should keep them (recreate them when untar) : %v", changes)
}
}
// TestTarUntarWithXattr is Unix as Lsetxattr is not supported on Windows
func TestTarUntarWithXattr(t *testing.T) {
skip.If(t, os.Getuid() != 0, "skipping test that requires root")
if _, err := exec.LookPath("setcap"); err != nil {
t.Skip("setcap not installed")
}
if _, err := exec.LookPath("getcap"); err != nil {
t.Skip("getcap not installed")
}
origin, err := os.MkdirTemp("", "docker-test-untar-origin")
assert.NilError(t, err)
defer os.RemoveAll(origin)
err = os.WriteFile(filepath.Join(origin, "1"), []byte("hello world"), 0o700)
assert.NilError(t, err)
err = os.WriteFile(filepath.Join(origin, "2"), []byte("welcome!"), 0o700)
assert.NilError(t, err)
err = os.WriteFile(filepath.Join(origin, "3"), []byte("will be ignored"), 0o700)
assert.NilError(t, err)
// there is no known Go implementation of setcap/getcap with support for v3 file capability
out, err := exec.Command("setcap", "cap_block_suspend+ep", filepath.Join(origin, "2")).CombinedOutput()
assert.NilError(t, err, string(out))
tarball, err := Tar(origin, Uncompressed)
assert.NilError(t, err)
defer tarball.Close()
rdr := tar.NewReader(tarball)
for {
h, err := rdr.Next()
if err == io.EOF {
break
}
assert.NilError(t, err)
capability, hasxattr := h.PAXRecords["SCHILY.xattr.security.capability"]
switch h.Name {
case "2":
if assert.Check(t, hasxattr, "tar entry %q should have the 'security.capability' xattr", h.Name) {
assert.Check(t, len(capability) > 0, "tar entry %q has a blank 'security.capability' xattr value")
}
default:
assert.Check(t, !hasxattr, "tar entry %q should not have the 'security.capability' xattr", h.Name)
}
}
for _, c := range []Compression{
Uncompressed,
Gzip,
} {
changes, err := tarUntar(t, origin, &TarOptions{
Compression: c,
ExcludePatterns: []string{"3"},
})
if err != nil {
t.Fatalf("Error tar/untar for compression %s: %s", c.Extension(), err)
}
if len(changes) != 1 || changes[0].Path != "/3" {
t.Fatalf("Unexpected differences after tarUntar: %v", changes)
}
out, err := exec.Command("getcap", filepath.Join(origin, "2")).CombinedOutput()
assert.NilError(t, err, string(out))
assert.Check(t, is.Contains(string(out), "cap_block_suspend=ep"), "untar should have kept the 'security.capability' xattr")
}
}
func TestCopyInfoDestinationPathSymlink(t *testing.T) {
tmpDir, _ := getTestTempDirs(t)
defer removeAllPaths(tmpDir)
root := strings.TrimRight(tmpDir, "/") + "/"
type FileTestData struct {
resource FileData
file string
expected CopyInfo
}
testData := []FileTestData{
// Create a directory: /tmp/archive-copy-test*/dir1
// Test will "copy" file1 to dir1
{resource: FileData{filetype: Dir, path: "dir1", permissions: 0o740}, file: "file1", expected: CopyInfo{Path: root + "dir1/file1", Exists: false, IsDir: false}},
// Create a symlink directory to dir1: /tmp/archive-copy-test*/dirSymlink -> dir1
// Test will "copy" file2 to dirSymlink
{resource: FileData{filetype: Symlink, path: "dirSymlink", contents: root + "dir1", permissions: 0o600}, file: "file2", expected: CopyInfo{Path: root + "dirSymlink/file2", Exists: false, IsDir: false}},
// Create a file in tmp directory: /tmp/archive-copy-test*/file1
// Test to cover when the full file path already exists.
{resource: FileData{filetype: Regular, path: "file1", permissions: 0o600}, file: "", expected: CopyInfo{Path: root + "file1", Exists: true}},
// Create a directory: /tmp/archive-copy*/dir2
// Test to cover when the full directory path already exists
{resource: FileData{filetype: Dir, path: "dir2", permissions: 0o740}, file: "", expected: CopyInfo{Path: root + "dir2", Exists: true, IsDir: true}},
// Create a symlink to a non-existent target: /tmp/archive-copy*/symlink1 -> noSuchTarget
// Negative test to cover symlinking to a target that does not exit
{resource: FileData{filetype: Symlink, path: "symlink1", contents: "noSuchTarget", permissions: 0o600}, file: "", expected: CopyInfo{Path: root + "noSuchTarget", Exists: false}},
// Create a file in tmp directory for next test: /tmp/existingfile
{resource: FileData{filetype: Regular, path: "existingfile", permissions: 0o600}, file: "", expected: CopyInfo{Path: root + "existingfile", Exists: true}},
// Create a symlink to an existing file: /tmp/archive-copy*/symlink2 -> /tmp/existingfile
// Test to cover when the parent directory of a new file is a symlink
{resource: FileData{filetype: Symlink, path: "symlink2", contents: "existingfile", permissions: 0o600}, file: "", expected: CopyInfo{Path: root + "existingfile", Exists: true}},
}
var dirs []FileData
for _, data := range testData {
dirs = append(dirs, data.resource)
}
provisionSampleDir(t, tmpDir, dirs)
for _, info := range testData {
p := filepath.Join(tmpDir, info.resource.path, info.file)
ci, err := CopyInfoDestinationPath(p)
assert.Check(t, err)
assert.Check(t, is.DeepEqual(info.expected, ci))
}
}