check / check (push) Successful in 11m53s
A directory got its stored mode and mtime before its contents were written, so a read-only directory came back without its files and a non-empty one carried the time of the restore. Directories are now created owner-only (0700) and get their stored owner, mode and mtime after the restore loop, each before its parent, skipping any whose place a symlink has since taken. A file's mode is now applied after its chown, which on Linux clears setuid and setgid. A symlink gets its stored owner (as root) and mtime on the link itself, through golang.org/x/sys/unix, now a direct dependency. An interrupted restore leaves its directories at 0700. Model: opus-5-5
350 lines
11 KiB
Go
350 lines
11 KiB
Go
package vaultik //nolint:testpackage // drives unexported restore internals
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// These tests check that restore applies each entry's owner, mode and
|
|
// mtime in an order that keeps them.
|
|
|
|
const (
|
|
readOnlyDirMode = uint32(os.ModeDir | 0o555)
|
|
unsearchableMode = uint32(os.ModeDir | 0o600)
|
|
plainDirMode = uint32(os.ModeDir | 0o755)
|
|
plainFileMode = uint32(0o644)
|
|
setuidFileMode = uint32(os.ModeSetuid | 0o755)
|
|
otherOwnerID = uint32(4321)
|
|
writableTestMode = 0o755
|
|
symlinkTargetPath = "/nonexistent/target"
|
|
memTargetDir = "/restore"
|
|
|
|
// Owner bits a normal user needs on a directory to create an entry
|
|
// in it, and to change an entry in it.
|
|
ownerWriteAndSearch = os.FileMode(0o300)
|
|
ownerSearch = os.FileMode(0o100)
|
|
)
|
|
|
|
// normalUserFs refuses what the kernel refuses a normal user. make test
|
|
// runs as root, which a read-only or unsearchable directory does not
|
|
// stop, so without it the tests below could not fail there. Creating an
|
|
// entry needs owner write and search on the directory holding it;
|
|
// changing an entry's mode or times needs owner search. Only that one
|
|
// directory is checked, not every ancestor.
|
|
type normalUserFs struct {
|
|
afero.Fs
|
|
}
|
|
|
|
//nolint:ireturn // afero.Fs.OpenFile is defined to return the interface
|
|
func (fs normalUserFs) OpenFile(
|
|
name string, flag int, perm os.FileMode,
|
|
) (afero.File, error) {
|
|
if flag&os.O_CREATE != 0 {
|
|
err := fs.checkParent(name, ownerWriteAndSearch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return fs.Fs.OpenFile(name, flag, perm)
|
|
}
|
|
|
|
func (fs normalUserFs) MkdirAll(path string, perm os.FileMode) error {
|
|
_, err := fs.Stat(path)
|
|
if err != nil {
|
|
err = fs.checkParent(path, ownerWriteAndSearch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return fs.Fs.MkdirAll(path, perm)
|
|
}
|
|
|
|
func (fs normalUserFs) Chmod(name string, mode os.FileMode) error {
|
|
err := fs.checkParent(name, ownerSearch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return fs.Fs.Chmod(name, mode)
|
|
}
|
|
|
|
func (fs normalUserFs) Chtimes(name string, atime, mtime time.Time) error {
|
|
err := fs.checkParent(name, ownerSearch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return fs.Fs.Chtimes(name, atime, mtime)
|
|
}
|
|
|
|
// checkParent returns a permission error when the directory holding name
|
|
// exists and its owner bits lack any of need.
|
|
func (fs normalUserFs) checkParent(name string, need os.FileMode) error {
|
|
info, err := fs.Stat(filepath.Dir(name))
|
|
if err == nil && info.Mode().Perm()&need != need {
|
|
return &os.PathError{Op: "access", Path: name, Err: os.ErrPermission}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestRestoreFillsReadOnlyDirectory checks that a read-only directory
|
|
// still receives the entries inside it, and ends with its stored mode.
|
|
func TestRestoreFillsReadOnlyDirectory(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
|
|
rows, repos := makeFiles(ctx, t, []*database.File{
|
|
{Path: "/ro", Mode: readOnlyDirMode},
|
|
{Path: "/ro/file", Mode: plainFileMode},
|
|
{Path: "/ro/sub", Mode: readOnlyDirMode},
|
|
{Path: "/ro/sub/file", Mode: plainFileMode},
|
|
})
|
|
|
|
fs := afero.NewMemMapFs()
|
|
v := newContainmentVaultik(ctx, normalUserFs{Fs: fs})
|
|
_, err := v.restoreAllFiles(rows, repos,
|
|
&RestoreOptions{TargetDir: memTargetDir}, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
for _, path := range []string{"/ro/file", "/ro/sub/file"} {
|
|
_, err := fs.Stat(filepath.Join(memTargetDir, path))
|
|
require.NoErrorf(t, err, "file inside a read-only directory: %s", path)
|
|
}
|
|
|
|
for _, dir := range []string{"/ro", "/ro/sub"} {
|
|
info, err := fs.Stat(filepath.Join(memTargetDir, dir))
|
|
require.NoError(t, err)
|
|
assert.Equalf(t, os.FileMode(0o555), info.Mode().Perm(), "mode of %s", dir)
|
|
}
|
|
}
|
|
|
|
// TestRestoreKeepsNonEmptyDirectoryMTime checks that a directory keeps
|
|
// its stored mtime although entries were written into it. It runs on the
|
|
// real filesystem, where writing an entry changes its directory's mtime.
|
|
func TestRestoreKeepsNonEmptyDirectoryMTime(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
targetDir := t.TempDir()
|
|
mtime := time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC)
|
|
|
|
rows, repos := makeFiles(ctx, t, []*database.File{
|
|
{Path: "/dir", Mode: plainDirMode, MTime: mtime},
|
|
{Path: "/dir/file", Mode: plainFileMode, MTime: mtime},
|
|
})
|
|
|
|
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
|
_, err := v.restoreAllFiles(rows, repos,
|
|
&RestoreOptions{TargetDir: targetDir}, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
info, err := os.Stat(filepath.Join(targetDir, "dir"))
|
|
require.NoError(t, err)
|
|
assert.Truef(t, info.ModTime().Equal(mtime),
|
|
"directory mtime is %s, stored %s", info.ModTime(), mtime)
|
|
}
|
|
|
|
// TestRestoreFinishesChildBeforeUnsearchableParent checks that a
|
|
// directory inside one whose stored mode denies search still gets its
|
|
// own stored mode and mtime.
|
|
func TestRestoreFinishesChildBeforeUnsearchableParent(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
mtime := time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC)
|
|
|
|
rows, repos := makeFiles(ctx, t, []*database.File{
|
|
{Path: "/locked", Mode: unsearchableMode, MTime: mtime},
|
|
{Path: "/locked/sub", Mode: plainDirMode, MTime: mtime},
|
|
})
|
|
|
|
fs := afero.NewMemMapFs()
|
|
v := newContainmentVaultik(ctx, normalUserFs{Fs: fs})
|
|
_, err := v.restoreAllFiles(rows, repos,
|
|
&RestoreOptions{TargetDir: memTargetDir}, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
info, err := fs.Stat(filepath.Join(memTargetDir, "locked"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
|
|
|
info, err = fs.Stat(filepath.Join(memTargetDir, "locked", "sub"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
|
assert.Truef(t, info.ModTime().Equal(mtime),
|
|
"mtime of sub is %s, stored %s", info.ModTime(), mtime)
|
|
}
|
|
|
|
// TestRestoreLeavesSymlinkedDirectoryTargetAlone checks that a directory
|
|
// whose place a later entry takes with a symlink does not hand its stored
|
|
// owner, mode and mtime to whatever the symlink points at. "/d" and "/d/"
|
|
// are different stored paths for the same place on disk.
|
|
func TestRestoreLeavesSymlinkedDirectoryTargetAlone(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
tempDir := t.TempDir()
|
|
targetDir := filepath.Join(tempDir, "target")
|
|
outsideDir := filepath.Join(tempDir, "outside")
|
|
require.NoError(t, os.Mkdir(outsideDir, writableTestMode))
|
|
|
|
before, err := os.Stat(outsideDir)
|
|
require.NoError(t, err)
|
|
|
|
mtime := time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC)
|
|
|
|
rows, repos := makeFiles(ctx, t, []*database.File{
|
|
{
|
|
Path: "/d",
|
|
Mode: readOnlyDirMode,
|
|
UID: otherOwnerID,
|
|
GID: otherOwnerID,
|
|
MTime: mtime,
|
|
},
|
|
{Path: "/d/", LinkTarget: types.FilePath(outsideDir), MTime: mtime},
|
|
})
|
|
|
|
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
|
_, err = v.restoreAllFiles(rows, repos,
|
|
&RestoreOptions{TargetDir: targetDir}, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
after, err := os.Stat(outsideDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, before.Mode(), after.Mode())
|
|
assert.Truef(t, after.ModTime().Equal(before.ModTime()),
|
|
"mtime changed from %s to %s", before.ModTime(), after.ModTime())
|
|
|
|
beforeOwner, ok := before.Sys().(*syscall.Stat_t)
|
|
require.True(t, ok)
|
|
|
|
afterOwner, ok := after.Sys().(*syscall.Stat_t)
|
|
require.True(t, ok)
|
|
assert.Equal(t, beforeOwner.Uid, afterOwner.Uid)
|
|
assert.Equal(t, beforeOwner.Gid, afterOwner.Gid)
|
|
}
|
|
|
|
// TestRestoreKeepsSetuidThroughChown checks that a setuid file keeps the
|
|
// bit when restore changes its owner. Linux clears setuid on any chown of
|
|
// a regular file, even one to its current owner, so the file is recorded
|
|
// with the current user as owner and the session is told it runs as
|
|
// root: the chown then needs no privilege.
|
|
func TestRestoreKeepsSetuidThroughChown(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
targetDir := t.TempDir()
|
|
|
|
info, err := os.Stat(targetDir)
|
|
require.NoError(t, err)
|
|
|
|
owner, ok := info.Sys().(*syscall.Stat_t)
|
|
require.True(t, ok)
|
|
|
|
rows, repos := makeFiles(ctx, t, []*database.File{{
|
|
Path: "/suid",
|
|
Mode: setuidFileMode,
|
|
UID: owner.Uid,
|
|
GID: owner.Gid,
|
|
MTime: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
|
}})
|
|
|
|
session := &restoreSession{
|
|
v: newContainmentVaultik(ctx, afero.NewOsFs()),
|
|
ctx: ctx,
|
|
repos: repos,
|
|
opts: &RestoreOptions{TargetDir: targetDir},
|
|
result: &RestoreResult{},
|
|
runningAsRoot: true,
|
|
}
|
|
require.NoError(t, session.restoreFile(rows[0]))
|
|
|
|
info, err = os.Stat(filepath.Join(targetDir, "suid"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(setuidFileMode),
|
|
info.Mode()&(os.ModeSetuid|os.ModePerm))
|
|
}
|
|
|
|
// TestRestoreSetsSymlinkMTime checks that a restored symlink gets its
|
|
// stored mtime on the link itself. The link dangles, so a call that
|
|
// follows it would fail.
|
|
func TestRestoreSetsSymlinkMTime(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
targetDir := t.TempDir()
|
|
mtime := time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC)
|
|
|
|
rows, repos := makeFiles(ctx, t, []*database.File{{
|
|
Path: "/link",
|
|
LinkTarget: types.FilePath(symlinkTargetPath),
|
|
MTime: mtime,
|
|
}})
|
|
|
|
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
|
_, err := v.restoreAllFiles(rows, repos,
|
|
&RestoreOptions{TargetDir: targetDir}, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
info, err := os.Lstat(filepath.Join(targetDir, "link"))
|
|
require.NoError(t, err)
|
|
assert.Truef(t, info.ModTime().Equal(mtime),
|
|
"symlink mtime is %s, stored %s", info.ModTime(), mtime)
|
|
}
|
|
|
|
// TestRestoreSetsSymlinkOwnerAsRoot checks that a symlink restored as
|
|
// root gets its stored owner on the link itself.
|
|
func TestRestoreSetsSymlinkOwnerAsRoot(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
if os.Geteuid() != 0 {
|
|
t.Skip("giving a file to another user needs root")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
targetDir := t.TempDir()
|
|
|
|
rows, repos := makeFiles(ctx, t, []*database.File{{
|
|
Path: "/link",
|
|
LinkTarget: types.FilePath(symlinkTargetPath),
|
|
UID: otherOwnerID,
|
|
GID: otherOwnerID,
|
|
MTime: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
|
}})
|
|
|
|
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
|
_, err := v.restoreAllFiles(rows, repos,
|
|
&RestoreOptions{TargetDir: targetDir}, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
info, err := os.Lstat(filepath.Join(targetDir, "link"))
|
|
require.NoError(t, err)
|
|
|
|
owner, ok := info.Sys().(*syscall.Stat_t)
|
|
require.True(t, ok)
|
|
assert.Equal(t, otherOwnerID, owner.Uid)
|
|
assert.Equal(t, otherOwnerID, owner.Gid)
|
|
}
|