Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions cmd/agent/git_ssh_signature.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ func NewGitSSHSignatureCmd(flags *flags.GlobalFlags) *cobra.Command {
GlobalFlags: flags,
}

gitSshSignatureCmd := &cobra.Command{
gitSSHSignatureCmd := &cobra.Command{
Use: "git-ssh-signature",
// Allow unknown flags so that git can pass any ssh-keygen flags
// (e.g. -U for stdin input) without cobra rejecting them.
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: true,
},
RunE: func(_ *cobra.Command, args []string) error {
logger := log.GetInstance()

Expand All @@ -54,10 +59,10 @@ func NewGitSSHSignatureCmd(flags *flags.GlobalFlags) *cobra.Command {
},
}

gitSshSignatureCmd.Flags().StringVarP(&cmd.CertPath, "file", "f", "", "Path to the private key")
gitSshSignatureCmd.Flags().StringVarP(&cmd.Namespace, "namespace", "n", "", "Namespace")
gitSshSignatureCmd.Flags().
gitSSHSignatureCmd.Flags().StringVarP(&cmd.CertPath, "file", "f", "", "Path to the private key")
gitSSHSignatureCmd.Flags().StringVarP(&cmd.Namespace, "namespace", "n", "", "Namespace")
gitSSHSignatureCmd.Flags().
StringVarP(&cmd.Command, "command", "Y", "sign", "Command - should be 'sign'")

return gitSshSignatureCmd
return gitSSHSignatureCmd
}
37 changes: 37 additions & 0 deletions cmd/agent/git_ssh_signature_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package agent

import (
"testing"

"github.com/skevetter/devpod/cmd/flags"
)

func TestGitSSHSignatureCmd_AcceptsUnknownFlags(t *testing.T) {
cmd := NewGitSSHSignatureCmd(&flags.GlobalFlags{})

// Simulate what git passes: -Y sign -n git -f /path/to/key -U /tmp/buffer
// We expect flag parsing to succeed (no "unknown shorthand flag" error).
err := cmd.ParseFlags(
[]string{"-Y", "sign", "-n", "git", "-f", "/path/to/key", "-U", "/tmp/buffer"},
)
if err != nil {
t.Fatalf("expected flag parsing to succeed with unknown flag -U, got: %v", err)
}
}

func TestGitSSHSignatureCmd_KnownFlagsParsed(t *testing.T) {
cmd := NewGitSSHSignatureCmd(&flags.GlobalFlags{})

err := cmd.ParseFlags([]string{"-Y", "sign", "-n", "git", "-f", "/path/to/key", "/tmp/buffer"})
if err != nil {
t.Fatalf("expected flag parsing to succeed, got: %v", err)
}

val, err := cmd.Flags().GetString("command")
if err != nil {
t.Fatalf("expected to get 'command' flag, got: %v", err)
}
if val != "sign" {
t.Fatalf("expected command flag to be 'sign', got: %q", val)
}
}
10 changes: 5 additions & 5 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ func (cmd *UpCmd) configureWorkspace(
}

