Replace symlinks with plain files containing relative paths

- Remove all symlink creation and resolution in favor of plain files
- currentvault file now contains relative path like "vaults.d/default"
- current-unlocker file now contains relative path like "unlockers.d/passphrase"
- current version file now contains relative path like "versions/20231215.001"
- Simplify path resolution to just read file contents and join with parent dir
- Update all tests to read files instead of using os.Readlink
This commit is contained in:
Jeffrey Paul 2025-12-23 11:53:28 +07:00
parent 18fb79e971
commit 949a5aee61
6 changed files with 144 additions and 310 deletions

View File

@ -332,16 +332,12 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
defaultVaultDir := filepath.Join(vaultsDir, "default") defaultVaultDir := filepath.Join(vaultsDir, "default")
verifyFileExists(t, defaultVaultDir) verifyFileExists(t, defaultVaultDir)
// Check currentvault symlink - it may be absolute or relative // Check currentvault file contains the relative path
currentVaultLink := filepath.Join(tempDir, "currentvault") currentVaultFile := filepath.Join(tempDir, "currentvault")
target, err := os.Readlink(currentVaultLink) targetBytes, err := os.ReadFile(currentVaultFile)
require.NoError(t, err, "should be able to read currentvault symlink") require.NoError(t, err, "should be able to read currentvault file")
// Check if it points to the right place (handle both absolute and relative) target := string(targetBytes)
if filepath.IsAbs(target) { assert.Equal(t, "vaults.d/default", target, "currentvault should contain relative path")
assert.Equal(t, filepath.Join(tempDir, "vaults.d/default"), target)
} else {
assert.Equal(t, "vaults.d/default", target)
}
// Verify vault structure // Verify vault structure
pubKeyFile := filepath.Join(defaultVaultDir, "pub.age") pubKeyFile := filepath.Join(defaultVaultDir, "pub.age")
@ -366,22 +362,12 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age") encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
verifyFileExists(t, encryptedLTPubKey) verifyFileExists(t, encryptedLTPubKey)
// Check current-unlocker file // Check current-unlocker file contains the relative path
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker") currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile) verifyFileExists(t, currentUnlockerFile)
// Read the current-unlocker symlink to see what it points to
symlinkTarget, err := os.Readlink(currentUnlockerFile)
if err != nil {
t.Logf("DEBUG: failed to read symlink %s: %v", currentUnlockerFile, err)
// Fallback to reading as file if it's not a symlink
currentUnlockerContent := readFile(t, currentUnlockerFile) currentUnlockerContent := readFile(t, currentUnlockerFile)
t.Logf("DEBUG: current-unlocker file content: %q", string(currentUnlockerContent)) assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
} else {
t.Logf("DEBUG: current-unlocker symlink points to: %q", symlinkTarget)
assert.Contains(t, symlinkTarget, "passphrase", "current unlocker should be passphrase type")
}
// Verify vault-metadata.json in vault // Verify vault-metadata.json in vault
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json") vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
@ -472,17 +458,12 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
workVaultDir := filepath.Join(tempDir, "vaults.d", "work") workVaultDir := filepath.Join(tempDir, "vaults.d", "work")
verifyFileExists(t, workVaultDir) verifyFileExists(t, workVaultDir)
// Check currentvault symlink was updated // Check currentvault file was updated
currentVaultLink := filepath.Join(tempDir, "currentvault") currentVaultFile := filepath.Join(tempDir, "currentvault")
target, err := os.Readlink(currentVaultLink) targetBytes, err := os.ReadFile(currentVaultFile)
require.NoError(t, err, "should be able to read currentvault symlink") require.NoError(t, err, "should be able to read currentvault file")
target := string(targetBytes)
// The symlink should now point to work vault assert.Equal(t, "vaults.d/work", target, "currentvault should contain relative path to work")
if filepath.IsAbs(target) {
assert.Equal(t, filepath.Join(tempDir, "vaults.d/work"), target)
} else {
assert.Equal(t, "vaults.d/work", target)
}
// Verify work vault has basic structure // Verify work vault has basic structure
unlockersDir := filepath.Join(workVaultDir, "unlockers.d") unlockersDir := filepath.Join(workVaultDir, "unlockers.d")
@ -608,15 +589,16 @@ func test05AddSecret(t *testing.T, tempDir, testMnemonic string, runSecret func(
metadataFile := filepath.Join(versionDir, "metadata.age") metadataFile := filepath.Join(versionDir, "metadata.age")
verifyFileExists(t, metadataFile) verifyFileExists(t, metadataFile)
// Check current symlink // Check current file
currentLink := filepath.Join(secretDir, "current") currentLink := filepath.Join(secretDir, "current")
verifyFileExists(t, currentLink) verifyFileExists(t, currentLink)
// Verify symlink points to the version directory // Verify current file contains the version path
target, err := os.Readlink(currentLink) targetBytes, err := os.ReadFile(currentLink)
require.NoError(t, err, "should read current symlink") require.NoError(t, err, "should read current file")
target := string(targetBytes)
expectedTarget := filepath.Join("versions", versionName) expectedTarget := filepath.Join("versions", versionName)
assert.Equal(t, expectedTarget, target, "current symlink should point to version") assert.Equal(t, expectedTarget, target, "current file should point to version")
// Verify we can retrieve the secret // Verify we can retrieve the secret
getOutput, err := runSecretWithEnv(map[string]string{ getOutput, err := runSecretWithEnv(map[string]string{
@ -698,12 +680,13 @@ func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecre
verifyFileExists(t, filepath.Join(versionDir, "metadata.age")) verifyFileExists(t, filepath.Join(versionDir, "metadata.age"))
} }
// Check current symlink points to new version // Check current file points to new version
currentLink := filepath.Join(secretDir, "current") currentLink := filepath.Join(secretDir, "current")
target, err := os.Readlink(currentLink) targetBytes, err := os.ReadFile(currentLink)
require.NoError(t, err, "should read current symlink") require.NoError(t, err, "should read current file")
target := string(targetBytes)
expectedTarget := filepath.Join("versions", newVersion) expectedTarget := filepath.Join("versions", newVersion)
assert.Equal(t, expectedTarget, target, "current symlink should point to new version") assert.Equal(t, expectedTarget, target, "current file should point to new version")
// Verify we get the new value when retrieving the secret // Verify we get the new value when retrieving the secret
getOutput, err := runSecretWithEnv(map[string]string{ getOutput, err := runSecretWithEnv(map[string]string{
@ -815,8 +798,9 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret
// Before promotion, current should point to .002 (from test 07) // Before promotion, current should point to .002 (from test 07)
currentLink := filepath.Join(defaultVaultDir, "secrets.d", "database%password", "current") currentLink := filepath.Join(defaultVaultDir, "secrets.d", "database%password", "current")
target, err := os.Readlink(currentLink) targetBytes, err := os.ReadFile(currentLink)
require.NoError(t, err, "should read current symlink") require.NoError(t, err, "should read current file")
target := string(targetBytes)
assert.Equal(t, filepath.Join("versions", version002), target, "current should initially point to .002") assert.Equal(t, filepath.Join("versions", version002), target, "current should initially point to .002")
// Promote the old version // Promote the old version
@ -828,11 +812,12 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret
assert.Contains(t, output, "Promoted version", "should confirm promotion") assert.Contains(t, output, "Promoted version", "should confirm promotion")
assert.Contains(t, output, version001, "should mention the promoted version") assert.Contains(t, output, version001, "should mention the promoted version")
// Verify symlink was updated // Verify current file was updated
newTarget, err := os.Readlink(currentLink) newTargetBytes, err := os.ReadFile(currentLink)
require.NoError(t, err, "should read current symlink after promotion") require.NoError(t, err, "should read current file after promotion")
newTarget := string(newTargetBytes)
expectedTarget := filepath.Join("versions", version001) expectedTarget := filepath.Join("versions", version001)
assert.Equal(t, expectedTarget, newTarget, "current symlink should now point to .001") assert.Equal(t, expectedTarget, newTarget, "current file should now point to .001")
// Verify we now get the old value when retrieving the secret // Verify we now get the old value when retrieving the secret
getOutput, err := runSecretWithEnv(map[string]string{ getOutput, err := runSecretWithEnv(map[string]string{
@ -1251,27 +1236,21 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) (
require.NoError(t, err, "vault select default should succeed") require.NoError(t, err, "vault select default should succeed")
// Verify current vault is default // Verify current vault is default
currentVaultLink := filepath.Join(tempDir, "currentvault") currentVaultFile := filepath.Join(tempDir, "currentvault")
target, err := os.Readlink(currentVaultLink) targetBytes, err := os.ReadFile(currentVaultFile)
require.NoError(t, err, "should read currentvault symlink") require.NoError(t, err, "should read currentvault file")
if filepath.IsAbs(target) { target := string(targetBytes)
assert.Contains(t, target, "vaults.d/default") assert.Equal(t, "vaults.d/default", target, "currentvault should point to default")
} else {
assert.Contains(t, target, "default")
}
// Switch to work vault // Switch to work vault
_, err = runSecret("vault", "select", "work") _, err = runSecret("vault", "select", "work")
require.NoError(t, err, "vault select work should succeed") require.NoError(t, err, "vault select work should succeed")
// Verify current vault is now work // Verify current vault is now work
target, err = os.Readlink(currentVaultLink) targetBytes, err = os.ReadFile(currentVaultFile)
require.NoError(t, err, "should read currentvault symlink") require.NoError(t, err, "should read currentvault file")
if filepath.IsAbs(target) { target = string(targetBytes)
assert.Contains(t, target, "vaults.d/work") assert.Equal(t, "vaults.d/work", target, "currentvault should point to work")
} else {
assert.Contains(t, target, "work")
}
// Switch back to default // Switch back to default
_, err = runSecret("vault", "select", "default") _, err = runSecret("vault", "select", "default")
@ -2006,26 +1985,28 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
} }
func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) { func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
// Test currentvault symlink // Test currentvault file
currentVaultLink := filepath.Join(tempDir, "currentvault") currentVaultFile := filepath.Join(tempDir, "currentvault")
verifyFileExists(t, currentVaultLink) verifyFileExists(t, currentVaultFile)
// Read the symlink // Read the file
target, err := os.Readlink(currentVaultLink) targetBytes, err := os.ReadFile(currentVaultFile)
require.NoError(t, err, "should read currentvault symlink") require.NoError(t, err, "should read currentvault file")
target := string(targetBytes)
assert.Contains(t, target, "vaults.d", "should point to vaults.d directory") assert.Contains(t, target, "vaults.d", "should point to vaults.d directory")
// Test version current symlink // Test version current file
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default") defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
secretDir := filepath.Join(defaultVaultDir, "secrets.d", "database%password") secretDir := filepath.Join(defaultVaultDir, "secrets.d", "database%password")
currentLink := filepath.Join(secretDir, "current") currentLink := filepath.Join(secretDir, "current")
verifyFileExists(t, currentLink) verifyFileExists(t, currentLink)
target, err = os.Readlink(currentLink) targetBytes, err = os.ReadFile(currentLink)
require.NoError(t, err, "should read current version symlink") require.NoError(t, err, "should read current version file")
target = string(targetBytes)
assert.Contains(t, target, "versions", "should point to versions directory") assert.Contains(t, target, "versions", "should point to versions directory")
// Test that symlinks update properly // Test that current file updates properly
// Add new version // Add new version
cmd := exec.Command(secretPath, "add", "database/password", "--force") cmd := exec.Command(secretPath, "add", "database/password", "--force")
cmd.Env = []string{ cmd.Env = []string{
@ -2038,11 +2019,12 @@ func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic strin
_, err = cmd.CombinedOutput() _, err = cmd.CombinedOutput()
require.NoError(t, err, "add new version should succeed") require.NoError(t, err, "add new version should succeed")
// Check that symlink was updated // Check that current file was updated
newTarget, err := os.Readlink(currentLink) newTargetBytes, err := os.ReadFile(currentLink)
require.NoError(t, err, "should read updated symlink") require.NoError(t, err, "should read updated current file")
assert.NotEqual(t, target, newTarget, "symlink should point to new version") newTarget := string(newTargetBytes)
assert.Contains(t, newTarget, "versions", "new symlink should still point to versions directory") assert.NotEqual(t, target, newTarget, "current file should point to new version")
assert.Contains(t, newTarget, "versions", "new current file should still point to versions directory")
} }
func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
@ -2078,18 +2060,11 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
err = copyDir(filepath.Join(tempDir, "vaults.d"), filepath.Join(backupDir, "vaults.d")) err = copyDir(filepath.Join(tempDir, "vaults.d"), filepath.Join(backupDir, "vaults.d"))
require.NoError(t, err, "backup vaults should succeed") require.NoError(t, err, "backup vaults should succeed")
// Also backup the currentvault symlink/file // Also backup the currentvault file
currentVaultSrc := filepath.Join(tempDir, "currentvault") currentVaultSrc := filepath.Join(tempDir, "currentvault")
currentVaultDst := filepath.Join(backupDir, "currentvault") currentVaultDst := filepath.Join(backupDir, "currentvault")
if target, err := os.Readlink(currentVaultSrc); err == nil {
// It's a symlink, recreate it
err = os.Symlink(target, currentVaultDst)
require.NoError(t, err, "backup currentvault symlink should succeed")
} else {
// It's a regular file, copy it
data := readFile(t, currentVaultSrc) data := readFile(t, currentVaultSrc)
writeFile(t, currentVaultDst, data) writeFile(t, currentVaultDst, data)
}
// Add more secrets after backup // Add more secrets after backup
cmd := exec.Command(secretPath, "add", "post-backup/secret", "--force") cmd := exec.Command(secretPath, "add", "post-backup/secret", "--force")
@ -2119,13 +2094,8 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
// Restore currentvault // Restore currentvault
os.Remove(currentVaultSrc) os.Remove(currentVaultSrc)
if target, err := os.Readlink(currentVaultDst); err == nil { restoredData := readFile(t, currentVaultDst)
err = os.Symlink(target, currentVaultSrc) writeFile(t, currentVaultSrc, restoredData)
require.NoError(t, err, "restore currentvault symlink should succeed")
} else {
data := readFile(t, currentVaultDst)
writeFile(t, currentVaultSrc, data)
}
// Verify original secrets are restored // Verify original secrets are restored
output, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
@ -2267,18 +2237,7 @@ func copyDir(src, dst string) error {
srcPath := filepath.Join(src, entry.Name()) srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name()) dstPath := filepath.Join(dst, entry.Name())
// Check if it's a symlink if entry.IsDir() {
if info, err := os.Lstat(srcPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
// It's a symlink - read and recreate it
target, err := os.Readlink(srcPath)
if err != nil {
return err
}
err = os.Symlink(target, dstPath)
if err != nil {
return err
}
} else if entry.IsDir() {
err = copyDir(srcPath, dstPath) err = copyDir(srcPath, dstPath)
if err != nil { if err != nil {
return err return err

View File

@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -431,59 +430,37 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
return versions, nil return versions, nil
} }
// GetCurrentVersion returns the version that the "current" symlink points to // GetCurrentVersion returns the version that the "current" file points to
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) { func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
currentPath := filepath.Join(secretDir, "current") currentPath := filepath.Join(secretDir, "current")
// Try to read as a real symlink first fileData, err := afero.ReadFile(fs, currentPath)
if _, ok := fs.(*afero.OsFs); ok { if err != nil {
target, err := os.Readlink(currentPath) return "", fmt.Errorf("failed to read current version file: %w", err)
if err == nil { }
target := strings.TrimSpace(string(fileData))
// Extract version from path (e.g., "versions/20231215.001" -> "20231215.001") // Extract version from path (e.g., "versions/20231215.001" -> "20231215.001")
parts := strings.Split(target, "/") parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" { if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil return parts[1], nil
} }
return "", fmt.Errorf("invalid current version symlink format: %s", target) return "", fmt.Errorf("invalid current version file format: %s", target)
}
}
// Fall back to reading as a file (for MemMapFs testing)
fileData, err := afero.ReadFile(fs, currentPath)
if err != nil {
return "", fmt.Errorf("failed to read current version symlink: %w", err)
}
target := strings.TrimSpace(string(fileData))
// Extract version from path
parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil
}
return "", fmt.Errorf("invalid current version symlink format: %s", target)
} }
// SetCurrentVersion updates the "current" symlink to point to a specific version // SetCurrentVersion updates the "current" file to point to a specific version
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error { func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current") currentPath := filepath.Join(secretDir, "current")
targetPath := filepath.Join("versions", version) targetPath := filepath.Join("versions", version)
// Remove existing symlink if it exists // Remove existing file if it exists
_ = fs.Remove(currentPath) _ = fs.Remove(currentPath)
// Try to create a real symlink first (works on Unix systems) // Write the relative path to the file
if _, ok := fs.(*afero.OsFs); ok {
if err := os.Symlink(targetPath, currentPath); err == nil {
return nil
}
}
// Fall back to creating a file with the target path (for MemMapFs testing)
if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil { if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil {
return fmt.Errorf("failed to create current version symlink: %w", err) return fmt.Errorf("failed to create current version file: %w", err)
} }
return nil return nil

View File

@ -26,9 +26,9 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase") t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
// Test symlink handling // Test currentvault file handling (plain file with relative path)
t.Run("SymlinkHandling", func(t *testing.T) { t.Run("CurrentVaultFileHandling", func(t *testing.T) {
stateDir := filepath.Join(tempDir, "symlink-test") stateDir := filepath.Join(tempDir, "currentvault-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil { if err := os.MkdirAll(stateDir, 0o700); err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
@ -45,31 +45,26 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Fatalf("Failed to get vault directory: %v", err) t.Fatalf("Failed to get vault directory: %v", err)
} }
// Create a symlink to the vault directory in a different location // Verify the currentvault file exists and contains the right relative path
symlinkPath := filepath.Join(tempDir, "test-symlink") currentVaultPath := filepath.Join(stateDir, "currentvault")
if err := os.Symlink(vaultDir, symlinkPath); err != nil { currentVaultContents, err := os.ReadFile(currentVaultPath)
t.Fatalf("Failed to create symlink: %v", err) if err != nil {
t.Fatalf("Failed to read currentvault file: %v", err)
} }
// Test that we can resolve the symlink correctly expectedRelativePath := "vaults.d/test-vault"
resolvedPath, err := vault.ResolveVaultSymlink(fs, symlinkPath) if string(currentVaultContents) != expectedRelativePath {
if err != nil { t.Errorf("Expected currentvault to contain %q, got %q", expectedRelativePath, string(currentVaultContents))
t.Fatalf("Failed to resolve symlink: %v", err)
} }
// On some platforms, the resolved path might have different case or format // Test that ResolveVaultSymlink correctly resolves the path
// We'll use filepath.EvalSymlinks to get the canonical path for comparison resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
expectedPath, err := filepath.EvalSymlinks(vaultDir)
if err != nil { if err != nil {
t.Fatalf("Failed to evaluate symlink: %v", err) t.Fatalf("Failed to resolve currentvault path: %v", err)
}
actualPath, err := filepath.EvalSymlinks(resolvedPath)
if err != nil {
t.Fatalf("Failed to evaluate resolved path: %v", err)
} }
if actualPath != expectedPath { if resolvedPath != vaultDir {
t.Errorf("Expected symlink to resolve to %s, got %s", expectedPath, actualPath) t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
} }
}) })

View File

@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
@ -31,104 +32,31 @@ func isValidVaultName(name string) bool {
return matched return matched
} }
// resolveRelativeSymlink resolves a relative symlink target to an absolute path // ResolveVaultSymlink reads the currentvault file to get the path to the current vault
func resolveRelativeSymlink(symlinkPath, _ string) (string, error) { // The file contains a relative path to the vault directory
// Get the current directory before changing func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
originalDir, err := os.Getwd() secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
fileData, err := afero.ReadFile(fs, currentVaultPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err) secret.Debug("Failed to read currentvault file", "error", err)
}
secret.Debug("Got current directory", "original_dir", originalDir)
// Change to the symlink's directory return "", fmt.Errorf("failed to read currentvault file: %w", err)
symlinkDir := filepath.Dir(symlinkPath)
secret.Debug("Changing to symlink directory", "symlink_path", symlinkDir)
secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
if err := os.Chdir(symlinkDir); err != nil {
return "", fmt.Errorf("failed to change to symlink directory: %w", err)
} }
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
// Get the absolute path of the target // The file contains a relative path like "vaults.d/default"
secret.Debug("Getting absolute path of current directory") relativePath := strings.TrimSpace(string(fileData))
absolutePath, err := os.Getwd() secret.Debug("Read relative path from file", "relative_path", relativePath)
if err != nil {
// Try to restore original directory before returning error
_ = os.Chdir(originalDir)
return "", fmt.Errorf("failed to get absolute path: %w", err) // Resolve to absolute path relative to the state directory
} stateDir := filepath.Dir(currentVaultPath)
secret.Debug("Got absolute path", "absolute_path", absolutePath) absolutePath := filepath.Join(stateDir, relativePath)
// Restore the original directory secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
secret.Debug("Restoring original directory", "original_dir", originalDir)
if err := os.Chdir(originalDir); err != nil {
return "", fmt.Errorf("failed to restore original directory: %w", err)
}
secret.Debug("Restored original directory successfully")
return absolutePath, nil return absolutePath, nil
} }
// ResolveVaultSymlink resolves the currentvault symlink by reading either the symlink target or file contents
// This function is designed to work on both Unix and Windows systems, as well as with in-memory filesystems
func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
// First try to handle the path as a real symlink (works on Unix systems)
_, isOsFs := fs.(*afero.OsFs)
if isOsFs {
target, err := tryResolveOsSymlink(symlinkPath)
if err == nil {
secret.Debug("resolveVaultSymlink completed successfully", "result", target)
return target, nil
}
// Fall through to fallback if symlink resolution failed
} else {
secret.Debug("Not using OS filesystem, skipping symlink resolution")
}
// Fallback: treat it as a regular file containing the target path
secret.Debug("Fallback: trying to read regular file with target path")
fileData, err := afero.ReadFile(fs, symlinkPath)
if err != nil {
secret.Debug("Failed to read target path file", "error", err)
return "", fmt.Errorf("failed to read vault symlink: %w", err)
}
target := string(fileData)
secret.Debug("Read target path from file", "target", target)
secret.Debug("resolveVaultSymlink completed via fallback", "result", target)
return target, nil
}
// tryResolveOsSymlink attempts to resolve a symlink on OS filesystems
func tryResolveOsSymlink(symlinkPath string) (string, error) {
secret.Debug("Using real filesystem symlink resolution")
// Check if the symlink exists
secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
target, err := os.Readlink(symlinkPath)
if err != nil {
return "", err
}
secret.Debug("Symlink points to", "target", target)
// On real filesystem, we need to handle relative symlinks
// by resolving them relative to the symlink's directory
if !filepath.IsAbs(target) {
return resolveRelativeSymlink(symlinkPath, target)
}
return target, nil
}
// GetCurrentVault gets the current vault from the file system // GetCurrentVault gets the current vault from the file system
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) { func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
secret.Debug("Getting current vault", "state_dir", stateDir) secret.Debug("Getting current vault", "state_dir", stateDir)
@ -328,32 +256,19 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
return fmt.Errorf("vault %s does not exist", name) return fmt.Errorf("vault %s does not exist", name)
} }
// Create or update the current vault symlink/file // Create or update the currentvault file with the relative path
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
targetPath := filepath.Join(stateDir, "vaults.d", name) relativePath := filepath.Join("vaults.d", name)
// First try to remove existing symlink if it exists // Remove existing file if it exists
if _, err := fs.Stat(currentVaultPath); err == nil { if _, err := fs.Stat(currentVaultPath); err == nil {
secret.Debug("Removing existing current vault symlink", "path", currentVaultPath) secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
// Ignore errors from Remove as we'll try to create/update it anyway.
// On some systems, removing a symlink may fail but the subsequent create may still succeed.
_ = fs.Remove(currentVaultPath) _ = fs.Remove(currentVaultPath)
} }
// Try to create a real symlink first (works on Unix systems) // Write the relative path to the file
if _, ok := fs.(*afero.OsFs); ok { secret.Debug("Writing currentvault file", "relative_path", relativePath)
secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath) if err := afero.WriteFile(fs, currentVaultPath, []byte(relativePath), secret.FilePerms); err != nil {
if err := os.Symlink(targetPath, currentVaultPath); err == nil {
secret.Debug("Successfully selected vault", "vault_name", name)
return nil
}
// If symlink creation fails, fall back to regular file
}
// Fallback: create a regular file with the target path
secret.Debug("Fallback: creating regular file with target path", "target", targetPath)
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), secret.FilePerms); err != nil {
return fmt.Errorf("failed to select vault: %w", err) return fmt.Errorf("failed to select vault: %w", err)
} }

