Skip to content

Commit 4f3f3b3

Browse files
authored
Merge pull request #1795 from gruntwork-io/james/oss-3397-ssh-file-handle-leaks
fix(ssh): close file handles in SCPDirFromContextE and guard listFileInRemoteDir
2 parents bcbe891 + 8996afb commit 4f3f3b3

2 files changed

Lines changed: 67 additions & 7 deletions

File tree

modules/ssh/ssh.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,11 @@ func SCPDirFromContextE(t testing.TestingT, ctx context.Context, options *SCPDow
276276
logger.Default.Logf(t, "Copying remote file: %s to local path %s", fullRemoteFilePath, localFilePath)
277277

278278
err = copyFileFromRemote(ctx, t, sshSession, localFile, fullRemoteFilePath, useSudo)
279+
// Close the local file regardless of copy outcome so we do not leak file handles.
280+
if closeErr := localFile.Close(); closeErr != nil && err == nil {
281+
err = closeErr
282+
}
283+
279284
errorsOccurred = multierror.Append(errorsOccurred, err)
280285
}
281286

@@ -651,11 +656,18 @@ func FetchContentsOfFileContext(t testing.TestingT, ctx context.Context, host *H
651656
return out
652657
}
653658

659+
// shellQuote wraps a path in single quotes, escaping any embedded single quotes,
660+
// so that paths containing spaces or shell metacharacters work correctly when
661+
// passed to commands like `cat` and `dd if=`.
662+
func shellQuote(s string) string {
663+
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
664+
}
665+
654666
// FetchContentsOfFileContextE connects to the given host via SSH and fetches the contents of the file at the given filePath.
655667
// If useSudo is true, then the contents will be retrieved using sudo. Returns the contents of that file.
656668
// The ctx parameter supports cancellation and timeouts.
657669
func FetchContentsOfFileContextE(t testing.TestingT, ctx context.Context, host *Host, useSudo bool, filePath string) (string, error) {
658-
command := "cat " + filePath
670+
command := "cat " + shellQuote(filePath)
659671
if useSudo {
660672
command = "sudo " + command
661673
}
@@ -707,15 +719,22 @@ func listFileInRemoteDir(ctx context.Context, t testing.TestingT, sshSession *SS
707719

708720
// The last character returned is `\n` this results in an extra "" array
709721
// member when we do the split below. Cut off the last character to avoid
710-
// having to remove the blank entry in the array.
711-
resultString = resultString[:len(resultString)-1]
722+
// having to remove the blank entry in the array. Guard against empty output
723+
// so we do not panic with index out of range.
724+
if len(resultString) > 0 {
725+
resultString = resultString[:len(resultString)-1]
726+
}
712727

713728
return strings.Split(resultString, "\n"), nil
714729
}
715730

716731
// copyFileFromRemote copies a file from a remote host to a local file.
717732
// Based on code: https://github.com/bramvdbogaerde/go-scp/pull/6/files
718733
func copyFileFromRemote(ctx context.Context, t testing.TestingT, sshSession *SSHSession, file *os.File, remotePath string, useSudo bool) error {
734+
// Ensure the local file handle is always closed; the caller passes us an
735+
// open *os.File and we own its lifetime from here.
736+
defer func() { _ = file.Close() }()
737+
719738
if err := setUpSSHClient(ctx, sshSession); err != nil {
720739
return err
721740
}
@@ -724,20 +743,20 @@ func copyFileFromRemote(ctx context.Context, t testing.TestingT, sshSession *SSH
724743
return err
725744
}
726745

727-
command := "dd if=" + remotePath
746+
command := "dd if=" + shellQuote(remotePath)
728747
if useSudo {
729748
command = "sudo " + command
730749
}
731750

732751
logger.Default.Logf(t, "Running command %s on %s@%s", command, sshSession.Options.Username, sshSession.Options.Address)
733752

753+
defer func() { _ = sshSession.Session.Close() }()
754+
734755
r, err := sshSession.Session.Output(command)
735756
if err != nil {
736-
logger.Default.Logf(t, "error reading from remote stdout: %s", err)
757+
return fmt.Errorf("error reading from remote stdout: %w", err)
737758
}
738759

739-
defer func() { _ = sshSession.Session.Close() }()
740-
741760
// Write to local file.
742761
_, err = file.Write(r)
743762

modules/ssh/ssh_unit_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package ssh //nolint:testpackage // white-box test for unexported shellQuote helper
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestShellQuote(t *testing.T) {
10+
t.Parallel()
11+
12+
testCases := []struct {
13+
name string
14+
input string
15+
expected string
16+
}{
17+
{
18+
name: "no special chars",
19+
input: "/etc/hostname",
20+
expected: "'/etc/hostname'",
21+
},
22+
{
23+
name: "embedded single quote",
24+
input: "it's",
25+
expected: `'it'\''s'`,
26+
},
27+
{
28+
name: "spaces",
29+
input: "/path with spaces/file.txt",
30+
expected: "'/path with spaces/file.txt'",
31+
},
32+
}
33+
34+
for _, tc := range testCases {
35+
tc := tc
36+
t.Run(tc.name, func(t *testing.T) {
37+
t.Parallel()
38+
assert.Equal(t, tc.expected, shellQuote(tc.input))
39+
})
40+
}
41+
}

0 commit comments

Comments
 (0)