Skip to content

Commit d014249

Browse files
committed
refactor: remove normalizeZip - server handles zips without directory entries
The Coder server's upload handler (coderd/files.go) converts uploaded ZIPs to tar via archive.CreateTarFromZip(), and the tar extractor (provisionersdk.Untar) calls os.MkdirAll(filepath.Dir(target)) for each file entry, creating parent directories automatically. There is no requirement for explicit directory entries in the uploaded ZIP.
1 parent 9ebda24 commit d014249

3 files changed

Lines changed: 5 additions & 337 deletions

File tree

internal/provider/template_resource.go

Lines changed: 5 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
package provider
22

33
import (
4-
"archive/zip"
54
"bufio"
6-
"bytes"
75
"context"
86
"encoding/json"
97
"fmt"
108
"io"
119
"os"
12-
stdpath "path"
1310
"path/filepath"
1411
"slices"
15-
"sort"
1612
"strings"
1713
"time"
1814

@@ -1241,121 +1237,19 @@ func archiveContentType(archivePath string) (string, error) {
12411237
}
12421238
}
12431239

1244-
// normalizeZip rewrites a zip archive to ensure that all intermediate directory
1245-
// entries exist. Some tools (e.g. Terraform's archive_file data source) produce
1246-
// zips that contain only file entries without explicit directory entries. Coder's
1247-
// server-side zip extractor expects directories to be present.
1248-
func normalizeZip(archivePath string) ([]byte, error) {
1249-
r, err := zip.OpenReader(archivePath)
1250-
if err != nil {
1251-
return nil, fmt.Errorf("failed to open zip: %w", err)
1252-
}
1253-
defer r.Close() //nolint:errcheck // Best-effort close of read-only zip.
1254-
1255-
// Collect the set of directories that already have explicit entries.
1256-
existingDirs := make(map[string]bool)
1257-
for _, f := range r.File {
1258-
if f.FileInfo().IsDir() {
1259-
existingDirs[f.Name] = true
1260-
}
1261-
}
1262-
1263-
// Determine which intermediate directories are missing.
1264-
missingDirs := make(map[string]bool)
1265-
for _, f := range r.File {
1266-
dir := stdpath.Dir(f.Name)
1267-
for dir != "." && dir != "/" && dir != "" {
1268-
dirEntry := dir + "/"
1269-
if existingDirs[dirEntry] || missingDirs[dirEntry] {
1270-
break
1271-
}
1272-
missingDirs[dirEntry] = true
1273-
dir = stdpath.Dir(dir)
1274-
}
1275-
}
1276-
1277-
// If nothing is missing, read and return the original file as-is.
1278-
if len(missingDirs) == 0 {
1279-
data, err := os.ReadFile(archivePath)
1280-
if err != nil {
1281-
return nil, fmt.Errorf("failed to read archive: %w", err)
1282-
}
1283-
return data, nil
1284-
}
1285-
1286-
// Sort missing dirs so parents come before children.
1287-
sortedMissing := make([]string, 0, len(missingDirs))
1288-
for d := range missingDirs {
1289-
sortedMissing = append(sortedMissing, d)
1290-
}
1291-
sort.Strings(sortedMissing)
1292-
1293-
// Rewrite the zip with directory entries first, then all original entries.
1294-
var buf bytes.Buffer
1295-
w := zip.NewWriter(&buf)
1296-
1297-
// Add missing directory entries.
1298-
for _, d := range sortedMissing {
1299-
_, err := w.CreateHeader(&zip.FileHeader{
1300-
Name: d,
1301-
ExternalAttrs: 0o755 << 16, // rwxr-xr-x in Unix mode
1302-
CreatorVersion: 3 << 8, // Unix
1303-
})
1304-
if err != nil {
1305-
return nil, fmt.Errorf("failed to create directory entry %q: %w", d, err)
1306-
}
1307-
}
1308-
1309-
// Copy all original entries.
1310-
for _, f := range r.File {
1311-
fw, err := w.CreateHeader(&f.FileHeader)
1312-
if err != nil {
1313-
return nil, fmt.Errorf("failed to create entry %q: %w", f.Name, err)
1314-
}
1315-
if f.FileInfo().IsDir() {
1316-
continue
1317-
}
1318-
rc, err := f.Open()
1319-
if err != nil {
1320-
return nil, fmt.Errorf("failed to open entry %q: %w", f.Name, err)
1321-
}
1322-
_, err = io.Copy(fw, rc)
1323-
_ = rc.Close()
1324-
if err != nil {
1325-
return nil, fmt.Errorf("failed to copy entry %q: %w", f.Name, err)
1326-
}
1327-
}
1328-
1329-
if err := w.Close(); err != nil {
1330-
return nil, fmt.Errorf("failed to finalize zip: %w", err)
1331-
}
1332-
return buf.Bytes(), nil
1333-
}
1334-
13351240
func uploadArchive(ctx context.Context, client *codersdk.Client, archivePath string) (*codersdk.UploadResponse, error) {
13361241
contentType, err := archiveContentType(archivePath)
13371242
if err != nil {
13381243
return nil, err
13391244
}
13401245

1341-
var reader io.Reader
1342-
if contentType == codersdk.ContentTypeZip {
1343-
// Normalize the zip to ensure intermediate directory entries exist.
1344-
data, err := normalizeZip(archivePath)
1345-
if err != nil {
1346-
return nil, fmt.Errorf("failed to normalize zip: %w", err)
1347-
}
1348-
reader = bytes.NewReader(data)
1349-
} else {
1350-
f, err := os.Open(archivePath)
1351-
if err != nil {
1352-
return nil, fmt.Errorf("failed to open archive: %w", err)
1353-
}
1354-
defer f.Close() //nolint:errcheck // Best-effort close; upload already consumed the reader.
1355-
reader = bufio.NewReader(f)
1246+
f, err := os.Open(archivePath)
1247+
if err != nil {
1248+
return nil, fmt.Errorf("failed to open archive: %w", err)
13561249
}
1250+
defer f.Close() //nolint:errcheck // Best-effort close; upload already consumed the reader.
13571251

1358-
resp, err := client.Upload(ctx, contentType, reader)
1252+
resp, err := client.Upload(ctx, contentType, bufio.NewReader(f))
13591253
if err != nil {
13601254
return nil, err
13611255
}

internal/provider/template_resource_test.go

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package provider
33
import (
44
"archive/tar"
55
"archive/zip"
6-
"bytes"
76
"context"
87
"fmt"
98
"os"
@@ -1781,39 +1780,4 @@ func TestAccArchiveUploadFlow(t *testing.T) {
17811780
require.NoError(t, err)
17821781
require.Equal(t, hash1, hash2)
17831782
})
1784-
1785-
t.Run("ZipArchiveNormalization", func(t *testing.T) {
1786-
t.Parallel()
1787-
tmpDir := t.TempDir()
1788-
1789-
// Create a zip with a file in a nested directory but no directory entries
1790-
zipPath := filepath.Join(tmpDir, "template.zip")
1791-
zipFile, err := os.Create(zipPath)
1792-
require.NoError(t, err)
1793-
defer zipFile.Close() //nolint:errcheck
1794-
1795-
zw := zip.NewWriter(zipFile)
1796-
w, err := zw.Create("subdir/template.tf")
1797-
require.NoError(t, err)
1798-
_, err = w.Write([]byte("resource \"null_resource\" \"test\" {}"))
1799-
require.NoError(t, err)
1800-
_ = zw.Close() //nolint:errcheck
1801-
1802-
// Normalize the zip
1803-
normalized, err := normalizeZip(zipPath)
1804-
require.NoError(t, err)
1805-
1806-
// Verify normalized zip has directory entry for "subdir/"
1807-
zr, err := zip.NewReader(bytes.NewReader(normalized), int64(len(normalized)))
1808-
require.NoError(t, err)
1809-
1810-
hasSubdirEntry := false
1811-
for _, f := range zr.File {
1812-
if f.Name == "subdir/" {
1813-
hasSubdirEntry = true
1814-
break
1815-
}
1816-
}
1817-
require.True(t, hasSubdirEntry, "normalized zip should contain directory entry for subdir/")
1818-
})
18191783
}

internal/provider/util_test.go

Lines changed: 0 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"archive/tar"
55
"archive/zip"
66
"bytes"
7-
"io"
87
"os"
98
"path/filepath"
109
"testing"
@@ -91,195 +90,6 @@ func TestComputeArchiveHash(t *testing.T) {
9190
})
9291
}
9392

