Skip to content

Commit 341ada2

Browse files
authored
fix: harden template application and parity helpers (#19)
* test(parity): harden matrix helper normalization * fix(templates): isolate extraction and support JSONC metadata * ci: remove disabled code quality upload
1 parent ab721fa commit 341ada2

7 files changed

Lines changed: 159 additions & 25 deletions

File tree

.github/workflows/go-cli.yml

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ jobs:
1616
runs-on: ubuntu-latest
1717
permissions:
1818
contents: read
19-
code-quality: write
2019
steps:
2120
- uses: actions/checkout@v7
2221
- uses: actions/setup-go@v7
@@ -39,19 +38,6 @@ jobs:
3938
- run: task build
4039
- run: ./devcontainer --version
4140

42-
# Convert the Go coverage profile (coverage.out) to Cobertura XML for
43-
# GitHub's native Code Quality feature.
44-
- name: Convert coverage to Cobertura XML
45-
run: |
46-
go install github.com/boumenot/gocover-cobertura@latest
47-
gocover-cobertura < coverage.out > coverage.xml
48-
- name: Upload coverage report
49-
uses: actions/upload-code-coverage@v1
50-
with:
51-
file: coverage.xml
52-
language: Go
53-
label: code-coverage/go
54-
5541
# Binary covdata for the cross-lane merge (coverage-report job).
5642
- uses: actions/upload-artifact@v7
5743
if: always()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestGoCLIWorkflowDoesNotUploadDisabledCodeQualityCoverage(t *testing.T) {
11+
workflowPath := filepath.Join("..", "..", ".github", "workflows", "go-cli.yml")
12+
data, err := os.ReadFile(workflowPath)
13+
if err != nil {
14+
t.Fatalf("read %s: %v", workflowPath, err)
15+
}
16+
17+
workflow := string(data)
18+
for _, disabledSetting := range []string{"actions/upload-code-coverage", "code-quality: write"} {
19+
if strings.Contains(workflow, disabledSetting) {
20+
t.Errorf("%s still contains disabled Code Quality setting %q", workflowPath, disabledSetting)
21+
}
22+
}
23+
}

internal/cli/parity_matrix_helpers_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@ func TestExtractCLIResultEnv_EmbeddedJSON(t *testing.T) {
168168
}
169169
}
170170

171+
func TestNormalizeRequired(t *testing.T) {
172+
got := normalizeRequired("One of --workspace-folder or --workspace-folder-data is required.")
173+
want := "workspace-folder,workspace-folder-data"
174+
if got != want {
175+
t.Fatalf("normalizeRequired() = %q, want %q", got, want)
176+
}
177+
}
178+
179+
func TestComposeProjectName(t *testing.T) {
180+
got := composeProjectName("Build_Feature-1 / alpine")
181+
want := "dcbuild_feature-1alpine"
182+
if got != want {
183+
t.Fatalf("composeProjectName() = %q, want %q", got, want)
184+
}
185+
}
186+
171187
// TestInShardPartitions proves the shard split is a proper partition: with N
172188
// shards, every case is claimed by exactly one shard and the union is the whole
173189
// set (no case dropped, none run twice).

