package secret_test import ( "errors" "os" "path/filepath" "strings" "testing" "filippo.io/age" "github.com/awnumar/memguard" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) 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" // privKeyFile is the file that holds the encrypted private key of a version // or of a passphrase unlocker. const privKeyFile = "priv.age" // 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 } // dirFiles returns the contents of the files in dir, by name. func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string { t.Helper() files := map[string]string{} for _, name := range dirNames(t, fs, dir) { data, err := afero.ReadFile(fs, filepath.Join(dir, name)) require.NoError(t, err) files[name] = string(data) } return files } // 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, testMnemonicBuffer(t), nil) 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.Parallel() const longestName = 255 fs := afero.NewOsFs() name := strings.Repeat("a", longestName) vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil) 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) defer got.Destroy() assert.Equal(t, []byte("long"), got.Bytes()) 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. func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) { t.Parallel() for _, tfs := range testFilesystems { t.Run(tfs.name, func(t *testing.T) { t.Parallel() 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) defer value.Destroy() assert.Equal(t, []byte("old"), value.Bytes()) }) } } // 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. func TestTempDirsStayOutOfListings(t *testing.T) { t.Parallel() for _, tfs := range testFilesystems { t.Run(tfs.name, func(t *testing.T) { t.Parallel() 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") vlt.Mnemonic = testMnemonicBuffer(t) 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", privKeyFile, "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) == privKeyFile { 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. func TestCurrentFilesNeverMissing(t *testing.T) { t.Parallel() for _, tfs := range testFilesystems { t.Run(tfs.name, func(t *testing.T) { t.Parallel() 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) { t.Parallel() // No mnemonic, and no current unlocker to get the key from base := afero.NewMemMapFs() _, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil) 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) } // TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that // creating a passphrase unlocker makes, that the unlocker's directory either // does not exist or holds all of its files: a crash or a failure at any point // leaves no partial unlocker. func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) { t.Parallel() files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile} for _, tfs := range testFilesystems { t.Run(tfs.name, func(t *testing.T) { t.Parallel() base, stateDir := tfs.open(t) vlt, err := vault.CreateVault(base, stateDir, testVaultName, testMnemonicBuffer(t), nil) require.NoError(t, err) vaultDir, err := vlt.GetDirectory() require.NoError(t, err) // The vault has no unlocker yet, so any directory in here is // the new one unlockersDir := filepath.Join(vaultDir, "unlockers.d") fs := hookFs{Fs: base, before: func(string, string) error { for _, name := range dirNames(t, base, unlockersDir) { assert.ElementsMatch(t, files, dirNames(t, base, filepath.Join(unlockersDir, name)), "unlocker directory visible before it was complete") } return nil }} passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase)) defer passphrase.Destroy() hooked := vault.NewVault(fs, stateDir, testVaultName) hooked.Mnemonic = vlt.Mnemonic unlocker, err := hooked.CreatePassphraseUnlocker(passphrase) require.NoError(t, err) assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory())) }) } } // TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's // passphrase unlocker twice, each time with only the current unlocker to open // the vault. The first replacement fails right after making the new unlocker // current, so the old one is not removed. The second checks, before every // change it makes, that the vault opens with the passphrase through its // current unlocker, which is what a crash at that change would leave; once it // returns, the vault must have one passphrase unlocker left. func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) { t.Parallel() for _, tfs := range testFilesystems { t.Run(tfs.name, func(t *testing.T) { t.Parallel() base, stateDir := tfs.open(t) vlt, err := vault.CreateVault(base, stateDir, testVaultName, testMnemonicBuffer(t), nil) require.NoError(t, err) ltIdentity, err := vlt.GetOrDeriveLongTermKey() require.NoError(t, err) passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase)) defer passphrase.Destroy() _, err = vlt.CreatePassphraseUnlocker(passphrase) require.NoError(t, err) vaultDir, err := vlt.GetDirectory() require.NoError(t, err) currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") // Every change after the switch to the new unlocker fails switched := false failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error { if switched { return errInjected } switched = op == opRename && path == currentUnlockerPath return nil }} replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName) replacing.Unlock(ltIdentity) _, err = replacing.CreatePassphraseUnlocker(passphrase) require.ErrorIs(t, err, errInjected) unlockers, err := vlt.ListUnlockers() require.NoError(t, err) assert.Len(t, unlockers, 2, "the old unlocker is left beside the new") assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase) checked := hookFs{Fs: base, before: func(string, string) error { assertOpens() return nil }} replacing = vault.NewVault(checked, stateDir, testVaultName) replacing.Unlock(ltIdentity) _, err = replacing.CreatePassphraseUnlocker(passphrase) require.NoError(t, err) assertOpens() unlockers, err = vlt.ListUnlockers() require.NoError(t, err) assert.Len(t, unlockers, 1) }) } } // vaultOpensCheck returns a function that checks that the test vault under // stateDir opens through its current unlocker, with passphrase, to the // long-term key ltIdentity. Opening it takes a second, so an unlocker // directory it has opened through before is not opened again: it must hold // the same files as then. func vaultOpensCheck( t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity, passphrase *memguard.LockedBuffer, ) func() { t.Helper() vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName) // The files of each unlocker directory the vault has opened through opened := map[string]map[string]string{} return func() { t.Helper() current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker")) require.NoError(t, err) files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current))) if before, ok := opened[string(current)]; ok { assert.Equal(t, before, files, "unlocker changed since it opened the vault") return } opener := vault.NewVault(fs, stateDir, testVaultName) opener.UnlockPassphrase = passphrase key, err := opener.UnlockVault() require.NoError(t, err) assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String()) opened[string(current)] = files } } // TestWriteDirFailureLeavesNothing makes writing a new directory fail after // a file has been written in it, and checks that neither the directory nor // its temporary directory is left behind; and, when the temporary directory // cannot be removed either, that both failures are reported. func TestWriteDirFailureLeavesNothing(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, "unlockers.d") target := filepath.Join(listed, "new") writeThenFail := func(tmp string) error { require.NoError(t, secret.WriteFileAtomic(base, filepath.Join(tmp, unlockerMetadataFile), []byte("{}"))) return errInjected } err := secret.WriteDir(base, target, writeThenFail) require.ErrorIs(t, err, errInjected) // Nothing in the directory that is listed, nor beside it assert.Empty(t, dirNames(t, base, listed)) assert.Equal(t, []string{"unlockers.d"}, dirNames(t, base, dir)) fs := hookFs{Fs: base, before: func(op, _ string) error { if op == opRemove { return os.ErrPermission } return nil }} err = secret.WriteDir(fs, target, writeThenFail) require.ErrorIs(t, err, errInjected) require.ErrorIs(t, err, os.ErrPermission) assert.Empty(t, dirNames(t, base, listed)) }) } } // TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling // write, when the directory already exists, and leaves the directory as it // was: it never writes into a directory in place. func TestWriteDirRefusesExistingDir(t *testing.T) { t.Parallel() for _, tfs := range testFilesystems { t.Run(tfs.name, func(t *testing.T) { t.Parallel() fs, dir := tfs.open(t) target := filepath.Join(dir, "unlockers.d", "existing") require.NoError(t, fs.MkdirAll(target, secret.DirPerms)) require.NoError(t, secret.WriteFileAtomic(fs, filepath.Join(target, unlockerMetadataFile), []byte("{}"))) err := secret.WriteDir(fs, target, func(string) error { t.Error("write called for a directory that exists") return nil }) require.ErrorIs(t, err, os.ErrExist) assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target)) }) } }