if cmd.GitSSHSigningKey != "" {
if err := setupGitSSHSignature(cmd.GitSSHSigningKey, client, log); err != nil {
if err := setupGitSSHSignature(cmd.GitSSHSigningKey, client); err != nil {
return err
}
}
Expand Down Expand Up @@ -1539,7 +1539,6 @@ func collectDotfilesScriptEnvKeyvaluePairs(envFiles []string) ([]string, error)
func setupGitSSHSignature(
signingKey string,
client client2.BaseWorkspaceClient,
log log.Logger,
) error {
execPath, err := os.Executable()
if err != nil {
Expand All @@ -1555,7 +1554,8 @@ func setupGitSSHSignature(
remoteUser = "root"
}

err = exec.Command(
// #nosec G204 -- execPath is from os.Executable(), not user input
out, err := exec.Command(
execPath,
"ssh",
"--agent-forwarding=true",
Expand All @@ -1566,9 +1566,9 @@ func setupGitSSHSignature(
client.Context(),
client.Workspace(),
"--command", fmt.Sprintf("devpod agent git-ssh-signature-helper %s", signingKey),
).Run()
).CombinedOutput()
if err != nil {
log.Error("failure in setting up git ssh signature helper")
return fmt.Errorf("setup git ssh signature helper: %w, output: %s", err, string(out))
}
return nil
}
Expand Down
76 changes: 76 additions & 0 deletions e2e/tests/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"time"

"github.com/onsi/ginkgo/v2"
Expand Down Expand Up @@ -104,6 +105,81 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
// framework.ExpectNoError(err)
// })

ginkgo.It(
"should set up git SSH signature helper in workspace",
func(ctx context.Context) {
if runtime.GOOS == "windows" {
ginkgo.Skip("skipping on windows")
}

tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/ssh-signing")
framework.ExpectNoError(err)

f := framework.NewDefaultFramework(initialDir + "/bin")
_ = f.DevPodProviderAdd(ctx, "docker")
err = f.DevPodProviderUse(ctx, "docker")
framework.ExpectNoError(err)

ginkgo.DeferCleanup(func(cleanupCtx context.Context) {
_ = f.DevPodWorkspaceDelete(cleanupCtx, tempDir)
framework.CleanupTempDir(initialDir, tempDir)
})

// Generate a temporary SSH key for signing
sshKeyDir, err := os.MkdirTemp("", "devpod-ssh-signing-test")
framework.ExpectNoError(err)
defer func() { _ = os.RemoveAll(sshKeyDir) }()

keyPath := filepath.Join(sshKeyDir, "id_ed25519")
// #nosec G204 -- test command with controlled arguments
err = exec.Command(
"ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q",
).Run()
framework.ExpectNoError(err)

// Start workspace with git-ssh-signing-key flag
devpodUpCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
err = f.DevPodUp(devpodUpCtx, tempDir,
"--git-ssh-signing-key", keyPath+".pub",
)
framework.ExpectNoError(err)

sshCtx, cancelSSH := context.WithTimeout(ctx, 20*time.Second)
defer cancelSSH()

// Verify the helper script was installed
out, err := f.DevPodSSH(sshCtx, tempDir,
"test -x /usr/local/bin/devpod-ssh-signature && echo EXISTS",
)
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(
gomega.Equal("EXISTS"),
"devpod-ssh-signature helper script should be installed and executable",
)

// Verify git config has the SSH signing program set
out, err = f.DevPodSSH(sshCtx, tempDir,
"git config --global gpg.ssh.program",
)
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(
gomega.Equal("devpod-ssh-signature"),
"git gpg.ssh.program should be set to devpod-ssh-signature",
)

// Verify git config has gpg format set to ssh
out, err = f.DevPodSSH(sshCtx, tempDir,
"git config --global gpg.format",
)
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(
gomega.Equal("ssh"),
"git gpg.format should be set to ssh",
)
},
)

ginkgo.It(
"should start a new workspace with a docker provider (default) and forward a port into it",
func(ctx context.Context) {
Expand Down
4 changes: 4 additions & 0 deletions e2e/tests/ssh/testdata/ssh-signing/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "SSH Signing Test",
"image": "mcr.microsoft.com/devcontainers/go:1"
}
34 changes: 24 additions & 10 deletions pkg/gitsshsigning/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,24 +151,38 @@ func removeGitConfigHelper(gitConfigPath, userName string) error {

func removeSignatureHelper(content string) string {
scan := scanner.NewScanner(strings.NewReader(content))
isGpgSetup := false
inGpgSSHSection := false
inGpgSection := false
out := []string{}

for scan.Scan() {
line := scan.Text()
if strings.TrimSpace(line) == "[gpg \"ssh\"]" {
isGpgSetup = true
trimmed := strings.TrimSpace(line)

// Track section transitions
if len(trimmed) > 0 && trimmed[0] == '[' {
inGpgSSHSection = trimmed == `[gpg "ssh"]`
inGpgSection = trimmed == "[gpg]"

// Skip the entire [gpg "ssh"] section header (devpod-managed)
if inGpgSSHSection {
continue
}
}

// Skip all lines inside [gpg "ssh"] section
if inGpgSSHSection {
continue
} else if strings.TrimSpace(line) == "[gpg]" {
isGpgSetup = true
} else if isGpgSetup {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 0 && trimmed[0] == '[' {
isGpgSetup = false
} else {
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Inside [gpg] section, only skip devpod-managed keys
if inGpgSection && len(trimmed) > 0 && trimmed[0] != '[' {
if strings.HasPrefix(trimmed, "format = ssh") ||
strings.HasPrefix(trimmed, "program = devpod-ssh-signature") {
continue
}
}

out = append(out, line)
}

Expand Down
58 changes: 58 additions & 0 deletions pkg/gitsshsigning/helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package gitsshsigning

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)

type HelperTestSuite struct {
suite.Suite
}

func TestHelperSuite(t *testing.T) {
suite.Run(t, new(HelperTestSuite))
}

func (s *HelperTestSuite) TestRemoveSignatureHelper_PreservesUnrelatedGpgConfig() {
input := strings.Join([]string{
"[user]", "\tname = Test User", "\temail = test@example.com",
`[gpg "ssh"]`, "\tprogram = devpod-ssh-signature",
"[gpg]", "\tformat = ssh", "\tprogram = /usr/bin/gpg2",
"[commit]", "\tgpgsign = true",
"[user]", "\tsigningkey = /path/to/key",
}, "\n")

result := removeSignatureHelper(input)

assert.NotContains(s.T(), result, "devpod-ssh-signature")
assert.Contains(s.T(), result, "[user]")
assert.Contains(s.T(), result, "[commit]")
assert.Contains(s.T(), result, "program = /usr/bin/gpg2")
assert.NotContains(s.T(), result, "format = ssh")
}

func (s *HelperTestSuite) TestRemoveSignatureHelper_RemovesDevpodSections() {
input := strings.Join([]string{
"[user]", "\tname = Test User",
`[gpg "ssh"]`, "\tprogram = devpod-ssh-signature",
"[gpg]", "\tformat = ssh",
"[user]", "\tsigningkey = /path/to/key",
}, "\n")

result := removeSignatureHelper(input)

assert.NotContains(s.T(), result, "devpod-ssh-signature")
assert.NotContains(s.T(), result, "format = ssh")
assert.Contains(s.T(), result, "Test User")
}

func (s *HelperTestSuite) TestRemoveSignatureHelper_NoGpgSections() {
input := "[user]\n\tname = Test User\n\temail = test@example.com"

result := removeSignatureHelper(input)

assert.Equal(s.T(), input, result)
}
Loading