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:
2025-12-23 11:53:28 +07:00
parent 18fb79e971
commit 949a5aee61
6 changed files with 144 additions and 310 deletions

View File

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

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"regexp"
"strings"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
@@ -31,104 +32,31 @@ func isValidVaultName(name string) bool {
return matched
}
// resolveRelativeSymlink resolves a relative symlink target to an absolute path
func resolveRelativeSymlink(symlinkPath, _ string) (string, error) {
// Get the current directory before changing
originalDir, err := os.Getwd()
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
// The file contains a relative path to the vault directory
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
fileData, err := afero.ReadFile(fs, currentVaultPath)
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
secret.Debug("Got current directory", "original_dir", originalDir)
secret.Debug("Failed to read currentvault file", "error", err)
// Change to the symlink's directory
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)
return "", fmt.Errorf("failed to read currentvault file: %w", err)
}
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
// Get the absolute path of the target
secret.Debug("Getting absolute path of current directory")
absolutePath, err := os.Getwd()
if err != nil {
// Try to restore original directory before returning error
_ = os.Chdir(originalDir)
// The file contains a relative path like "vaults.d/default"
relativePath := strings.TrimSpace(string(fileData))
secret.Debug("Read relative path from file", "relative_path", relativePath)
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
secret.Debug("Got absolute path", "absolute_path", absolutePath)
// Resolve to absolute path relative to the state directory
stateDir := filepath.Dir(currentVaultPath)
absolutePath := filepath.Join(stateDir, relativePath)
// Restore the original directory
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")
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
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
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
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)
}
// Create or update the current vault symlink/file
// Create or update the currentvault file with the relative path
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 {
secret.Debug("Removing existing current vault symlink", "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.
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
_ = fs.Remove(currentVaultPath)
}
// Try to create a real symlink first (works on Unix systems)
if _, ok := fs.(*afero.OsFs); ok {
secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
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 {
// Write the relative path to the file
secret.Debug("Writing currentvault file", "relative_path", relativePath)
if err := afero.WriteFile(fs, currentVaultPath, []byte(relativePath), secret.FilePerms); err != nil {
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
}
// 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) {
linkReader, ok := v.fs.(afero.LinkReader)
if !ok {
// Fallback for filesystems that don't support symlinks
return v.readUnlockerPathFromFile(currentUnlockerPath)
}
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
secret.Debug("Resolving unlocker symlink using afero")
// 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)
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
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 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
@@ -287,30 +277,28 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
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")
// Remove existing symlink if it exists
// Remove existing file if it exists
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 {
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
if linker, ok := v.fs.(afero.Linker); ok {
secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath)
if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil {
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)
}
// Compute relative path from vault directory to unlocker directory
relativePath, err := filepath.Rel(vaultDir, targetUnlockerDir)
if err != nil {
return fmt.Errorf("failed to compute relative path: %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

View File

@@ -84,4 +84,4 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
exists, _ := afero.DirExists(fs, secretDir)
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
}
}