Skip to content

Commit 9fdbef5

Browse files
committed
reorganize
1 parent 17b8a3c commit 9fdbef5

14 files changed

Lines changed: 367 additions & 123 deletions

File tree

client/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
"strings"
1717
"time"
1818

19-
"github.com/bodrovis/lokex/apierr"
19+
"github.com/bodrovis/lokex/internal/apierr"
2020
)
2121

2222
const (

client/download.go

Lines changed: 5 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,18 @@
1313
package client
1414

1515
import (
16-
"archive/zip"
1716
"context"
1817
"fmt"
1918
"io"
2019
"maps"
2120
"net/http"
2221
"os"
23-
"path"
24-
"path/filepath"
2522
"strconv"
2623
"strings"
2724

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"
3028
)
3129

3230
// Downloader wraps a *Client to perform Lokalise file exports (downloads).
@@ -197,18 +195,12 @@ func (d *Downloader) DownloadAndUnzip(ctx context.Context, bundleURL, destDir st
197195
return err
198196
}
199197
// 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)
206199
}, nil); err != nil {
207200
return err
208201
}
209202

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 {
212204
return fmt.Errorf("unzip: %w", err)
213205
}
214206
return nil
@@ -276,105 +268,3 @@ func (d *Downloader) downloadOnce(ctx context.Context, url, destPath, ua string)
276268
}
277269
return nil
278270
}
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-
}

client/download_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import (
1616
"testing"
1717
"time"
1818

19-
"github.com/bodrovis/lokex/apierr"
2019
"github.com/bodrovis/lokex/client"
21-
"github.com/bodrovis/lokex/utils"
20+
"github.com/bodrovis/lokex/internal/apierr"
21+
"github.com/bodrovis/lokex/internal/utils"
2222
"github.com/jarcoal/httpmock"
2323
)
2424

client/upload.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import (
1818
"path/filepath"
1919
"strings"
2020

21-
"github.com/bodrovis/lokex/utils"
21+
"github.com/bodrovis/lokex/internal/utils"
2222
)
2323

2424
// Uploader wraps a *Client to perform Lokalise file uploads.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"net/http"
77
"testing"
88

9-
"github.com/bodrovis/lokex/apierr"
9+
"github.com/bodrovis/lokex/internal/apierr"
1010
)
1111

1212
// Compile-time check: APIError implements error.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"net/http"
66
"testing"
77

8-
"github.com/bodrovis/lokex/apierr"
8+
"github.com/bodrovis/lokex/internal/apierr"
99
)
1010

1111
func TestParse_NonJSON(t *testing.T) {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
"testing"
1212
"time"
1313

14-
"github.com/bodrovis/lokex/apierr"
14+
"github.com/bodrovis/lokex/internal/apierr"
1515
)
1616

1717
// mock net.Error for deterministic Timeout() behaviors

0 commit comments

Comments
 (0)