Lock the state directory and write vault files atomically (closes #34)
check / check (push) Successful in 1m31s
check / check (push) Successful in 1m31s
Each command that changes the state directory holds one lock: flock(2) on `lock` in the state directory, dropped by the kernel if the process dies, or a process-wide mutex on the in-memory test filesystem. It covers the state directory, not each vault, because `currentvault`, `vault create` and cross-vault moves span vaults, and a lock file in a vault would be deleted by `vault remove` under a waiting command. Files go through `secret.WriteFileAtomic`; versions, new secrets and cross-vault copies are built in a temporary directory and renamed into place; removals rename out of the way first. Left for later: replacing an unlocker (#71) and deleting what an interrupted command leaves under a `.tmp-` name (#75). Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #69.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
||||
// a crash at any moment, finds either the old content or the new, never a
|
||||
// partial file. The data goes into a temporary file that afero.TempFile
|
||||
// creates with mode 0600 in the same directory (a rename is only atomic
|
||||
// within one filesystem), is synced to disk, and is renamed over path. The
|
||||
// temporary file is removed if any step fails.
|
||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
||||
"."+filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
||||
}
|
||||
|
||||
_, err = tmp.Write(data)
|
||||
if err == nil {
|
||||
err = tmp.Sync()
|
||||
}
|
||||
|
||||
closeErr := tmp.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = fs.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = fs.Remove(tmp.Name())
|
||||
|
||||
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TempDirFor creates an empty temporary directory in which to build the
|
||||
// directory target before renaming it into place, or into which to move
|
||||
// target before deleting it. It is made in target's grandparent: on the
|
||||
// same filesystem, so the rename is atomic, and outside target's parent,
|
||||
// the directory that is listed to find vaults, secrets, versions and
|
||||
// unlockers, so one left behind by a crash is never taken for one of them.
|
||||
// Its name leaves out target's, which may already be as long as a file name
|
||||
// can be.
|
||||
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
||||
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to create temporary directory for %s: %w", target, err)
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
||||
// rename: dir is moved into a new directory from TempDirFor, which is then
|
||||
// deleted. A crash part-way leaves only that temporary directory behind.
|
||||
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
||||
tmp, err := TempDirFor(fs, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = fs.Rename(dir, filepath.Join(tmp, filepath.Base(dir)))
|
||||
if err != nil {
|
||||
_ = fs.Remove(tmp)
|
||||
|
||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
||||
}
|
||||
|
||||
err = fs.RemoveAll(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var errInjected = errors.New("injected failure")
|
||||
|
||||
// The kinds of change hookFs passes to before.
|
||||
const (
|
||||
opCreate = "create"
|
||||
opOpen = "open"
|
||||
opSync = "sync"
|
||||
opMkdir = "mkdir"
|
||||
opRemove = "remove"
|
||||
opRename = "rename"
|
||||
)
|
||||
|
||||
// currentFile is the file in a secret's directory that names its current
|
||||
// version.
|
||||
const currentFile = "current"
|
||||
|
||||
// unlockerMetadataFile is the file a new unlocker writes last.
|
||||
const unlockerMetadataFile = "unlocker-metadata.json"
|
||||
|
||||
// unlockerPassphrase protects the passphrase unlockers the tests create.
|
||||
//
|
||||
//nolint:gosec // G101: test data, not a real credential
|
||||
const unlockerPassphrase = "unlocker passphrase"
|
||||
|
||||
// hookFs passes every call through to Fs, but first calls before for each
|
||||
// call that changes the filesystem, and for each Sync of a file opened
|
||||
// through it, with the path it changes (the new path, for Rename). A test
|
||||
// uses before to inspect the tree at every point where a crash could stop
|
||||
// the code under test, or returns an error from it to make that call fail.
|
||||
// If opened is set, OpenFile also tells it the mode it opens each file with.
|
||||
type hookFs struct {
|
||||
afero.Fs
|
||||
|
||||
before func(op, path string) error
|
||||
opened func(path string, perm os.FileMode)
|
||||
}
|
||||
|
||||
// hookFile is a file opened through hookFs.
|
||||
type hookFile struct {
|
||||
afero.File
|
||||
|
||||
before func(op, path string) error
|
||||
}
|
||||
|
||||
func (f hookFile) Sync() error {
|
||||
err := f.before(opSync, f.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.File.Sync()
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements afero.Fs
|
||||
func (h hookFs) Create(name string) (afero.File, error) {
|
||||
err := h.before(opCreate, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := h.Fs.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hookFile{File: file, before: h.before}, nil
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements afero.Fs
|
||||
func (h hookFs) OpenFile(
|
||||
name string, flag int, perm os.FileMode,
|
||||
) (afero.File, error) {
|
||||
err := h.before(opOpen, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.opened != nil {
|
||||
h.opened(name, perm)
|
||||
}
|
||||
|
||||
file, err := h.Fs.OpenFile(name, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hookFile{File: file, before: h.before}, nil
|
||||
}
|
||||
|
||||
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
||||
err := h.before(opMkdir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.Mkdir(name, perm)
|
||||
}
|
||||
|
||||
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
||||
err := h.before(opMkdir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
func (h hookFs) Remove(name string) error {
|
||||
err := h.before(opRemove, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.Remove(name)
|
||||
}
|
||||
|
||||
func (h hookFs) RemoveAll(path string) error {
|
||||
err := h.before(opRemove, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.RemoveAll(path)
|
||||
}
|
||||
|
||||
func (h hookFs) Rename(oldname, newname string) error {
|
||||
err := h.before(opRename, newname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.Rename(oldname, newname)
|
||||
}
|
||||
|
||||
// testFilesystem is a filesystem to run a test on, with a directory in it
|
||||
// to work in.
|
||||
type testFilesystem struct {
|
||||
name string
|
||||
open func(t *testing.T) (afero.Fs, string)
|
||||
}
|
||||
|
||||
// testFilesystems are the in-memory filesystem that most tests use and the
|
||||
// real one: every rename-based guarantee is checked on both.
|
||||
//
|
||||
//nolint:gochecknoglobals // read-only table shared by the tests below
|
||||
var testFilesystems = []testFilesystem{
|
||||
{"memory", func(*testing.T) (afero.Fs, string) {
|
||||
return afero.NewMemMapFs(), "/test"
|
||||
}},
|
||||
{"real", func(t *testing.T) (afero.Fs, string) {
|
||||
t.Helper()
|
||||
|
||||
return afero.NewOsFs(), t.TempDir()
|
||||
}},
|
||||
}
|
||||
|
||||
// dirNames lists the names in dir.
|
||||
func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
|
||||
t.Helper()
|
||||
|
||||
entries, err := afero.ReadDir(fs, dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// writeLongTermKey gives the test vault under stateDir a new long-term key
|
||||
// and returns it.
|
||||
func writeLongTermKey(
|
||||
t *testing.T, fs afero.Fs, stateDir string,
|
||||
) *age.X25519Identity {
|
||||
t.Helper()
|
||||
|
||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
||||
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fs.MkdirAll(vaultDir, 0o700))
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
||||
[]byte(ltIdentity.Recipient().String()), 0o600))
|
||||
|
||||
return ltIdentity
|
||||
}
|
||||
|
||||
// newVaultWithSecret creates the vault name under stateDir from the test
|
||||
// mnemonic, with a secret "shared" in it that holds value.
|
||||
func newVaultWithSecret(
|
||||
t *testing.T, fs afero.Fs, stateDir, name, value string,
|
||||
) *vault.Vault {
|
||||
t.Helper()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, stateDir, name)
|
||||
require.NoError(t, err)
|
||||
|
||||
buffer := memguard.NewBufferFromBytes([]byte(value))
|
||||
defer buffer.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("shared", buffer, false))
|
||||
|
||||
return vlt
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicReplacesFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs, dir := tfs.open(t)
|
||||
path := filepath.Join(dir, currentFile)
|
||||
|
||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("old")))
|
||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("new")))
|
||||
|
||||
data, err := afero.ReadFile(fs, path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new", string(data))
|
||||
|
||||
info, err := fs.Stat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, secret.FilePerms, info.Mode().Perm())
|
||||
|
||||
// No temporary file is left next to it
|
||||
assert.Equal(t, []string{currentFile}, dirNames(t, fs, dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicFailureKeepsOldFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, dir := tfs.open(t)
|
||||
path := filepath.Join(dir, currentFile)
|
||||
require.NoError(t, secret.WriteFileAtomic(base, path, []byte("old")))
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
||||
if op == opRename {
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
err := secret.WriteFileAtomic(fs, path, []byte("new"))
|
||||
require.ErrorIs(t, err, errInjected)
|
||||
|
||||
data, err := afero.ReadFile(base, path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "old", string(data))
|
||||
|
||||
// The temporary file is removed again
|
||||
assert.Equal(t, []string{currentFile}, dirNames(t, base, dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveDirAtomic checks that RemoveDirAtomic deletes nothing where the
|
||||
// directory stands, which a crash could stop half-way, and that it leaves
|
||||
// nothing behind.
|
||||
func TestRemoveDirAtomic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, dir := tfs.open(t)
|
||||
listed := filepath.Join(dir, "secrets.d")
|
||||
target := filepath.Join(listed, "doomed")
|
||||
|
||||
require.NoError(t, base.MkdirAll(filepath.Join(target, "versions"), 0o700))
|
||||
require.NoError(t, secret.WriteFileAtomic(base,
|
||||
filepath.Join(target, currentFile), []byte("20231216.001")))
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRemove && strings.HasPrefix(path, target) {
|
||||
t.Errorf("deleted %s where it stands", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
require.NoError(t, secret.RemoveDirAtomic(fs, target))
|
||||
|
||||
// Gone, and no temporary directory is left in the directory
|
||||
// that is listed or in the one above it
|
||||
assert.Empty(t, dirNames(t, base, listed))
|
||||
assert.Equal(t, []string{"secrets.d"}, dirNames(t, base, dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongestNames adds a secret to a vault and removes the vault, both
|
||||
// named with 255 bytes, the most a file name may have, on the real
|
||||
// filesystem: the temporary directories they use must fit that limit too.
|
||||
func TestLongestNames(t *testing.T) {
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
const longestName = 255
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
name := strings.Repeat("a", longestName)
|
||||
|
||||
vlt, err := vault.CreateVault(fs, t.TempDir(), name)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("long"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret(name, value, false))
|
||||
|
||||
got, err := vlt.GetSecret(name)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "long", string(got))
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, secret.RemoveDirAtomic(fs, vaultDir))
|
||||
assert.NoDirExists(t, vaultDir)
|
||||
}
|
||||
|
||||
// TestForcedCopyKeepsDestinationUntilReplaced copies a secret over one in
|
||||
// another vault, as a forced move between vaults does, and makes the last
|
||||
// step that completes the copy fail. The secret it was to replace must
|
||||
// still be there unchanged: it may go only once its replacement is whole.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv forbids t.Parallel
|
||||
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
base, stateDir := tfs.open(t)
|
||||
src := newVaultWithSecret(t, base, stateDir, "source", "new")
|
||||
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
|
||||
|
||||
// The copy is complete once its current file is written
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRename && filepath.Base(path) == currentFile {
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
err := vault.NewVault(fs, stateDir, "dest").
|
||||
CopySecretAllVersions(src, "shared", "shared", true)
|
||||
require.ErrorIs(t, err, errInjected)
|
||||
|
||||
value, err := dest.GetSecret("shared")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "old", string(value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempDirsStayOutOfListings adds a version, adds a secret, copies a
|
||||
// secret over another and removes one, and checks that none of them makes a
|
||||
// directory directly in secrets.d or in a versions directory. Those are
|
||||
// listed to find secrets and versions, so a temporary directory made there
|
||||
// would be listed while half-built, and one left by a crash would stay.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv forbids t.Parallel
|
||||
func TestTempDirsStayOutOfListings(t *testing.T) {
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
base, stateDir := tfs.open(t)
|
||||
newVaultWithSecret(t, base, stateDir, "default", "first")
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
parent := filepath.Base(filepath.Dir(path))
|
||||
if op == opMkdir && (parent == "secrets.d" || parent == "versions") {
|
||||
t.Errorf("made %s where it is listed", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
vlt := vault.NewVault(fs, stateDir, "default")
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("second"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("shared", value, true))
|
||||
require.NoError(t, vlt.AddSecret("other", value, false))
|
||||
require.NoError(t, vlt.CopySecretAllVersions(vlt, "shared", "other", true))
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, secret.RemoveDirAtomic(fs,
|
||||
filepath.Join(vaultDir, "secrets.d", "shared")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionSaveIsWholeOrAbsent checks, before every change Save makes and
|
||||
// once after it returns, that the version directory either does not exist
|
||||
// or holds all of its files: a crash at any point leaves no version that
|
||||
// cannot be decrypted.
|
||||
func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, stateDir := tfs.open(t)
|
||||
ltIdentity := writeLongTermKey(t, base, stateDir)
|
||||
|
||||
var versionDir string
|
||||
|
||||
checkVersionDir := func(string, string) error {
|
||||
exists, err := afero.DirExists(base, versionDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
if exists {
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"pub.age", "value.age", "priv.age", "metadata.age"},
|
||||
dirNames(t, base, versionDir),
|
||||
"version directory visible before it was complete")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fs := hookFs{Fs: base, before: checkVersionDir}
|
||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
versionDir = sv.Directory
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("whole or nothing"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, sv.Save(value))
|
||||
require.NoError(t, checkVersionDir("", ""))
|
||||
|
||||
got, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer got.Destroy()
|
||||
|
||||
assert.Equal(t, "whole or nothing", got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionSaveFailureLeavesNothing makes the write of the encrypted
|
||||
// private key fail, after the value has been written, and checks that
|
||||
// neither the version nor its temporary directory is left behind.
|
||||
func TestVersionSaveFailureLeavesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, stateDir := tfs.open(t)
|
||||
writeLongTermKey(t, base, stateDir)
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRename && filepath.Base(path) == "priv.age" {
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("never stored"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.ErrorIs(t, sv.Save(value), errInjected)
|
||||
|
||||
// The secret directory holds only the empty versions directory
|
||||
versionsDir := filepath.Dir(sv.Directory)
|
||||
assert.Equal(t, []string{"versions"},
|
||||
dirNames(t, base, filepath.Dir(versionsDir)))
|
||||
assert.Empty(t, dirNames(t, base, versionsDir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentFilesNeverMissing selects the current version, vault and
|
||||
// unlocker again and checks, before each change this makes, that the file
|
||||
// naming the current one exists: a reader or a crash never finds it
|
||||
// missing.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv forbids t.Parallel
|
||||
func TestCurrentFilesNeverMissing(t *testing.T) {
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
base, stateDir := tfs.open(t)
|
||||
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
// Created as the current unlocker
|
||||
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "shared")
|
||||
version, err := secret.GetCurrentVersion(base, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
reselect func(fs afero.Fs) error
|
||||
}{
|
||||
{filepath.Join(secretDir, currentFile), func(fs afero.Fs) error {
|
||||
return secret.SetCurrentVersion(fs, secretDir, version)
|
||||
}},
|
||||
{filepath.Join(stateDir, "currentvault"), func(fs afero.Fs) error {
|
||||
return vault.SelectVault(fs, stateDir, testVaultName)
|
||||
}},
|
||||
{filepath.Join(vaultDir, "current-unlocker"), func(fs afero.Fs) error {
|
||||
return vault.NewVault(fs, stateDir, testVaultName).
|
||||
SelectUnlocker(unlocker.GetID())
|
||||
}},
|
||||
} {
|
||||
fs := hookFs{Fs: base, before: func(string, string) error {
|
||||
exists, err := afero.Exists(base, tc.path)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "%s is missing", filepath.Base(tc.path))
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
require.NoError(t, tc.reselect(fs))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFileAtomicTempFile checks that WriteFileAtomic creates its
|
||||
// temporary file with mode 0600, rather than wider and narrowed later, so
|
||||
// that no other user can ever read it, and syncs it before renaming it into
|
||||
// place, so that a crash cannot leave the file named but its data lost.
|
||||
func TestWriteFileAtomicTempFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, dir := tfs.open(t)
|
||||
|
||||
var modes []os.FileMode
|
||||
|
||||
synced := false
|
||||
fs := hookFs{
|
||||
Fs: base,
|
||||
before: func(op, _ string) error {
|
||||
switch op {
|
||||
case opSync:
|
||||
synced = true
|
||||
case opRename:
|
||||
assert.True(t, synced, "renamed before syncing")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
opened: func(_ string, perm os.FileMode) {
|
||||
modes = append(modes, perm)
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, secret.WriteFileAtomic(fs,
|
||||
filepath.Join(dir, currentFile), []byte("new")))
|
||||
assert.Equal(t, []os.FileMode{secret.FilePerms}, modes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPassphraseUnlockerGetsKeyFirst creates a passphrase unlocker in a
|
||||
// vault whose long-term key cannot be had: it must fail without writing
|
||||
// anything, so that it never leaves a partial unlocker, nor breaks the one
|
||||
// it would replace.
|
||||
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
||||
// No mnemonic, and no current unlocker to get the key from
|
||||
t.Setenv(secret.EnvMnemonic, "")
|
||||
|
||||
base := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||
t.Errorf("changed %s before getting the long-term key", path)
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
|
||||
CreatePassphraseUnlocker(passphrase)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestPassphraseUnlockerWritesMetadataLast checks that the last file a new
|
||||
// passphrase unlocker writes in its directory is its metadata: an unlocker
|
||||
// directory without metadata is never used, so one interrupted earlier
|
||||
// cannot be.
|
||||
func TestPassphraseUnlockerWritesMetadataLast(t *testing.T) {
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
base := afero.NewMemMapFs()
|
||||
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
|
||||
|
||||
var last string
|
||||
|
||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||
if filepath.Dir(path) == unlockerDir {
|
||||
last = filepath.Base(path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
|
||||
CreatePassphraseUnlocker(passphrase)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unlockerMetadataFile, last)
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func (k *KeychainUnlocker) Remove() error {
|
||||
|
||||
// Step 3: Remove directory
|
||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
||||
if err := RemoveDirAtomic(k.fs, k.Directory); err != nil {
|
||||
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
||||
|
||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||
@@ -368,7 +368,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
// Step 3: Store age recipient as plaintext
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||
if err := WriteFileAtomic(fs, recipientPath, []byte(ageRecipient)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
}
|
||||
|
||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||
if err := WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
|
||||
// Write encrypted long-term private key
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||
if err := WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -440,9 +440,9 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := afero.WriteFile(fs,
|
||||
if err := WriteFileAtomic(fs,
|
||||
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||
metadataBytes, FilePerms); err != nil {
|
||||
metadataBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ func (p *PassphraseUnlocker) Remove() error {
|
||||
|
||||
// For passphrase unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
err := p.fs.RemoveAll(p.Directory)
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func (p *PGPUnlocker) GetID() string {
|
||||
func (p *PGPUnlocker) Remove() error {
|
||||
// For PGP unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
err := p.fs.RemoveAll(p.Directory)
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func CreatePGPUnlocker(
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||
|
||||
err = afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms)
|
||||
err = WriteFileAtomic(fs, recipientPath, []byte(ageRecipient))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func CreatePGPUnlocker(
|
||||
// Write encrypted long-term private key
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms)
|
||||
err = WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func CreatePGPUnlocker(
|
||||
|
||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||
|
||||
err = afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms)
|
||||
err = WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
@@ -357,9 +357,8 @@ func writePGPUnlockerMetadata(
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
err = afero.WriteFile(fs,
|
||||
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||
metadataBytes, FilePerms)
|
||||
err = WriteFileAtomic(fs,
|
||||
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
||||
}
|
||||
|
||||
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||
if err := s.fs.RemoveAll(s.Directory); err != nil {
|
||||
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
|
||||
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ func CreateSecureEnclaveUnlocker(
|
||||
|
||||
// Write SE-encrypted long-term key
|
||||
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
|
||||
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
|
||||
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to write SE-encrypted long-term key: %w",
|
||||
err,
|
||||
@@ -295,7 +295,7 @@ func CreateSecureEnclaveUnlocker(
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
|
||||
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+43
-25
@@ -132,7 +132,10 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||
}
|
||||
|
||||
// Save saves the version metadata and value
|
||||
// Save saves the version metadata and value. The files are written into a
|
||||
// temporary directory that is renamed to sv.Directory once all of them are
|
||||
// complete, so the version directory is either whole or absent, even if the
|
||||
// process dies part-way.
|
||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
if value == nil {
|
||||
return errNilValueBuffer
|
||||
@@ -146,14 +149,22 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Create version directory
|
||||
err := fs.MkdirAll(sv.Directory, DirPerms)
|
||||
// Create the versions directory the finished version is renamed into
|
||||
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
|
||||
if err != nil {
|
||||
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||
Debug("Failed to create versions directory", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to create version directory: %w", err)
|
||||
return fmt.Errorf("failed to create versions directory: %w", err)
|
||||
}
|
||||
|
||||
tmpDir, err := TempDirFor(fs, sv.Directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Once the rename below has moved it into place, this finds nothing.
|
||||
defer func() { _ = fs.RemoveAll(tmpDir) }()
|
||||
|
||||
// Generate a new keypair for this version
|
||||
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||
|
||||
@@ -174,21 +185,28 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
slog.String("public_key", versionIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
|
||||
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
|
||||
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sv.writeEncryptedMetadata(fs, versionIdentity)
|
||||
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = fs.Rename(tmpDir, sv.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to move version into place: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret version",
|
||||
"version", sv.Version, "secret_name", sv.SecretName)
|
||||
|
||||
@@ -359,17 +377,18 @@ func (sv *Version) GetValue(
|
||||
}
|
||||
|
||||
// writePublicKeyAndValue stores the version's public key and the value
|
||||
// encrypted to it.
|
||||
// encrypted to it in dir.
|
||||
func (sv *Version) writePublicKeyAndValue(
|
||||
fs afero.Fs,
|
||||
dir string,
|
||||
versionIdentity *age.X25519Identity,
|
||||
value *memguard.LockedBuffer,
|
||||
) error {
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||
pubKeyPath := filepath.Join(dir, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
|
||||
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
|
||||
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
|
||||
if err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
|
||||
@@ -386,10 +405,10 @@ func (sv *Version) writePublicKeyAndValue(
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
}
|
||||
|
||||
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||
valuePath := filepath.Join(dir, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
|
||||
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
|
||||
err = WriteFileAtomic(fs, valuePath, encryptedValue)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
|
||||
@@ -400,9 +419,10 @@ func (sv *Version) writePublicKeyAndValue(
|
||||
}
|
||||
|
||||
// writeEncryptedPrivateKey encrypts the version's private key to the
|
||||
// vault's long-term public key and stores it.
|
||||
// vault's long-term public key and stores it in dir.
|
||||
func (sv *Version) writeEncryptedPrivateKey(
|
||||
fs afero.Fs,
|
||||
dir string,
|
||||
versionPrivateKeyBuffer *memguard.LockedBuffer,
|
||||
) error {
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
@@ -436,10 +456,10 @@ func (sv *Version) writeEncryptedPrivateKey(
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
privKeyPath := filepath.Join(dir, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
|
||||
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
|
||||
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version private key",
|
||||
"error", err, "path", privKeyPath)
|
||||
@@ -451,9 +471,10 @@ func (sv *Version) writeEncryptedPrivateKey(
|
||||
}
|
||||
|
||||
// writeEncryptedMetadata encrypts the version metadata to the version's
|
||||
// public key and stores it.
|
||||
// public key and stores it in dir.
|
||||
func (sv *Version) writeEncryptedMetadata(
|
||||
fs afero.Fs,
|
||||
dir string,
|
||||
versionIdentity *age.X25519Identity,
|
||||
) error {
|
||||
Debug("Encrypting version metadata", "version", sv.Version)
|
||||
@@ -477,10 +498,10 @@ func (sv *Version) writeEncryptedMetadata(
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
metadataPath := filepath.Join(dir, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
|
||||
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
|
||||
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version metadata",
|
||||
"error", err, "path", metadataPath)
|
||||
@@ -553,15 +574,12 @@ func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||
}
|
||||
|
||||
// SetCurrentVersion updates the "current" file to point to a specific version
|
||||
// The file contains just the version name (e.g., "20231215.001")
|
||||
// The file contains just the version name (e.g., "20231215.001"). It is
|
||||
// replaced in one rename, so once written it always exists.
|
||||
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
|
||||
// Remove existing file if it exists
|
||||
_ = fs.Remove(currentPath)
|
||||
|
||||
// Write just the version name to the file
|
||||
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
|
||||
err := WriteFileAtomic(fs, currentPath, []byte(version))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current version file: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user