324 lines
7.8 KiB
Go
324 lines
7.8 KiB
Go
package installer
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestSanitizeTarPath(t *testing.T) {
|
|
destDir := "/install"
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
wantErr bool
|
|
wantClean string
|
|
}{
|
|
{
|
|
name: "simple path",
|
|
path: "code-server/bin/code-server",
|
|
wantErr: false,
|
|
wantClean: "code-server/bin/code-server",
|
|
},
|
|
{
|
|
name: "nested path",
|
|
path: "code-server-4.96.4-macos-arm64/bin/code-server",
|
|
wantErr: false,
|
|
wantClean: "code-server-4.96.4-macos-arm64/bin/code-server",
|
|
},
|
|
{
|
|
name: "path traversal with dotdot",
|
|
path: "../etc/passwd",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "absolute path",
|
|
path: "/etc/passwd",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "current directory",
|
|
path: ".",
|
|
wantErr: false,
|
|
wantClean: ".",
|
|
},
|
|
{
|
|
name: "hidden traversal via dotdot in middle",
|
|
path: "foo/../../etc/passwd",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
clean, err := sanitizeTarPath(tt.path, destDir)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("expected error for path %q, got nil", tt.path)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("unexpected error for path %q: %v", tt.path, err)
|
|
return
|
|
}
|
|
if clean != tt.wantClean {
|
|
t.Errorf("expected clean path %q, got %q", tt.wantClean, clean)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandTemplate(t *testing.T) {
|
|
s := &GithubTarballStrategy{
|
|
config: GithubTarballConfig{
|
|
Version: "4.96.4",
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
tmpl string
|
|
target string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "asset pattern",
|
|
tmpl: "code-server-{version}-{os}-{arch}.tar.gz",
|
|
target: "macos-arm64",
|
|
expected: "code-server-4.96.4-macos-arm64.tar.gz",
|
|
},
|
|
{
|
|
name: "binary path pattern",
|
|
tmpl: "code-server-{version}-{os}-{arch}/bin/code-server",
|
|
target: "linux-amd64",
|
|
expected: "code-server-4.96.4-linux-amd64/bin/code-server",
|
|
},
|
|
{
|
|
name: "no placeholders",
|
|
tmpl: "binary",
|
|
target: "linux-amd64",
|
|
expected: "binary",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := s.expandTemplate(tt.tmpl, tt.target)
|
|
if result != tt.expected {
|
|
t.Errorf("expected %q, got %q", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz(t *testing.T) {
|
|
// Create a tar.gz archive in memory with a file and a directory
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Add a directory
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "myapp/",
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0o755,
|
|
})
|
|
|
|
// Add a file
|
|
content := []byte("#!/bin/sh\necho hello")
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "myapp/bin/hello",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o755,
|
|
Size: int64(len(content)),
|
|
})
|
|
_, _ = tarWriter.Write(content)
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
// Extract into temp dir
|
|
destDir := t.TempDir()
|
|
if err := extractTarGz(&buf, destDir); err != nil {
|
|
t.Fatalf("extractTarGz failed: %v", err)
|
|
}
|
|
|
|
// Verify the file was extracted
|
|
extractedPath := filepath.Join(destDir, "myapp", "bin", "hello")
|
|
data, err := os.ReadFile(extractedPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read extracted file: %v", err)
|
|
}
|
|
if string(data) != string(content) {
|
|
t.Errorf("expected %q, got %q", string(content), string(data))
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_RejectsTraversal(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Add a malicious entry
|
|
content := []byte("malicious")
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "../etc/passwd",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o644,
|
|
Size: int64(len(content)),
|
|
})
|
|
_, _ = tarWriter.Write(content)
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
err := extractTarGz(&buf, destDir)
|
|
if err == nil {
|
|
t.Error("expected error for path traversal, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_SymlinkEscapeBlocked(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Add a symlink that escapes the dest dir
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "escape",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "../../../../etc/passwd",
|
|
})
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
err := extractTarGz(&buf, destDir)
|
|
if err == nil {
|
|
t.Error("expected error for symlink escape, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_SymlinkWithRelativeDotDot(t *testing.T) {
|
|
// Legitimate relative symlinks using ".." (like npm .bin links) should be allowed
|
|
// as long as the resolved target stays within destDir.
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Create directory structure: pkg/node_modules/.bin/ and pkg/node_modules/semver/bin/
|
|
for _, dir := range []string{
|
|
"pkg/node_modules/.bin/",
|
|
"pkg/node_modules/semver/bin/",
|
|
} {
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: dir,
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0o755,
|
|
})
|
|
}
|
|
|
|
// Create the target file
|
|
content := []byte("#!/bin/sh\necho semver")
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/node_modules/semver/bin/semver.js",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o755,
|
|
Size: int64(len(content)),
|
|
})
|
|
_, _ = tarWriter.Write(content)
|
|
|
|
// Create symlink: .bin/semver -> ../semver/bin/semver.js (stays within destDir)
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/node_modules/.bin/semver",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "../semver/bin/semver.js",
|
|
})
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
if err := extractTarGz(&buf, destDir); err != nil {
|
|
t.Fatalf("extractTarGz should allow relative '..' symlinks within destDir, got: %v", err)
|
|
}
|
|
|
|
// Verify the symlink was created
|
|
linkPath := filepath.Join(destDir, "pkg/node_modules/.bin/semver")
|
|
linkTarget, err := os.Readlink(linkPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read symlink: %v", err)
|
|
}
|
|
if linkTarget != "../semver/bin/semver.js" {
|
|
t.Errorf("expected symlink target '../semver/bin/semver.js', got %q", linkTarget)
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_SymlinkDotDotEscapeBlocked(t *testing.T) {
|
|
// A ".." symlink that escapes destDir must still be blocked by the resolved-path check.
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/",
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0o755,
|
|
})
|
|
|
|
// Symlink that uses ".." to escape the destDir
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/escape",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "../../etc/passwd",
|
|
})
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
err := extractTarGz(&buf, destDir)
|
|
if err == nil {
|
|
t.Error("expected error for '..' symlink escaping destDir, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolveTarget_Unsupported(t *testing.T) {
|
|
s := &GithubTarballStrategy{
|
|
config: GithubTarballConfig{
|
|
Targets: map[string]string{
|
|
"linux/amd64": "linux-amd64",
|
|
},
|
|
},
|
|
}
|
|
// resolveTarget uses runtime.GOOS/GOARCH — if the test platform isn't in Targets,
|
|
// it should return an error. We can't guarantee this, so just verify the method works.
|
|
_, err := s.resolveTarget()
|
|
// The result depends on the test platform; just verify no panic.
|
|
_ = err
|
|
}
|
|
|
|
func TestBuildURL(t *testing.T) {
|
|
s := &GithubTarballStrategy{
|
|
config: GithubTarballConfig{
|
|
Owner: "coder",
|
|
Repo: "code-server",
|
|
Version: "4.96.4",
|
|
AssetPattern: "code-server-{version}-{os}-{arch}.tar.gz",
|
|
},
|
|
}
|
|
|
|
url := s.buildURL("macos-arm64")
|
|
expected := "https://github.com/coder/code-server/releases/download/v4.96.4/code-server-4.96.4-macos-arm64.tar.gz"
|
|
if url != expected {
|
|
t.Errorf("expected %q, got %q", expected, url)
|
|
}
|
|
}
|