diff --git a/pkg/controllers/secrets.go b/pkg/controllers/secrets.go index 2dd0428a..bd23ac85 100644 --- a/pkg/controllers/secrets.go +++ b/pkg/controllers/secrets.go @@ -26,6 +26,7 @@ import ( "path/filepath" "sort" "strings" + "syscall" "text/template" "time" @@ -176,7 +177,7 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( return "", nil, Error{Err: errors.New("The mount path already exists. This may be due to another running instance of the Doppler CLI, or due to an improper shutdown. If this is unexpected, delete the file and try again.")} } - if err := utils.CreateNamedPipe(mountPath, 0600); err != nil { + if err := utils.CreateNamedPipe(mountPath, 0o600); err != nil { return "", nil, Error{Err: err, Message: "Unable to mount secrets file"} } @@ -224,7 +225,9 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( utils.HandleError(err, message) } - numReads++ + if enableReadsLimit { + numReads++ + } utils.LogDebug("Secrets mount opened by reader") if _, err := f.Write(secrets); err != nil { @@ -232,6 +235,13 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( if errors.Is(err, fs.ErrNotExist) && fifoCleanupStarted { break } + // broken pipe occurs when reader closes pipe before writing completes (eg. with vite dev server) + if errors.Is(err, syscall.EPIPE) { + utils.LogDebug("Reader closed pipe before write completed") + _ = f.Close() + continue + } + cleanupFIFO() utils.HandleError(err, message) } @@ -241,6 +251,12 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( if errors.Is(err, fs.ErrNotExist) && fifoCleanupStarted { break } + // broken pipe on close is safe to ignore - the reader has already disconnected + if errors.Is(err, syscall.EPIPE) { + utils.LogDebug("Pipe closed by reader") + continue + } + cleanupFIFO() utils.HandleError(err, message) } diff --git a/pkg/controllers/secrets_test.go b/pkg/controllers/secrets_test.go index bccb47c6..ce03712a 100644 --- a/pkg/controllers/secrets_test.go +++ b/pkg/controllers/secrets_test.go @@ -17,9 +17,13 @@ limitations under the License. package controllers import ( + "os" + "path/filepath" "strings" "testing" + "time" + "github.com/DopplerHQ/cli/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -106,3 +110,59 @@ func TestSecretsToBytes(t *testing.T) { t.Errorf("Unable to convert secrets to byte array in %s format", format) } } + +func TestMountSecrets(t *testing.T) { + if !utils.SupportsNamedPipes { + t.Skip("Named pipes not supported on this platform") + } + + secrets := []byte(`{"SECRET_KEY":"secret_value"}`) + mountPath := filepath.Join(t.TempDir(), "secrets_mount") + + path, cleanup, err := MountSecrets(secrets, mountPath, 1) + if !err.IsNil() { + t.Fatalf("MountSecrets failed: %v", err.Err) + } + defer cleanup() + + time.Sleep(50 * time.Millisecond) + + content, readErr := os.ReadFile(path) + assert.NoError(t, readErr) + assert.Equal(t, string(secrets), string(content)) +} + +func TestMountSecretsBrokenPipe(t *testing.T) { + if !utils.SupportsNamedPipes { + t.Skip("Named pipes not supported on this platform") + } + + // large data to exceed pipe buffer and trigger EPIPE when reader closes early + secrets := make([]byte, 256*1024) + for i := range secrets { + secrets[i] = byte('A' + (i % 26)) + } + mountPath := filepath.Join(t.TempDir(), "secrets_mount_epipe") + + path, cleanup, err := MountSecrets(secrets, mountPath, 11) + if !err.IsNil() { + t.Fatalf("MountSecrets failed: %v", err.Err) + } + defer cleanup() + + time.Sleep(50 * time.Millisecond) + + for i := 0; i < 10; i++ { + f, openErr := os.OpenFile(path, os.O_RDONLY, 0) + if openErr != nil { + continue + } + f.Read(make([]byte, 1)) + f.Close() + time.Sleep(5 * time.Millisecond) + } + + content, readErr := os.ReadFile(path) + assert.NoError(t, readErr, "mount should survive broken pipe") + assert.Equal(t, secrets, content) +}