internal/cli/parity_matrix_test.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,9 @@ var reChoiceYargs = regexp.MustCompile(`(?m)Argument:\s*([^,]+),\s*Given:\s*"([^
921921
var reChoiceGo = regexp.MustCompile(`(?m)Invalid value "([^"]+)" for --([^.\s]+)\.\s*Choose from:\s*(.+)$`)
922922
var reInvalidMode = regexp.MustCompile(`(?m)Invalid mode "([^"]+)".*Choose from:\s*(.+)$`)
923923
var reSetupEnv = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)=(.*)$`)
924+
var reRequiredPrefix = regexp.MustCompile(`(?i)^One of\s+`)
925+
var reRequiredSuffix = regexp.MustCompile(`(?i)\s+is required\.?$`)
926+
var reRequiredSplit = regexp.MustCompile(`\s+or\s+|,\s*`)
924927

925928
func matchChoiceYargs(text string) string {
926929
match := reChoiceYargs.FindStringSubmatch(text)
@@ -968,9 +971,9 @@ func normalizeChoices(raw string) string {
968971
}
969972

970973
func normalizeRequired(raw string) string {
971-
raw = regexp.MustCompile(`(?i)^One of\s+`).ReplaceAllString(raw, "")
972-
raw = regexp.MustCompile(`(?i)\s+is required\.?$`).ReplaceAllString(raw, "")
973-
parts := regexp.MustCompile(`\s+or\s+|,\s*`).Split(raw, -1)
974+
raw = reRequiredPrefix.ReplaceAllString(raw, "")
975+
raw = reRequiredSuffix.ReplaceAllString(raw, "")
976+
parts := reRequiredSplit.Split(raw, -1)
974977
var clean []string
975978
for _, p := range parts {
976979
p = strings.TrimSpace(strings.TrimPrefix(p, "--"))
@@ -1089,13 +1092,17 @@ func composeProjectName(caseID string) string {
10891092
var b strings.Builder
10901093
b.WriteString("dc")
10911094
for _, r := range strings.ToLower(caseID) {
1092-
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
1095+
if isComposeProjectNameRune(r) {
10931096
b.WriteRune(r)
10941097
}
10951098
}
10961099
return b.String()
10971100
}
10981101

1102+
func isComposeProjectNameRune(r rune) bool {
1103+
return (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_'
1104+
}
1105+
10991106
func sanitizeEnvValue(value string) string {
11001107
replacer := strings.NewReplacer("/", "-", " ", "-", ":", "-", "\t", "-", "\n", "-")
11011108
return replacer.Replace(value)

internal/templates/apply.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,19 @@ func FetchAndApply(params ApplyParams, selected SelectedTemplate) ([]string, err
6060
return nil, fmt.Errorf("fetch template blob: %w", err)
6161
}
6262

63-
// Extract to temp dir
64-
tmpDir := params.TmpDir
65-
if tmpDir == "" {
66-
tmpDir = os.TempDir()
63+
// Extract to a unique temporary directory when the caller did not provide
64+
// one. Caller-provided directories retain the stable per-template layout and
65+
// remain caller-owned.
66+
var extractDir string
67+
if params.TmpDir == "" {
68+
extractDir, err = os.MkdirTemp("", "devcontainer-template-")
69+
if err != nil {
70+
return nil, fmt.Errorf("create extract dir: %w", err)
71+
}
72+
defer os.RemoveAll(extractDir)
73+
} else {
74+
extractDir = filepath.Join(params.TmpDir, "template-"+ref.ID)
6775
}
68-
extractDir := filepath.Join(tmpDir, "template-"+ref.ID)
6976
if err := fsys.MkdirAll(extractDir); err != nil {
7077
return nil, fmt.Errorf("create extract dir: %w", err)
7178
}
@@ -191,7 +198,9 @@ func mergeFeatures(fsys pfs.FS, workspaceFolder string, featureOpts []TemplateFe
191198
return fmt.Errorf("parse %s: %w", configPath, stdErr)
192199
}
193200
var config map[string]json.RawMessage
194-
json.Unmarshal(stdData, &config)
201+
if err := json.Unmarshal(stdData, &config); err != nil {
202+
return fmt.Errorf("unmarshal %s: %w", configPath, err)
203+
}
195204
existing := map[string]bool{}
196205
_, hasFeatures := config["features"]
197206
if hasFeatures {
@@ -262,8 +271,12 @@ func applyOptionDefaults(fsys pfs.FS, extractDir string, userOptions map[string]
262271
if err != nil {
263272
return merged
264273
}
274+
standardized, err := hujson.Standardize(data)
275+
if err != nil {
276+
return merged
277+
}
265278
var meta TemplateMetadata
266-
if err := json.Unmarshal(data, &meta); err != nil {
279+
if err := json.Unmarshal(standardized, &meta); err != nil {
267280
return merged
268281
}
269282
for key, raw := range meta.Options {

internal/templates/apply_fetch_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,76 @@ func TestFetchAndApply_Success(t *testing.T) {
194194
}
195195
}
196196

197+
func TestFetchAndApply_DefaultTempDirIsRemoved(t *testing.T) {
198+
tests := []struct {
199+
name string
200+
blob func(*testing.T) []byte
201+
wantErr bool
202+
}{
203+
{
204+
name: "success",
205+
blob: func(t *testing.T) []byte {
206+
return buildTemplateTarGz(t, templateEntries())
207+
},
208+
},
209+
{
210+
name: "extraction error",
211+
blob: func(*testing.T) []byte {
212+
return []byte("not a valid gzip tarball")
213+
},
214+
wantErr: true,
215+
},
216+
}
217+
218+
for _, tt := range tests {
219+
t.Run(tt.name, func(t *testing.T) {
220+
tempRoot := t.TempDir()
221+
t.Setenv("TMPDIR", tempRoot)
222+
params := ApplyParams{
223+
OCIClient: &fakeTemplateRegistry{blob: tt.blob(t)},
224+
FS: pfs.OSFS{},
225+
Logger: log.Null,
226+
WorkspaceFolder: t.TempDir(),
227+
}
228+
229+
_, err := FetchAndApply(params, SelectedTemplate{ID: "ghcr.io/devcontainers/templates/sample:1"})
230+
if (err != nil) != tt.wantErr {
231+
t.Fatalf("FetchAndApply() error = %v, wantErr %v", err, tt.wantErr)
232+
}
233+
entries, readErr := os.ReadDir(tempRoot)
234+
if readErr != nil {
235+
t.Fatal(readErr)
236+
}
237+
if len(entries) != 0 {
238+
t.Fatalf("default temp root contains %v after FetchAndApply", entries)
239+
}
240+
})
241+
}
242+
}
243+
244+
func TestFetchAndApply_CallerTempDirIsPreserved(t *testing.T) {
245+
tempRoot := t.TempDir()
246+
params := ApplyParams{
247+
OCIClient: &fakeTemplateRegistry{blob: buildTemplateTarGz(t, templateEntries())},
248+
FS: pfs.OSFS{},
249+
Logger: log.Null,
250+
WorkspaceFolder: t.TempDir(),
251+
TmpDir: tempRoot,
252+
}
253+
254+
_, err := FetchAndApply(params, SelectedTemplate{ID: "ghcr.io/devcontainers/templates/sample:1"})
255+
if err != nil {
256+
t.Fatalf("FetchAndApply: %v", err)
257+
}
258+
entries, err := os.ReadDir(tempRoot)
259+
if err != nil {
260+
t.Fatal(err)
261+
}
262+
if len(entries) != 1 || entries[0].Name() != "template-sample" {
263+
t.Fatalf("caller temp root contains %v, want preserved template-sample directory", entries)
264+
}
265+
}
266+
197267
// TestFetchAndApply_PartialWorkspaceWrite covers the partial-write risk: a WriteFile
198268
// that fails mid-Walk. The first workspace file is written, the second fails,
199269
// and the error must propagate (wrapped) instead of silently leaving a partial

internal/templates/apply_pure_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,25 @@ func TestApplyOptionDefaults(t *testing.T) {
6060
}
6161
}
6262

63+
func TestApplyOptionDefaults_JSONCMetadata(t *testing.T) {
64+
dir := t.TempDir()
65+
metadata := []byte(`{
66+
// Template metadata permits JSON with comments and trailing commas.
67+
"id": "x",
68+
"options": {
69+
"imageVariant": { "type": "string", "default": "bookworm", },
70+
},
71+
}`)
72+
if err := os.WriteFile(filepath.Join(dir, "devcontainer-template.json"), metadata, 0o644); err != nil {
73+
t.Fatal(err)
74+
}
75+
76+
got := applyOptionDefaults(pfs.OSFS{}, dir, nil, log.Null)
77+
if got["imageVariant"] != "bookworm" {
78+
t.Fatalf("imageVariant = %q, want JSONC default %q", got["imageVariant"], "bookworm")
79+
}
80+
}
81+
6382
func TestApplyOptionDefaults_NoMetadata(t *testing.T) {
6483
// Missing devcontainer-template.json → returns the user options unchanged.
6584
dir := t.TempDir()

0 commit comments

Comments
 (0)