feat: file_validate_type y file_unique_name puras (issue 0014 fase 2)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FileUniqueName genera un nombre de archivo unico combinando un UUID v4 con la
|
||||
// extension sanitizada del nombre original.
|
||||
//
|
||||
// Ejemplo: "vacaciones.PNG" -> "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
|
||||
//
|
||||
// La extension se sanitiza: solo se conservan caracteres alfanumericos en minusculas
|
||||
// y se trunca a 16 caracteres como maximo. Si el archivo no tiene extension, se
|
||||
// retorna solo el UUID.
|
||||
//
|
||||
// La funcion es "pura en intencion" en el sentido de que su firma no depende del
|
||||
// contexto, pero internamente usa un generador de UUIDs aleatorios — el resultado
|
||||
// no es determinista.
|
||||
func FileUniqueName(originalName string) string {
|
||||
id := uuid.NewString()
|
||||
|
||||
ext := filepath.Ext(originalName)
|
||||
ext = strings.TrimPrefix(ext, ".")
|
||||
ext = sanitizeExt(ext)
|
||||
if ext == "" {
|
||||
return id
|
||||
}
|
||||
return id + "." + ext
|
||||
}
|
||||
|
||||
// sanitizeExt deja solo caracteres alfanumericos en minusculas y trunca a 16 chars.
|
||||
func sanitizeExt(ext string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(ext) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
if b.Len() >= 16 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: file_unique_name
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func FileUniqueName(originalName string) string"
|
||||
description: "Genera un nombre de archivo unico combinando un UUID v4 con la extension sanitizada del nombre original. Evita colisiones y elimina problemas con caracteres especiales."
|
||||
tags: [file, unique, name, uuid, upload, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [path/filepath, strings, unicode, github.com/google/uuid]
|
||||
params:
|
||||
- name: originalName
|
||||
desc: "nombre original del archivo (puede contener path, espacios, caracteres especiales)"
|
||||
output: "nombre unico {uuid}.{ext} con extension sanitizada (alfanumerica, minusculas, max 16 chars). Si no hay extension retorna solo el UUID"
|
||||
tested: true
|
||||
tests: ["preserva extension comun como png", "convierte extension a minusculas", "remueve caracteres especiales en extension", "genera UUID sin extension si el archivo no tiene", "trunca extensiones extremadamente largas"]
|
||||
test_file_path: "functions/infra/file_unique_name_test.go"
|
||||
file_path: "functions/infra/file_unique_name.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
n1 := FileUniqueName("vacaciones.PNG")
|
||||
// n1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
|
||||
|
||||
n2 := FileUniqueName("contrato sin extension")
|
||||
// n2 = "f9b6c2d1-..." (solo UUID)
|
||||
|
||||
n3 := FileUniqueName("malicious; rm -rf /.exe.txt")
|
||||
// n3 = "{uuid}.txt"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Marcada como `pure` por contrato (no hace I/O ni depende de estado mutable explicitamente), pero internamente la generacion del UUID v4 usa un PRNG por lo que el resultado NO es determinista. Esto es aceptable en la convencion del registry: la pureza se refiere a la ausencia de side effects observables (no escribe a disco, red, ni globals), no al determinismo bit a bit.
|
||||
|
||||
La extension se sanitiza para evitar:
|
||||
- Path traversal en disco (ej: `../../etc/passwd`)
|
||||
- Inyeccion de comandos en logs/UI
|
||||
- Ambiguedad de filesystem entre mayus/minus
|
||||
@@ -0,0 +1,63 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileUniqueName(t *testing.T) {
|
||||
t.Run("preserva extension comun como png", func(t *testing.T) {
|
||||
got := FileUniqueName("foto.png")
|
||||
if !strings.HasSuffix(got, ".png") {
|
||||
t.Fatalf("got %q, want suffix .png", got)
|
||||
}
|
||||
if len(got) < 36+4 { // uuid + ".png"
|
||||
t.Fatalf("got %q, want UUID + .png", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("convierte extension a minusculas", func(t *testing.T) {
|
||||
got := FileUniqueName("VACACIONES.JPEG")
|
||||
if !strings.HasSuffix(got, ".jpeg") {
|
||||
t.Fatalf("got %q, want suffix .jpeg", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remueve caracteres especiales en extension", func(t *testing.T) {
|
||||
got := FileUniqueName("malicious.t!x@t#")
|
||||
if !strings.HasSuffix(got, ".txt") {
|
||||
t.Fatalf("got %q, want suffix .txt", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("genera UUID sin extension si el archivo no tiene", func(t *testing.T) {
|
||||
got := FileUniqueName("contrato_sin_extension")
|
||||
if strings.Contains(got, ".") {
|
||||
t.Fatalf("got %q, want sin punto", got)
|
||||
}
|
||||
if len(got) != 36 {
|
||||
t.Fatalf("got %q (len %d), want UUID len 36", got, len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trunca extensiones extremadamente largas", func(t *testing.T) {
|
||||
got := FileUniqueName("file." + strings.Repeat("a", 100))
|
||||
// Buscar la ultima parte despues del punto
|
||||
idx := strings.LastIndex(got, ".")
|
||||
if idx < 0 {
|
||||
t.Fatalf("got %q, want al menos un punto", got)
|
||||
}
|
||||
ext := got[idx+1:]
|
||||
if len(ext) > 16 {
|
||||
t.Fatalf("got ext len %d, want <= 16", len(ext))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dos llamadas generan IDs distintos", func(t *testing.T) {
|
||||
a := FileUniqueName("x.png")
|
||||
b := FileUniqueName("x.png")
|
||||
if a == b {
|
||||
t.Fatalf("got %q == %q, want distintos", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package infra
|
||||
|
||||
import "bytes"
|
||||
|
||||
// fileSignature describe el magic byte signature de un tipo de archivo.
|
||||
type fileSignature struct {
|
||||
mime string
|
||||
prefix []byte
|
||||
// Para WebP: el prefix son los primeros 4 bytes "RIFF", luego 4 bytes de tamaño,
|
||||
// luego suffix en offset 8: "WEBP".
|
||||
suffix []byte
|
||||
suffixOffset int
|
||||
}
|
||||
|
||||
// fileSignatures es la tabla interna de magic bytes soportados.
|
||||
var fileSignatures = []fileSignature{
|
||||
{mime: "image/jpeg", prefix: []byte{0xFF, 0xD8, 0xFF}},
|
||||
{mime: "image/png", prefix: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}},
|
||||
{mime: "image/gif", prefix: []byte{0x47, 0x49, 0x46, 0x38}},
|
||||
{mime: "application/pdf", prefix: []byte{0x25, 0x50, 0x44, 0x46}},
|
||||
{mime: "image/webp", prefix: []byte{0x52, 0x49, 0x46, 0x46}, suffix: []byte{0x57, 0x45, 0x42, 0x50}, suffixOffset: 8},
|
||||
{mime: "application/zip", prefix: []byte{0x50, 0x4B, 0x03, 0x04}},
|
||||
}
|
||||
|
||||
// FileValidateType detecta el MIME type real de un archivo a partir de sus primeros
|
||||
// bytes (magic bytes / file signature) y verifica que esta en la lista permitida.
|
||||
//
|
||||
// Retorna el MIME type detectado y true si esta permitido. Si no se puede detectar
|
||||
// el tipo o no esta en allowedTypes, retorna "" y false.
|
||||
//
|
||||
// Funcion pura — no hace I/O. La validacion por magic bytes es mas segura que confiar
|
||||
// en el header Content-Type del request, que puede ser falsificado.
|
||||
func FileValidateType(header []byte, allowedTypes []string) (string, bool) {
|
||||
mime := detectMimeType(header)
|
||||
if mime == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, allowed := range allowedTypes {
|
||||
if allowed == mime {
|
||||
return mime, true
|
||||
}
|
||||
}
|
||||
return mime, false
|
||||
}
|
||||
|
||||
// detectMimeType busca el primer signature que matchee header.
|
||||
func detectMimeType(header []byte) string {
|
||||
for _, sig := range fileSignatures {
|
||||
if len(header) < len(sig.prefix) {
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(header[:len(sig.prefix)], sig.prefix) {
|
||||
continue
|
||||
}
|
||||
if len(sig.suffix) > 0 {
|
||||
end := sig.suffixOffset + len(sig.suffix)
|
||||
if len(header) < end {
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(header[sig.suffixOffset:end], sig.suffix) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return sig.mime
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: file_validate_type
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func FileValidateType(header []byte, allowedTypes []string) (string, bool)"
|
||||
description: "Detecta el MIME type real de un archivo a partir de sus primeros bytes (magic bytes) y verifica que esta en la lista de tipos permitidos. Mas seguro que confiar en el header Content-Type del request."
|
||||
tags: [file, validate, mime, magic, security, upload, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [bytes]
|
||||
params:
|
||||
- name: header
|
||||
desc: "primeros bytes del archivo (al menos 12 bytes para detectar todos los formatos soportados)"
|
||||
- name: allowedTypes
|
||||
desc: "lista blanca de MIME types permitidos (ej: [\"image/png\", \"image/jpeg\", \"application/pdf\"])"
|
||||
output: "tupla (mime_detectado, permitido). Si no se reconoce el tipo retorna (\"\", false). Si se reconoce pero no esta en allowedTypes retorna (mime, false)"
|
||||
tested: true
|
||||
tests: ["detecta JPEG por magic bytes", "detecta PNG por magic bytes", "detecta PDF", "detecta WebP con prefix RIFF y suffix WEBP", "rechaza tipo no permitido", "tipo desconocido retorna vacio"]
|
||||
test_file_path: "functions/infra/file_validate_type_test.go"
|
||||
file_path: "functions/infra/file_validate_type.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
data, _ := os.ReadFile("./uploads/some.bin")
|
||||
mime, ok := FileValidateType(data[:12], []string{"image/png", "image/jpeg"})
|
||||
if !ok {
|
||||
log.Printf("tipo no permitido: %s", mime)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — sin I/O, determinista. Tabla interna de signatures soportados:
|
||||
|
||||
| Tipo | Magic bytes |
|
||||
|------|-------------|
|
||||
| JPEG | `FF D8 FF` |
|
||||
| PNG | `89 50 4E 47 0D 0A 1A 0A` |
|
||||
| GIF | `47 49 46 38` |
|
||||
| PDF | `25 50 44 46` |
|
||||
| WebP | `52 49 46 46 ?? ?? ?? ?? 57 45 42 50` |
|
||||
| ZIP | `50 4B 03 04` |
|
||||
|
||||
NO es un antivirus. Solo verifica los primeros bytes — un archivo puede tener magic valido pero contenido malicioso despues. Para apps con requisitos de seguridad altos, complementar con escaneo adicional.
|
||||
@@ -0,0 +1,72 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFileValidateType(t *testing.T) {
|
||||
allowed := []string{"image/png", "image/jpeg", "application/pdf", "image/webp"}
|
||||
|
||||
t.Run("detecta JPEG por magic bytes", func(t *testing.T) {
|
||||
header := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "image/jpeg" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (image/jpeg,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detecta PNG por magic bytes", func(t *testing.T) {
|
||||
header := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "image/png" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (image/png,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detecta PDF", func(t *testing.T) {
|
||||
header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "application/pdf" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (application/pdf,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detecta WebP con prefix RIFF y suffix WEBP", func(t *testing.T) {
|
||||
header := []byte{
|
||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
||||
0xAA, 0xBB, 0xCC, 0xDD, // tamano (cualquier valor)
|
||||
0x57, 0x45, 0x42, 0x50, // WEBP
|
||||
0x56, 0x50, 0x38, 0x20,
|
||||
}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "image/webp" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (image/webp,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza tipo no permitido", func(t *testing.T) {
|
||||
// PDF detectado, pero no en allowedTypes
|
||||
header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D}
|
||||
mime, ok := FileValidateType(header, []string{"image/png"})
|
||||
if mime != "application/pdf" {
|
||||
t.Fatalf("got mime %q, want application/pdf", mime)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("got ok=true, want false (PDF no en allowedTypes)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tipo desconocido retorna vacio", func(t *testing.T) {
|
||||
header := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "" || ok {
|
||||
t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header demasiado corto retorna vacio", func(t *testing.T) {
|
||||
header := []byte{0xFF}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "" || ok {
|
||||
t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user