diff --git a/pkg/chunked/filesystem_linux_test.go b/pkg/chunked/filesystem_linux_test.go new file mode 100644 index 000000000..16a31f13b --- /dev/null +++ b/pkg/chunked/filesystem_linux_test.go @@ -0,0 +1,339 @@ +package chunked + +import ( + "bytes" + "io" + "os" + "path" + "syscall" + "testing" + + "github.com/containers/storage/pkg/archive" + "github.com/containers/storage/pkg/chunked/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type nopCloser struct { + *bytes.Reader +} + +func (nopCloser) Close() error { + return nil +} + +func TestSeekableFileGetBlobAt(t *testing.T) { + content := []byte("Hello, World!") + + br := bytes.NewReader(content) + reader := nopCloser{br} + + sf := newSeekableFile(reader) + chunks := []ImageSourceChunk{ + {Offset: 0, Length: 5}, + {Offset: 7, Length: 5}, + } + + streams, errs, err := sf.GetBlobAt(chunks) + assert.NoError(t, err) + + expectedContents := [][]byte{ + []byte("Hello"), + []byte("World"), + } + + i := 0 + for stream := range streams { + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(stream) + require.NoError(t, err) + + require.Equal(t, expectedContents[i], buf.Bytes()) + i++ + } + + err, ok := <-errs + assert.NoError(t, err) + assert.False(t, ok) +} + +func TestDoHardLink(t *testing.T) { + tmpDir := t.TempDir() + + srcFile := createTempFile(t, tmpDir, "source") + defer srcFile.Close() + srcFd := int(srcFile.Fd()) + + destDir := t.TempDir() + destDirFd, err := syscall.Open(destDir, syscall.O_RDONLY|syscall.O_CLOEXEC, 0) + require.NoError(t, err) + defer syscall.Close(destDirFd) + + destBase := "dest-file" + err = doHardLink(srcFd, destDirFd, destBase) + require.NoError(t, err) + + // an existing file is unlinked first + err = doHardLink(srcFd, destDirFd, destBase) + assert.NoError(t, err) + + err = doHardLink(-1, destDirFd, destBase) + assert.Error(t, err) + + err = doHardLink(srcFd, -1, destBase) + assert.Error(t, err) +} + +func TestAppendHole(t *testing.T) { + tmpDir := t.TempDir() + + tmpFile := createTempFile(t, tmpDir, "file-with-holes") + defer tmpFile.Close() + + fd := int(tmpFile.Fd()) + + size := int64(1024) + + err := appendHole(fd, size) + assert.NoError(t, err, "Appending hole failed") + + fileSize, err := syscall.Seek(fd, 0, io.SeekEnd) + assert.NoError(t, err) + assert.Equal(t, size, fileSize, "File size is not as expected") +} + +func TestSafeMkdir(t *testing.T) { + rootDir := t.TempDir() + dirName := "../dir" + + rootFile, err := os.Open(rootDir) + require.NoError(t, err) + defer rootFile.Close() + + rootFd := int(rootFile.Fd()) + + metadata := fileMetadata{ + FileMetadata: internal.FileMetadata{ + Type: internal.TypeDir, + Mode: 0o755, + }, + } + options := &archive.TarOptions{ + // Allow the test to run without privileges + IgnoreChownErrors: true, + } + + err = safeMkdir(rootFd, 0o755, dirName, &metadata, options) + require.NoError(t, err) + + dir, err := openFileUnderRoot(rootFd, dirName, syscall.O_DIRECTORY, 0) + assert.NoError(t, err) + err = dir.Close() + assert.NoError(t, err) +} + +func TestSafeLink(t *testing.T) { + linkName := "a-hard-link" + + rootDir := t.TempDir() + + rootFile, err := os.Open(rootDir) + require.NoError(t, err) + defer rootFile.Close() + + rootFd := int(rootFile.Fd()) + + file := createTempFile(t, rootDir, "an-existing-file") + existingFile := path.Base(file.Name()) + err = file.Close() + assert.NoError(t, err) + + metadata := fileMetadata{ + FileMetadata: internal.FileMetadata{ + Name: linkName, + // try to create outside the root + Linkname: "../../" + existingFile, + Type: internal.TypeReg, + Mode: 0o755, + }, + } + options := &archive.TarOptions{ + // Allow the test to run without privileges + IgnoreChownErrors: true, + } + + err = safeLink(rootFd, 0o755, &metadata, options) + require.NoError(t, err) + + // validate it was created + newFile, err := openFileUnderRoot(rootFd, linkName, syscall.O_RDONLY, 0) + assert.NoError(t, err) + + st := syscall.Stat_t{} + err = syscall.Fstat(int(newFile.Fd()), &st) + assert.NoError(t, err) + + assert.Equal(t, st.Nlink, uint64(2)) + + err = newFile.Close() + assert.NoError(t, err) +} + +func TestSafeSymlink(t *testing.T) { + linkName := "a-hard-link" + + rootDir := t.TempDir() + + rootFile, err := os.Open(rootDir) + require.NoError(t, err) + defer rootFile.Close() + + rootFd := int(rootFile.Fd()) + + file := createTempFile(t, rootDir, "an-existing-file") + st := syscall.Stat_t{} + err = syscall.Fstat(int(file.Fd()), &st) + assert.NoError(t, err) + + err = file.Close() + assert.NoError(t, err) + + existingFile := path.Base(file.Name()) + + metadata := fileMetadata{ + FileMetadata: internal.FileMetadata{ + Name: linkName, + // try to create outside the root + Linkname: "../../" + existingFile, + Type: internal.TypeReg, + Mode: 0o755, + }, + } + options := &archive.TarOptions{ + // Allow the test to run without privileges + IgnoreChownErrors: true, + } + + err = safeSymlink(rootFd, 0o755, &metadata, options) + require.NoError(t, err) + + // validate it was created + newFile, err := openFileUnderRoot(rootFd, linkName, syscall.O_RDONLY, 0) + assert.NoError(t, err) + + st2 := syscall.Stat_t{} + err = syscall.Fstat(int(newFile.Fd()), &st2) + require.NoError(t, err) + + // validate that the opened file is the same as the original file that was + // created earlier. Compare the inode and device numbers. + assert.Equal(t, st.Dev, st2.Dev) + assert.Equal(t, st.Ino, st2.Ino) + + err = newFile.Close() + assert.NoError(t, err) +} + +func TestOpenOrCreateDirUnderRoot(t *testing.T) { + rootDir := t.TempDir() + dirName := "dir" + + rootFile, err := os.Open(rootDir) + require.NoError(t, err) + defer rootFile.Close() + + rootFd := int(rootFile.Fd()) + + // try to create a directory outside the root + dir, err := openOrCreateDirUnderRoot(rootFd, "../../"+dirName, 0o755) + require.NoError(t, err) + err = dir.Close() + assert.NoError(t, err) + + dir, err = openFileUnderRoot(rootFd, dirName, syscall.O_DIRECTORY, 0) + require.NoError(t, err) + err = dir.Close() + require.NoError(t, err) +} + +func TestCopyFileContent(t *testing.T) { + rootDir := t.TempDir() + + rootFile, err := os.Open(rootDir) + require.NoError(t, err) + defer rootFile.Close() + + rootFd := int(rootFile.Fd()) + + file := createTempFile(t, rootDir, "an-existing-file") + defer file.Close() + + size, err := file.Write([]byte("Hello, World!")) + require.NoError(t, err) + + _, err = file.Seek(0, io.SeekStart) + assert.NoError(t, err) + + st := syscall.Stat_t{} + err = syscall.Fstat(int(file.Fd()), &st) + require.NoError(t, err) + + metadata := fileMetadata{ + FileMetadata: internal.FileMetadata{ + Name: "new-file", + Type: internal.TypeDir, + Mode: 0o755, + }, + } + + newFile, newSize, err := copyFileContent(int(file.Fd()), &metadata, rootFd, 0o755, false) + require.NoError(t, err) + + assert.Equal(t, size, int(newSize)) + + st2 := syscall.Stat_t{} + err = syscall.Fstat(int(newFile.Fd()), &st2) + require.NoError(t, err) + + err = newFile.Close() + require.NoError(t, err) + + // the file was copied without hard links, the inodes must be different + assert.Equal(t, st.Dev, st2.Dev) + assert.NotEqual(t, st.Ino, st2.Ino) + + metadataCopyHardLinks := fileMetadata{ + FileMetadata: internal.FileMetadata{ + Name: "new-file2", + Type: internal.TypeDir, + Mode: 0o755, + }, + } + + newFile, newSize, err = copyFileContent(int(file.Fd()), &metadataCopyHardLinks, rootFd, 0o755, true) + require.NoError(t, err) + assert.Nil(t, newFile) + + // validate it was created as an inode + newFile, err = openFileUnderRoot(rootFd, metadataCopyHardLinks.FileMetadata.Name, syscall.O_RDONLY, 0) + require.NoError(t, err) + + assert.Equal(t, size, int(newSize)) + + st2 = syscall.Stat_t{} + err = syscall.Fstat(int(newFile.Fd()), &st2) + require.NoError(t, err) + + err = newFile.Close() + require.NoError(t, err) + + // the file was copied with hard links, the inodes must be equal + assert.Equal(t, st.Dev, st2.Dev) + assert.Equal(t, st.Ino, st2.Ino) +} + +func createTempFile(t *testing.T, dir, name string) *os.File { + tmpFile, err := os.CreateTemp(dir, name) + require.NoError(t, err) + return tmpFile +}