View File

@ -98,38 +98,28 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
return unlocker, nil return unlocker, nil
} }
// resolveUnlockerDirectory resolves the unlocker directory from a symlink or file // resolveUnlockerDirectory reads the current-unlocker file to get the unlocker directory path
// The file contains a relative path to the unlocker directory
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) { func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
linkReader, ok := v.fs.(afero.LinkReader) secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
if !ok {
// Fallback for filesystems that don't support symlinks
return v.readUnlockerPathFromFile(currentUnlockerPath)
}
secret.Debug("Resolving unlocker symlink using afero") unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
// Try to read as symlink first
unlockerDir, err := linkReader.ReadlinkIfPossible(currentUnlockerPath)
if err == nil {
return unlockerDir, nil
}
secret.Debug("Failed to read symlink, falling back to file contents",
"error", err, "symlink_path", currentUnlockerPath)
// Fallback: read the path from file contents
return v.readUnlockerPathFromFile(currentUnlockerPath)
}
// readUnlockerPathFromFile reads the unlocker directory path from a file
func (v *Vault) readUnlockerPathFromFile(path string) (string, error) {
secret.Debug("Reading unlocker path from file", "path", path)
unlockerDirBytes, err := afero.ReadFile(v.fs, path)
if err != nil { if err != nil {
secret.Debug("Failed to read unlocker path file", "error", err, "path", path) secret.Debug("Failed to read current-unlocker file", "error", err, "path", currentUnlockerPath)
return "", fmt.Errorf("failed to read current unlocker: %w", err) return "", fmt.Errorf("failed to read current unlocker: %w", err)
} }
return strings.TrimSpace(string(unlockerDirBytes)), nil relativePath := strings.TrimSpace(string(unlockerDirBytes))
secret.Debug("Read relative path from file", "relative_path", relativePath)
// Resolve to absolute path relative to the vault directory
vaultDir := filepath.Dir(currentUnlockerPath)
absolutePath := filepath.Join(vaultDir, relativePath)
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
return absolutePath, nil
} }
// findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path // findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path
@ -287,30 +277,28 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return fmt.Errorf("unlocker with ID %s not found", unlockerID) return fmt.Errorf("unlocker with ID %s not found", unlockerID)
} }
// Create/update current unlocker symlink // Create/update current-unlocker file with relative path
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Remove existing symlink if it exists // Remove existing file if it exists
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil { if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
return fmt.Errorf("failed to check if current unlocker symlink exists: %w", err) return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
} else if exists { } else if exists {
if err := v.fs.Remove(currentUnlockerPath); err != nil { if err := v.fs.Remove(currentUnlockerPath); err != nil {
return fmt.Errorf("failed to remove existing unlocker symlink: %w", err) return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
} }
} }
// Create new symlink using afero's SymlinkIfPossible // Compute relative path from vault directory to unlocker directory
if linker, ok := v.fs.(afero.Linker); ok { relativePath, err := filepath.Rel(vaultDir, targetUnlockerDir)
secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath) if err != nil {
if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil { return fmt.Errorf("failed to compute relative path: %w", err)
return fmt.Errorf("failed to create unlocker symlink: %w", err)
}
} else {
// Fallback: create a regular file with the target path for filesystems that don't support symlinks
secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir)
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil {
return fmt.Errorf("failed to create unlocker symlink file: %w", err)
} }
// Write the relative path to the file
secret.Debug("Writing current-unlocker file", "relative_path", relativePath)
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(relativePath), secret.FilePerms); err != nil {
return fmt.Errorf("failed to create current-unlocker file: %w", err)
} }
return nil return nil