Skip to content

Commit 4a3f410

Browse files
ranaroussiclaude
andcommitted
feat: keep git operations working when smudge cannot decrypt
- On a missing or wrong key, the smudge filter (both single-shot and filter-process) now passes the encrypted content through with a clear warning instead of aborting the checkout, so git pull/checkout/clone succeed with the file left encrypted - The warning states the git operation succeeded and how to recover (envapor init <key>, then envapor decrypt) - Clean stays fail-closed: encryption failures still abort so plaintext can never reach the object store - Also destroy key material in the single-shot smudge/textconv paths 🤖 Generated with Claude Code Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 71780f1 commit 4a3f410

4 files changed

Lines changed: 89 additions & 17 deletions

File tree

‎docs/user-guide.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ Both keys appear in the command by design: migration needs the old key to decryp
374374
## Troubleshooting
375375

376376
**Values look like `ENC[v2:...]` in my editor.**
377-
The smudge filter did not run on checkout. Run `envapor init --pem <key>` to reinstall the filters, then re-checkout the file with `git checkout -- .env`.
377+
The file was checked out without being decrypted — either the filters are not installed, or the local key does not match (in that case `git pull`/`git checkout` prints an `envapor: warning: could not decrypt ...` message but completes normally, leaving the file encrypted). Set the correct key with `envapor init <key>`, then run `envapor decrypt` to restore plaintext.
378378

379379
**My commit was aborted with a plaintext warning.**
380380
This is the pre-commit guard doing its job: it caught a value that was not encrypted. Run `envapor doctor` to confirm the filters are installed, then re-stage and commit.

‎src/internal/cmd/filter.go‎

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
11
package cmd
22

33
import (
4+
"fmt"
45
"io"
56
"os"
7+
"strings"
68

79
"github.com/automazeio/envapor/internal/envfile"
810
"github.com/spf13/cobra"
911
)
1012

