|
13 | 13 | package client |
14 | 14 |
|
15 | 15 | import ( |
16 | | - "archive/zip" |
17 | 16 | "context" |
18 | 17 | "fmt" |
19 | 18 | "io" |
20 | 19 | "maps" |
21 | 20 | "net/http" |
22 | 21 | "os" |
23 | | - "path" |
24 | | - "path/filepath" |
25 | 22 | "strconv" |
26 | 23 | "strings" |
27 | 24 |
|
28 | | - "github.com/bodrovis/lokex/apierr" |
29 | | - "github.com/bodrovis/lokex/utils" |
| 25 | + "github.com/bodrovis/lokex/internal/apierr" |
| 26 | + "github.com/bodrovis/lokex/internal/utils" |
| 27 | + "github.com/bodrovis/lokex/internal/zipx" |
30 | 28 | ) |
31 | 29 |
|
32 | 30 | // Downloader wraps a *Client to perform Lokalise file exports (downloads). |
@@ -197,18 +195,12 @@ func (d *Downloader) DownloadAndUnzip(ctx context.Context, bundleURL, destDir st |
197 | 195 | return err |
198 | 196 | } |
199 | 197 | // validate it's a real zip; if not, return ErrUnexpectedEOF to trigger retry |
200 | | - zr, zerr := zip.OpenReader(tmpPath) |
201 | | - if zerr != nil { |
202 | | - return fmt.Errorf("zip validate: %w", io.ErrUnexpectedEOF) |
203 | | - } |
204 | | - _ = zr.Close() |
205 | | - return nil |
| 198 | + return zipx.Validate(tmpPath) |
206 | 199 | }, nil); err != nil { |
207 | 200 | return err |
208 | 201 | } |
209 | 202 |
|
210 | | - // unzip after a validated download |
211 | | - if err := unzipSafe(tmpPath, destDir); err != nil { |
| 203 | + if err := zipx.Unzip(tmpPath, destDir, zipx.DefaultPolicy()); err != nil { |
212 | 204 | return fmt.Errorf("unzip: %w", err) |
213 | 205 | } |
214 | 206 | return nil |
@@ -276,105 +268,3 @@ func (d *Downloader) downloadOnce(ctx context.Context, url, destPath, ua string) |
276 | 268 | } |
277 | 269 | return nil |
278 | 270 | } |
279 | | - |
280 | | -// unzipSafe extracts a zip archive to destDir with multiple safety checks: |
281 | | -// |
282 | | -// - Ensures the number of files and their total uncompressed size are bounded. |
283 | | -// - Guards against zip-slip by verifying resulting absolute paths remain |
284 | | -// under destDir. |
285 | | -// - Skips special files (symlinks, devices, fifos, sockets). |
286 | | -// - Preserves regular file perms when present; otherwise falls back to 0644. |
287 | | -func unzipSafe(srcZip, destDir string) error { |
288 | | - r, err := zip.OpenReader(srcZip) |
289 | | - if err != nil { |
290 | | - return err |
291 | | - } |
292 | | - defer func() { |
293 | | - _ = r.Close() |
294 | | - }() |
295 | | - |
296 | | - if err := os.MkdirAll(destDir, 0o755); err != nil { |
297 | | - return err |
298 | | - } |
299 | | - destAbs, err := filepath.Abs(destDir) |
300 | | - if err != nil { |
301 | | - return err |
302 | | - } |
303 | | - |
304 | | - const ( |
305 | | - maxFiles = 20000 |
306 | | - maxTotalUnzip = 2 << 30 // 2 GiB |
307 | | - maxSingleUnzip = 512 << 20 // 512 MiB |
308 | | - ) |
309 | | - |
310 | | - var total int64 |
311 | | - if len(r.File) > maxFiles { |
312 | | - return fmt.Errorf("zip too many files: %d", len(r.File)) |
313 | | - } |
314 | | - |
315 | | - for _, f := range r.File { |
316 | | - if f.UncompressedSize64 > maxSingleUnzip { |
317 | | - return fmt.Errorf("zip entry too big: %s (%d bytes)", f.Name, f.UncompressedSize64) |
318 | | - } |
319 | | - if total += int64(f.UncompressedSize64); total > maxTotalUnzip { |
320 | | - return fmt.Errorf("zip too large uncompressed: %d", total) |
321 | | - } |
322 | | - // Normalize path inside the zip (remove leading slashes, clean ..) |
323 | | - rel := path.Clean(f.Name) |
324 | | - rel = strings.TrimPrefix(rel, "/") |
325 | | - rel = strings.TrimPrefix(rel, "./") |
326 | | - if rel == "." || rel == "" { |
327 | | - continue |
328 | | - } |
329 | | - targetPath := filepath.Join(destDir, rel) |
330 | | - |
331 | | - // zip-slip guard: ensure final path is still under destDir |
332 | | - targetAbs, err := filepath.Abs(targetPath) |
333 | | - if err != nil { |
334 | | - return err |
335 | | - } |
336 | | - if targetAbs != destAbs && !strings.HasPrefix(targetAbs, destAbs+string(filepath.Separator)) { |
337 | | - return fmt.Errorf("unsafe path in zip: %q", f.Name) |
338 | | - } |
339 | | - |
340 | | - info := f.FileInfo() |
341 | | - if info.IsDir() { |
342 | | - if err := os.MkdirAll(targetAbs, 0o755); err != nil { |
343 | | - return err |
344 | | - } |
345 | | - continue |
346 | | - } |
347 | | - |
348 | | - mode := info.Mode() |
349 | | - // skip risky types |
350 | | - if mode&os.ModeSymlink != 0 || mode&os.ModeDevice != 0 || mode&os.ModeNamedPipe != 0 || mode&os.ModeSocket != 0 { |
351 | | - continue |
352 | | - } |
353 | | - |
354 | | - if err := os.MkdirAll(filepath.Dir(targetAbs), 0o755); err != nil { |
355 | | - return err |
356 | | - } |
357 | | - rc, err := f.Open() |
358 | | - if err != nil { |
359 | | - return err |
360 | | - } |
361 | | - perm := mode.Perm() |
362 | | - if perm == 0 { |
363 | | - perm = 0o644 // sane default if zip lacks perms |
364 | | - } |
365 | | - out, err := os.OpenFile(targetAbs, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) |
366 | | - if err != nil { |
367 | | - defer func() { _ = rc.Close() }() |
368 | | - return err |
369 | | - } |
370 | | - _, copyErr := io.Copy(out, rc) |
371 | | - defer func() { _ = rc.Close() }() |
372 | | - if cerr := out.Close(); copyErr == nil && cerr != nil { |
373 | | - copyErr = cerr |
374 | | - } |
375 | | - if copyErr != nil { |
376 | | - return copyErr |
377 | | - } |
378 | | - } |
379 | | - return nil |
380 | | -} |
0 commit comments