feat(files): add archive content listing preview
ci / check (push) Successful in 1m12s

Lista conteudo de .zip, .tar, .tar.gz e .gz sem extrair, com limite anti-bomba.
This commit is contained in:
lgcosta committed 2026-11-06 09:55:56 -03:00
1 parent dc829b9d43
commit f6a74e1092
6 files changed
+389 -15

No files matched your search

+159
View File
@@ -1,6 +1,9 @@
package store
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -56,6 +59,162 @@ type FileNode struct {
URL string `json:"url"`
}
// ArchiveEntry descreve um item dentro de um arquivo compactado.
type ArchiveEntry struct {
Name string `json:"name"`
Size int64 `json:"size"` // -1 = tamanho desconhecido sem extrair
Dir bool `json:"dir"`
}
// maxArchiveEntries limita quantos itens da listagem são devolvidos.
const maxArchiveEntries = 2000
// maxArchiveScanBytes limita a leitura ao contar bytes descomprimidos de um
// .gz simples (proteção contra bomba de descompressão).
const maxArchiveScanBytes = 1 << 30
// isArchiveExt diz se o nome tem extensão de compactado com listagem
// suportada (zip/tar/tar.gz/tgz/gz, sem extrair nada em disco).
func isArchiveExt(name string) bool {
l := strings.ToLower(name)
return strings.HasSuffix(l, ".zip") || strings.HasSuffix(l, ".tar") ||
strings.HasSuffix(l, ".tar.gz") || strings.HasSuffix(l, ".tgz") ||
strings.HasSuffix(l, ".gz")
}
// cleanArchiveName higieniza um nome vindo de dentro do compactado para
// exibição (UTF-8 válido, tamanho limitado).
func cleanArchiveName(name string) string {
name = strings.ToValidUTF8(name, "�")
name = strings.Trim(name, "\x00")
if len(name) > 512 {
name = name[:512] + "…"
}
if name == "" {
name = "(sem nome)"
}
return name
}
// ListArchive lista o conteúdo de um compactado (.zip, .tar, .tar.gz, .tgz
// ou .gz simples) lendo só os cabeçalhos — nada é extraído em disco.
// Devolve os itens (no máximo maxArchiveEntries), o total e se houve corte.
func (s *Store) ListArchive(id string) (entries []ArchiveEntry, total int, truncated bool, err error) {
s.mu.RLock()
m := s.fileByID(id)
var p, name string
if m != nil {
name = m.Name
p = s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))
}
s.mu.RUnlock()
if m == nil {
return nil, 0, false, ErrNotFound
}
if !isArchiveExt(name) {
return nil, 0, false, fmt.Errorf("%w: listagem suportada apenas para .zip, .tar, .tar.gz e .gz", ErrInvalid)
}
f, err := os.Open(p)
if err != nil {
return nil, 0, false, ErrNotFound
}
defer f.Close()
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".zip"):
st, err := f.Stat()
if err != nil {
return nil, 0, false, ErrNotFound
}
return listZipEntries(f, st.Size())
case strings.HasSuffix(lower, ".tar"):
return listTarEntries(f)
default: // .tar.gz, .tgz ou .gz simples
single := !strings.Contains(lower, ".tar") && !strings.HasSuffix(lower, ".tgz")
return listTarGzEntries(f, single, strings.TrimSuffix(name, filepath.Ext(name)))
}
}
// listZipEntries lê o diretório central do zip (sem descomprimir).
func listZipEntries(r io.ReaderAt, size int64) ([]ArchiveEntry, int, bool, error) {
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, 0, false, fmt.Errorf("%w: zip inválido ou corrompido", ErrInvalid)
}
total := len(zr.File)
out := make([]ArchiveEntry, 0, min(total, maxArchiveEntries))
for _, zf := range zr.File {
if len(out) >= maxArchiveEntries {
return out, total, true, nil
}
out = append(out, ArchiveEntry{
Name: cleanArchiveName(zf.Name),
Size: int64(zf.UncompressedSize64),
Dir: zf.FileInfo().IsDir(),
})
}
return out, total, false, nil
}
// listTarEntries percorre os cabeçalhos do tar (sem extrair).
func listTarEntries(r io.Reader) ([]ArchiveEntry, int, bool, error) {
tr := tar.NewReader(r)
var out []ArchiveEntry
total := 0
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
if total == 0 {
return nil, 0, false, fmt.Errorf("%w: tar inválido ou corrompido", ErrInvalid)
}
return out, total, true, nil
}
total++
if len(out) < maxArchiveEntries {
out = append(out, ArchiveEntry{
Name: cleanArchiveName(hdr.Name),
Size: hdr.Size,
Dir: hdr.FileInfo().IsDir(),
})
}
}
if out == nil {
out = []ArchiveEntry{}
}
return out, total, total > len(out), nil
}
// listTarGzEntries abre um .tar.gz/.tgz (lista o tar interno) ou um .gz
// simples (descreve o arquivo único, contando bytes com teto anti-bomba).
func listTarGzEntries(r io.Reader, single bool, fallbackName string) ([]ArchiveEntry, int, bool, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, 0, false, fmt.Errorf("%w: gzip inválido ou corrompido", ErrInvalid)
}
defer gz.Close()
if !single {
return listTarEntries(gz)
}
name := strings.TrimSpace(gz.Name)
if name == "" {
name = fallbackName
}
n, err := io.Copy(io.Discard, io.LimitReader(gz, maxArchiveScanBytes+1))
if err != nil {
return nil, 0, false, fmt.Errorf("%w: gzip inválido ou corrompido", ErrInvalid)
}
size := n
truncated := false
if n > maxArchiveScanBytes {
size = maxArchiveScanBytes
truncated = true
}
return []ArchiveEntry{{Name: cleanArchiveName(name), Size: size}}, 1, truncated, nil
}
// TreeNode é a hierarquia completa (pastas aninhadas + arquivos).
type TreeNode struct {
Folder