13+
// smudgeFallback reports a smudge failure and returns the content unchanged,
14+
// so the surrounding git operation (pull, checkout, clone) succeeds with the
15+
// file left encrypted instead of aborting halfway through. Clean has no such
16+
// fallback: failures there stay fatal so plaintext can never reach the object
17+
// store unencrypted.
18+
func smudgeFallback(path string, cause error, content []byte) []byte {
19+
if path == "" {
20+
path = "the file"
21+
}
22+
reason := strings.TrimPrefix(cause.Error(), "envapor: ")
23+
fmt.Fprintf(os.Stderr, "envapor: warning: could not decrypt %s: %s\n", path, reason)
24+
fmt.Fprintf(os.Stderr, "envapor: the git operation itself succeeded, but %s still holds encrypted values.\n", path)
25+
fmt.Fprintf(os.Stderr, "envapor: set the right key with 'envapor init <key>', then run 'envapor decrypt'.\n")
26+
return content
27+
}
28+
1129
// cleanCmd is the Git clean filter: it reads a plaintext .env from stdin and
1230
// writes the encrypted form to stdout on the way into the object store.
1331
var cleanCmd = &cobra.Command{
@@ -34,24 +52,32 @@ var cleanCmd = &cobra.Command{
3452
}
3553

3654
// smudgeCmd is the Git smudge filter: it reads an encrypted .env from stdin and
37-
// writes the decrypted form to the working tree on checkout.
55+
// writes the decrypted form to the working tree on checkout. When decryption is
56+
// impossible (missing or wrong key), it passes the encrypted content through
57+
// with a warning rather than failing the whole git operation.
3858
var smudgeCmd = &cobra.Command{
3959
Use: "smudge [file]",
4060
Short: "Git smudge filter (decrypt stdin to stdout)",
4161
Hidden: true,
4262
Args: cobra.MaximumNArgs(1),
4363
RunE: func(cmd *cobra.Command, args []string) error {
44-
key, err := loadRepoKey()
45-
if err != nil {
46-
return err
64+
path := ".env"
65+
if len(args) == 1 {
66+
path = args[0]
4767
}
4868
in, err := io.ReadAll(os.Stdin)
4969
if err != nil {
5070
return err
5171
}
72+
key, err := loadRepoKey()
73+
if err != nil {
74+
_, werr := os.Stdout.Write(smudgeFallback(path, err, in))
75+
return werr
76+
}
77+
defer key.Destroy()
5278
out, err := envfile.Decrypt(in, key)
5379
if err != nil {
54-
return err
80+
out = smudgeFallback(path, err, in)
5581
}
5682
_, err = os.Stdout.Write(out)
5783
return err
@@ -66,17 +92,19 @@ var textconvCmd = &cobra.Command{
6692
Hidden: true,
6793
Args: cobra.ExactArgs(1),
6894
RunE: func(cmd *cobra.Command, args []string) error {
69-
key, err := loadRepoKey()
95+
data, err := os.ReadFile(args[0])
7096
if err != nil {
7197
return err
7298
}
73-
data, err := os.ReadFile(args[0])
99+
key, err := loadRepoKey()
74100
if err != nil {
75-
return err
101+
_, werr := os.Stdout.Write(smudgeFallback(args[0], err, data))
102+
return werr
76103
}
104+
defer key.Destroy()
77105
out, err := envfile.Decrypt(data, key)
78106
if err != nil {
79-
return err
107+
out = smudgeFallback(args[0], err, data)
80108
}
81109
_, err = os.Stdout.Write(out)
82110
return err

‎src/internal/cmd/filterprocess.go‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func runFilterProcess(stdin io.Reader, stdout io.Writer) error {
5858
return nil
5959
}
6060
command := metaValue(meta, "command")
61+
pathname := metaValue(meta, "pathname")
6162
content, err := r.ReadDataUntilFlush()
6263
if err != nil {
6364
return err
@@ -69,13 +70,21 @@ func runFilterProcess(stdin io.Reader, stdout io.Writer) error {
6970
}
7071

7172
var result []byte
72-
switch {
73-
case keyErr != nil:
74-
err = keyErr
75-
case command == "clean":
76-
result, err = envfile.Encrypt(content, key)
77-
case command == "smudge":
78-
result, err = envfile.Decrypt(content, key)
73+
switch command {
74+
case "clean":
75+
if keyErr != nil {
76+
err = keyErr
77+
} else {
78+
result, err = envfile.Encrypt(content, key)
79+
}
80+
case "smudge":
81+
// Smudge failures pass the encrypted content through so the git
82+
// operation succeeds; clean failures above stay fatal (fail closed).
83+
if keyErr != nil {
84+
result = smudgeFallback(pathname, keyErr, content)
85+
} else if result, err = envfile.Decrypt(content, key); err != nil {
86+
result, err = smudgeFallback(pathname, err, content), nil
87+
}
7988
default:
8089
err = fmt.Errorf("unsupported filter command %q", command)
8190
}

‎src/internal/cmd/filterprocess_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,41 @@ func TestFilterProcessKeyErrorFailsClosed(t *testing.T) {
7272
}
7373
}
7474

75+
func TestFilterProcessSmudgeWrongKeyPassesThrough(t *testing.T) {
76+
encKey, err := crypto.Generate()
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
wrongKey, err := crypto.Generate()
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
orig := loadFilterKey
85+
loadFilterKey = func() (*crypto.Key, error) { return wrongKey, nil }
86+
defer func() { loadFilterKey = orig }()
87+
88+
ciphertext, err := envfile.Encrypt([]byte("SECRET=hunter2\n"), encKey)
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
93+
var in bytes.Buffer
94+
w := pktline.NewWriter(&in)
95+
writeHandshake(t, w)
96+
writeCommand(t, w, "smudge", ciphertext)
97+
98+
var out bytes.Buffer
99+
if err := runFilterProcess(&in, &out); err != nil {
100+
t.Fatalf("runFilterProcess: %v", err)
101+
}
102+
103+
r := pktline.NewReader(&out)
104+
readHandshake(t, r)
105+
if got := readCommandResult(t, r); !bytes.Equal(got, ciphertext) {
106+
t.Fatalf("smudge with wrong key = %q, want encrypted pass-through %q", got, ciphertext)
107+
}
108+
}
109+
75110
func writeHandshake(t *testing.T, w *pktline.Writer) {
76111
t.Helper()
77112
must(t, w.WriteText("git-filter-client\n"))

0 commit comments

Comments
 (0)