94-
func TestNormalizeZip(t *testing.T) {
95-
t.Parallel()
96-
97-
t.Run("AddsMissingDirectoryEntries", func(t *testing.T) {
98-
t.Parallel()
99-
// Create a zip with only file entries (no directory entries),
100-
// mimicking hashicorp/archive's archive_file data source.
101-
zipPath := filepath.Join(t.TempDir(), "nodirs.zip")
102-
var buf bytes.Buffer
103-
zw := zip.NewWriter(&buf)
104-
105-
// Top-level regular file.
106-
fw, err := zw.Create("main.tf")
107-
require.NoError(t, err)
108-
_, err = fw.Write([]byte("resource {}"))
109-
require.NoError(t, err)
110-
111-
// Top-level hidden file (dotfile at root).
112-
fw, err = zw.Create(".env")
113-
require.NoError(t, err)
114-
_, err = fw.Write([]byte("SECRET=value"))
115-
require.NoError(t, err)
116-
117-
// Top-level hidden directory with a file inside.
118-
fw, err = zw.Create(".config/settings.json")
119-
require.NoError(t, err)
120-
_, err = fw.Write([]byte(`{"key": "value"}`))
121-
require.NoError(t, err)
122-
123-
// Hidden file inside a regular directory.
124-
fw, err = zw.Create("subdir/.hidden")
125-
require.NoError(t, err)
126-
_, err = fw.Write([]byte("hidden content"))
127-
require.NoError(t, err)
128-
129-
// Deeply nested hidden directory with a hidden file inside.
130-
fw, err = zw.Create("a/b/.secret-dir/.credentials")
131-
require.NoError(t, err)
132-
_, err = fw.Write([]byte("token=abc123"))
133-
require.NoError(t, err)
134-
135-
require.NoError(t, zw.Close())
136-
err = os.WriteFile(zipPath, buf.Bytes(), 0644)
137-
require.NoError(t, err)
138-
139-
// Verify original has no directory entries.
140-
origReader, err := zip.OpenReader(zipPath)
141-
require.NoError(t, err)
142-
for _, f := range origReader.File {
143-
require.False(t, f.FileInfo().IsDir(), "expected no dir entries in original, got %q", f.Name)
144-
}
145-
require.NoError(t, origReader.Close())
146-
147-
// Normalize.
148-
data, err := normalizeZip(zipPath)
149-
require.NoError(t, err)
150-
151-
// Read normalized zip and collect entries.
152-
normReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
153-
require.NoError(t, err)
154-
155-
dirs := make(map[string]bool)
156-
files := make(map[string]bool)
157-
for _, f := range normReader.File {
158-
if f.FileInfo().IsDir() {
159-
dirs[f.Name] = true
160-
} else {
161-
files[f.Name] = true
162-
}
163-
}
164-
165-
// All intermediate directories should exist, including hidden ones.
166-
require.True(t, dirs[".config/"], "missing .config/")
167-
require.True(t, dirs["subdir/"], "missing subdir/")
168-
require.True(t, dirs["a/"], "missing a/")
169-
require.True(t, dirs["a/b/"], "missing a/b/")
170-
require.True(t, dirs["a/b/.secret-dir/"], "missing a/b/.secret-dir/")
171-
172-
// All original files should still be present.
173-
require.True(t, files["main.tf"], "missing main.tf")
174-
require.True(t, files[".env"], "missing .env")
175-
require.True(t, files[".config/settings.json"], "missing .config/settings.json")
176-
require.True(t, files["subdir/.hidden"], "missing subdir/.hidden")
177-
require.True(t, files["a/b/.secret-dir/.credentials"], "missing a/b/.secret-dir/.credentials")
178-
})
179-
180-
t.Run("PreservesDirectoryPermissions", func(t *testing.T) {
181-
t.Parallel()
182-
// Verify that synthesized directory entries have proper mode bits
183-
// so the server-side extractor can create them.
184-
zipPath := filepath.Join(t.TempDir(), "needsperms.zip")
185-
var buf bytes.Buffer
186-
zw := zip.NewWriter(&buf)
187-
188-
fw, err := zw.Create("deep/nested/dir/file.txt")
189-
require.NoError(t, err)
190-
_, err = fw.Write([]byte("content"))
191-
require.NoError(t, err)
192-
require.NoError(t, zw.Close())
193-
err = os.WriteFile(zipPath, buf.Bytes(), 0644)
194-
require.NoError(t, err)
195-
196-
data, err := normalizeZip(zipPath)
197-
require.NoError(t, err)
198-
199-
normReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
200-
require.NoError(t, err)
201-
202-
for _, f := range normReader.File {
203-
if f.FileInfo().IsDir() {
204-
mode := f.Mode()
205-
require.NotZero(t, mode&0700,
206-
"directory %q should have owner rwx bits set, got %s", f.Name, mode)
207-
}
208-
}
209-
})
210-
211-
t.Run("NoChangeWhenDirsExist", func(t *testing.T) {
212-
t.Parallel()
213-
zipPath := filepath.Join(t.TempDir(), "withdirs.zip")
214-
var buf bytes.Buffer
215-
zw := zip.NewWriter(&buf)
216-
217-
// Add directory entry explicitly.
218-
_, err := zw.Create("subdir/")
219-
require.NoError(t, err)
220-
fw, err := zw.Create("subdir/file.txt")
221-
require.NoError(t, err)
222-
_, err = fw.Write([]byte("content"))
223-
require.NoError(t, err)
224-
require.NoError(t, zw.Close())
225-
226-
origData := buf.Bytes()
227-
err = os.WriteFile(zipPath, origData, 0644)
228-
require.NoError(t, err)
229-
230-
// Normalize should return original bytes unchanged.
231-
data, err := normalizeZip(zipPath)
232-
require.NoError(t, err)
233-
require.Equal(t, origData, data)
234-
})
235-
236-
t.Run("PreservesFileContent", func(t *testing.T) {
237-
t.Parallel()
238-
// Verify normalization doesn't corrupt file contents,
239-
// including hidden files at various levels.
240-
zipPath := filepath.Join(t.TempDir(), "content.zip")
241-
var buf bytes.Buffer
242-
zw := zip.NewWriter(&buf)
243-
244-
fileContents := map[string]string{
245-
".gitignore": "node_modules/\n.env\n",
246-
"main.tf": "resource \"null_resource\" \"test\" {}",
247-
".vscode/settings.json": `{"editor.formatOnSave": true}`,
248-
"scripts/.local/bin/deploy": "#!/bin/bash\necho deploy",
249-
}
250-
for name, content := range fileContents {
251-
fw, err := zw.Create(name)
252-
require.NoError(t, err)
253-
_, err = fw.Write([]byte(content))
254-
require.NoError(t, err)
255-
}
256-
require.NoError(t, zw.Close())
257-
err := os.WriteFile(zipPath, buf.Bytes(), 0644)
258-
require.NoError(t, err)
259-
260-
data, err := normalizeZip(zipPath)
261-
require.NoError(t, err)
262-
263-
normReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
264-
require.NoError(t, err)
265-
266-
for _, f := range normReader.File {
267-
if f.FileInfo().IsDir() {
268-
continue
269-
}
270-
expected, ok := fileContents[f.Name]
271-
require.True(t, ok, "unexpected file in normalized zip: %q", f.Name)
272-
rc, err := f.Open()
273-
require.NoError(t, err)
274-
actual, err := io.ReadAll(rc)
275-
require.NoError(t, rc.Close())
276-
require.NoError(t, err)
277-
require.Equal(t, expected, string(actual),
278-
"content mismatch for %q", f.Name)
279-
}
280-
})
281-
}
282-
28393
func TestArchiveContentType(t *testing.T) {
28494
t.Parallel()
28595

0 commit comments

Comments
 (0)