feat: file_validate_type y file_unique_name puras (issue 0014 fase 2)

This commit is contained in:
2026-04-18 17:12:05 +02:00
parent 3d47e74ec7
commit 1675d2bb84
8 changed files with 386 additions and 15 deletions
+47
View File
@@ -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()
}
+47
View File
@@ -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
+63
View File
@@ -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)
}
})
}
+67
View File
@@ -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 ""
}
+52
View File
@@ -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)
}
})
}