Merged in feature/support-more-types (pull request #219)
support new types for import * tests pass * missing file * bug fixes
This commit is contained in:
@@ -141,6 +141,10 @@ func (s *Service) Get(ctx context.Context, clientID string, batchID uuid.UUID) (
|
||||
}
|
||||
details.DocumentOutcomes = outcomes
|
||||
|
||||
// Derive summary counts from actual outcome states rather than
|
||||
// the extraction-phase counters stored in batch_uploads.
|
||||
s.deriveSummaryCounts(details)
|
||||
|
||||
return details, nil
|
||||
}
|
||||
|
||||
@@ -197,6 +201,23 @@ func (s *Service) UpdateProgress(ctx context.Context, clientID string, batchID u
|
||||
})
|
||||
}
|
||||
|
||||
// SetTotalDocuments updates the total document count for a batch.
|
||||
// Called after ZIP extraction when the actual file count is known.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - batchID: the batch UUID
|
||||
// - total: the total number of files extracted from the ZIP
|
||||
//
|
||||
// Returns:
|
||||
// - error: database error, or nil
|
||||
func (s *Service) SetTotalDocuments(ctx context.Context, batchID uuid.UUID, total int32) error {
|
||||
return s.cfg.GetDBQueries().SetBatchTotalDocuments(ctx, &repository.SetBatchTotalDocumentsParams{
|
||||
ID: batchID,
|
||||
TotalDocuments: total,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateStatus updates the status of a batch
|
||||
func (s *Service) UpdateStatus(ctx context.Context, batchID uuid.UUID, status string) error {
|
||||
// Convert string status to repository.BatchStatus
|
||||
@@ -407,6 +428,49 @@ func (s *Service) CheckDuplicate(ctx context.Context, clientID string, hash stri
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// deriveSummaryCounts recomputes ProcessedDocuments, FailedDocuments, and
|
||||
// InvalidTypeDocuments from the actual document outcome states. This ensures the
|
||||
// summary reflects final pipeline results rather than extraction-phase counters.
|
||||
//
|
||||
// Terminal outcome bucketing:
|
||||
//
|
||||
// processed: clean_passed, sync_skipped, duplicate, init_duplicate
|
||||
// failed: clean_failed, failed_open, failed_read, failed_s3_upload, failed_upload
|
||||
// invalid_type: invalid_type
|
||||
// in-progress: submitted, init_complete, sync_complete (not expected at completion)
|
||||
func (s *Service) deriveSummaryCounts(details *BatchUploadDetails) {
|
||||
if len(details.DocumentOutcomes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var processed, failed, invalidType int32
|
||||
for _, o := range details.DocumentOutcomes {
|
||||
switch o.Outcome {
|
||||
case "clean_passed", "sync_skipped", "duplicate", "init_duplicate":
|
||||
processed++
|
||||
case "clean_failed", "failed_open", "failed_read",
|
||||
"failed_s3_upload", "failed_upload":
|
||||
failed++
|
||||
case "invalid_type":
|
||||
invalidType++
|
||||
default:
|
||||
// Non-terminal states (submitted, init_complete, sync_complete)
|
||||
// are in-progress; don't count them in any bucket.
|
||||
}
|
||||
}
|
||||
|
||||
details.ProcessedDocuments = processed
|
||||
details.FailedDocuments = failed
|
||||
details.InvalidTypeDocuments = invalidType
|
||||
|
||||
// Recalculate progress percent from derived counts
|
||||
total := details.TotalDocuments
|
||||
if total > 0 {
|
||||
done := processed + failed + invalidType
|
||||
details.ProgressPercent = int32(float64(done) / float64(total) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// ListUnprocessed retrieves all unprocessed batch uploads across all clients
|
||||
func (s *Service) ListUnprocessed(ctx context.Context) ([]*BatchUploadSummary, error) {
|
||||
batches, err := s.cfg.GetDBQueries().GetUnprocessedBatches(ctx)
|
||||
|
||||
@@ -716,6 +716,88 @@ func TestListOutcomes_WithCleanFail(t *testing.T) {
|
||||
assert.Equal(t, "invalid_mimetype", *outcomes[0].CleanFail)
|
||||
}
|
||||
|
||||
func TestSetTotalDocuments(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
// Create test client
|
||||
clientID := "test_client_set_total"
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client Set Total",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch with totalDocs=0
|
||||
batchID, err := service.Create(ctx, clientID, "test.zip", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set total documents to 15
|
||||
err = service.SetTotalDocuments(ctx, batchID, 15)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fetch and assert TotalDocuments == 15
|
||||
batch, err := cfg.GetDBQueries().GetBatchUpload(ctx, &repository.GetBatchUploadParams{
|
||||
ID: batchID,
|
||||
ClientID: clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(15), batch.TotalDocuments)
|
||||
|
||||
// Update progress and verify all fields
|
||||
err = service.UpdateProgress(ctx, clientID, batchID, 10, 3, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
batch, err = cfg.GetDBQueries().GetBatchUpload(ctx, &repository.GetBatchUploadParams{
|
||||
ID: batchID,
|
||||
ClientID: clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(10), batch.ProcessedDocuments)
|
||||
assert.Equal(t, int32(3), batch.FailedDocuments)
|
||||
assert.Equal(t, int32(2), batch.InvalidTypeDocuments)
|
||||
assert.Equal(t, int32(100), batch.ProgressPercent) // (10+3+2)/15 * 100 = 100%
|
||||
}
|
||||
|
||||
func TestSetTotalDocuments_ThenUpdateProgress(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
// Create test client
|
||||
clientID := "test_client_set_total_flow"
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client Set Total Flow",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch with totalDocs=0
|
||||
batchID, err := service.Create(ctx, clientID, "flow.zip", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set total to 20
|
||||
err = service.SetTotalDocuments(ctx, batchID, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update progress (10 processed, 5 failed, 3 invalid)
|
||||
err = service.UpdateProgress(ctx, clientID, batchID, 10, 5, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert ProgressPercent == 90 (18/20 * 100)
|
||||
batch, err := cfg.GetDBQueries().GetBatchUpload(ctx, &repository.GetBatchUploadParams{
|
||||
ID: batchID,
|
||||
ClientID: clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(90), batch.ProgressPercent)
|
||||
}
|
||||
|
||||
// TestCheckDuplicate creates a document, then verifies that CheckDuplicate
|
||||
// returns its ID when the hash matches.
|
||||
func TestCheckDuplicate(t *testing.T) {
|
||||
@@ -751,3 +833,141 @@ func TestCheckDuplicate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, noMatch)
|
||||
}
|
||||
|
||||
// TestGet_DerivesSummaryCountsFromOutcomes verifies that the Get endpoint
|
||||
// derives ProcessedDocuments, FailedDocuments, and InvalidTypeDocuments from
|
||||
// actual document outcome states rather than the extraction-phase counters
|
||||
// stored in batch_uploads.
|
||||
func TestGet_DerivesSummaryCountsFromOutcomes(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
|
||||
clientID := "test_derive_counts_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client Derive Counts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch -- extraction-phase counters will all be zero
|
||||
batchID, err := service.Create(ctx, clientID, "mixed.zip", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set total so progress percent can be calculated
|
||||
err = service.SetTotalDocuments(ctx, batchID, 7)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create real documents for outcomes that require document_id
|
||||
docA, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID, Hash: "hash_a", BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
docB, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID, Hash: "hash_b", BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
docC, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID, Hash: "hash_c", BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Record outcomes: 3 processed, 3 failed, 1 invalid_type
|
||||
err = service.RecordOutcome(ctx, batchID, "a.pdf",
|
||||
repository.BatchOutcomeStatusCleanPassed, nil, &docA)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "b.pdf",
|
||||
repository.BatchOutcomeStatusDuplicate, nil, &docB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "c.pdf",
|
||||
repository.BatchOutcomeStatusInitDuplicate, nil, &docC)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "d.pdf",
|
||||
repository.BatchOutcomeStatusCleanFailed, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
errOpen := "cannot open"
|
||||
err = service.RecordOutcome(ctx, batchID, "e.pdf",
|
||||
repository.BatchOutcomeStatusFailedOpen, &errOpen, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
errRead := "truncated"
|
||||
err = service.RecordOutcome(ctx, batchID, "f.pdf",
|
||||
repository.BatchOutcomeStatusFailedRead, &errRead, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "g.exe",
|
||||
repository.BatchOutcomeStatusInvalidType, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Call Get and assert derived summary counts
|
||||
details, err := service.Get(ctx, clientID, batchID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int32(3), details.ProcessedDocuments,
|
||||
"processed should count clean_passed + duplicate + init_duplicate")
|
||||
assert.Equal(t, int32(3), details.FailedDocuments,
|
||||
"failed should count clean_failed + failed_open + failed_read")
|
||||
assert.Equal(t, int32(1), details.InvalidTypeDocuments,
|
||||
"invalid_type should count invalid_type outcomes")
|
||||
assert.Equal(t, int32(100), details.ProgressPercent,
|
||||
"progress should be 100%% when all 7 outcomes are terminal")
|
||||
assert.Equal(t, int32(7), details.TotalDocuments)
|
||||
}
|
||||
|
||||
// TestGet_DerivedCountsWithInProgressOutcomes verifies that non-terminal
|
||||
// outcomes (submitted, init_complete, sync_complete) are not counted in
|
||||
// any summary bucket.
|
||||
func TestGet_DerivedCountsWithInProgressOutcomes(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
|
||||
clientID := "test_inprog_counts_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client InProgress Counts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := service.Create(ctx, clientID, "partial.zip", 0)
|
||||
require.NoError(t, err)
|
||||
err = service.SetTotalDocuments(ctx, batchID, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 1 terminal, 2 non-terminal
|
||||
docA, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID, Hash: "hash_term", BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "a.pdf",
|
||||
repository.BatchOutcomeStatusCleanPassed, nil, &docA)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "b.pdf",
|
||||
repository.BatchOutcomeStatusSubmitted, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "c.pdf",
|
||||
repository.BatchOutcomeStatusInitComplete, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
details, err := service.Get(ctx, clientID, batchID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int32(1), details.ProcessedDocuments,
|
||||
"only terminal processed outcomes should be counted")
|
||||
assert.Equal(t, int32(0), details.FailedDocuments,
|
||||
"no failed outcomes recorded")
|
||||
assert.Equal(t, int32(0), details.InvalidTypeDocuments)
|
||||
assert.Equal(t, int32(33), details.ProgressPercent,
|
||||
"only 1 of 3 outcomes is terminal: 33%%")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
documenttypes "queryorchestration/internal/document/types"
|
||||
@@ -46,9 +48,15 @@ func (s *Service) executeCleanTasks(ctx context.Context, params *CleanParams) (*
|
||||
length = *out.ContentLength
|
||||
}
|
||||
|
||||
mimeType, err := s.getAcceptedMimeType(ctx, params, out.ContentType, length)
|
||||
extension := strings.ToLower(path.Ext(params.Key.String()))
|
||||
mimeType, err := s.getAcceptedMimeType(ctx, params, out.ContentType, length, extension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if mimeType == documenttypes.MimeTypeOLE2Encrypted {
|
||||
reason := documenttypes.InvalidDocumentPasswordProtected
|
||||
return &ExecuteCleanResponse{
|
||||
failReason: &reason,
|
||||
}, nil
|
||||
} else if mimeType == documenttypes.MimeTypeInvalid {
|
||||
reason := documenttypes.InvalidDocumentMimeType
|
||||
return &ExecuteCleanResponse{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package documentclean
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
documenttypes "queryorchestration/internal/document/types"
|
||||
|
||||
@@ -12,29 +16,49 @@ import (
|
||||
|
||||
var AcceptMimeTypes = []documenttypes.MimeType{
|
||||
documenttypes.MimeTypePDF,
|
||||
documenttypes.MimeTypeTIFF,
|
||||
documenttypes.MimeTypeJPEG,
|
||||
documenttypes.MimeTypePNG,
|
||||
documenttypes.MimeTypeBMP,
|
||||
documenttypes.MimeTypeDOCX,
|
||||
documenttypes.MimeTypeXLSX,
|
||||
documenttypes.MimeTypePPTX,
|
||||
documenttypes.MimeTypeTXT,
|
||||
documenttypes.MimeTypeEML,
|
||||
}
|
||||
|
||||
const (
|
||||
PDFSignatureStr = "%PDF-"
|
||||
)
|
||||
|
||||
const (
|
||||
PDFSignatureEndStr = "%%EOF"
|
||||
)
|
||||
|
||||
// Magic byte signatures ordered from most-specific to least-specific.
|
||||
var (
|
||||
PDFSignature = []byte(PDFSignatureStr)
|
||||
sigPNG = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // 8 bytes
|
||||
sigOLE2 = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1} // 8 bytes
|
||||
sigPDF = []byte(PDFSignatureStr) // 5 bytes prefix
|
||||
sigTIFF_LE = []byte{0x49, 0x49, 0x2A, 0x00} // 4 bytes little-endian
|
||||
sigTIFF_BE = []byte{0x4D, 0x4D, 0x00, 0x2A} // 4 bytes big-endian
|
||||
sigZIP = []byte{0x50, 0x4B, 0x03, 0x04} // 4 bytes
|
||||
sigJPEG = []byte{0xFF, 0xD8, 0xFF} // 3 bytes
|
||||
sigBMP = []byte{0x42, 0x4D} // 2 bytes
|
||||
)
|
||||
|
||||
const (
|
||||
PDFSignatureStr = "%PDF-"
|
||||
PDFSignatureEndStr = "%%EOF"
|
||||
)
|
||||
|
||||
var (
|
||||
PDFSignatureEnd = []byte(PDFSignatureEndStr)
|
||||
)
|
||||
|
||||
// emlHeaderPattern matches RFC 822 header fields (e.g., "From:", "Subject:").
|
||||
var emlHeaderPattern = regexp.MustCompile(`(?m)^[A-Za-z][A-Za-z0-9-]*:`)
|
||||
|
||||
// emlRequiredHeaders are the headers that confirm a file is an email message.
|
||||
var emlRequiredHeaders = []string{"From:", "To:", "Subject:", "Date:"}
|
||||
|
||||
// getAcceptedMimeType determines the file's MIME type by inspecting actual content.
|
||||
// S3 ContentType metadata is derived from the file extension and cannot be trusted,
|
||||
// so content-based validation (magic bytes) always runs first.
|
||||
func (s *Service) getAcceptedMimeType(ctx context.Context, params *CleanParams, contentType *string, length int64) (documenttypes.MimeType, error) {
|
||||
bodyMimeType, err := s.getBodyMimeType(ctx, params, length)
|
||||
func (s *Service) getAcceptedMimeType(ctx context.Context, params *CleanParams, contentType *string, length int64, extension string) (documenttypes.MimeType, error) {
|
||||
bodyMimeType, err := s.getBodyMimeType(ctx, params, length, extension)
|
||||
if err != nil {
|
||||
return documenttypes.MimeTypeInvalid, err
|
||||
}
|
||||
@@ -70,23 +94,179 @@ func (s *Service) getMetadataMimeType(contentType *string) documenttypes.MimeTyp
|
||||
return documenttypes.MimeTypeInvalid
|
||||
}
|
||||
|
||||
func (s *Service) getBodyMimeType(ctx context.Context, params *CleanParams, length int64) (documenttypes.MimeType, error) {
|
||||
startBuffer, err := s.getBytesBuffer(ctx, params, 0, 5)
|
||||
if err != nil {
|
||||
return documenttypes.MimeTypeInvalid, err
|
||||
}
|
||||
endBuffer, err := s.getBytesBuffer(ctx, params, length-6, length)
|
||||
// getBodyMimeType determines MIME type from file content magic bytes.
|
||||
// Detection order is most-specific to least-specific signature length:
|
||||
// PNG(8) -> OLE2(8) -> PDF(5+5) -> TIFF(4) -> ZIP/Office(4) -> JPEG(3) -> BMP(2) -> EML(text) -> TXT(fallback)
|
||||
func (s *Service) getBodyMimeType(ctx context.Context, params *CleanParams, length int64, extension string) (documenttypes.MimeType, error) {
|
||||
// Read first 8 bytes for binary signature detection
|
||||
startBuffer, err := s.getBytesBuffer(ctx, params, 0, 8)
|
||||
if err != nil {
|
||||
return documenttypes.MimeTypeInvalid, err
|
||||
}
|
||||
|
||||
prefixMimeType := documenttypes.MimeTypeInvalid
|
||||
|
||||
if bytes.HasPrefix(startBuffer, PDFSignature) && bytes.HasSuffix(endBuffer, PDFSignatureEnd) {
|
||||
prefixMimeType = documenttypes.MimeTypePDF
|
||||
// 1. PNG (8 bytes -- most specific)
|
||||
if bytes.HasPrefix(startBuffer, sigPNG) {
|
||||
return documenttypes.MimeTypePNG, nil
|
||||
}
|
||||
|
||||
return prefixMimeType, nil
|
||||
// 2. OLE2 (8 bytes) -- encrypted Office documents use OLE2 wrapper
|
||||
if bytes.HasPrefix(startBuffer, sigOLE2) {
|
||||
// Fetch full file to probe for EncryptedPackage stream.
|
||||
fullBuf, fullErr := s.getBytesBuffer(ctx, params, 0, length)
|
||||
if fullErr != nil {
|
||||
return documenttypes.MimeTypeInvalid, fullErr
|
||||
}
|
||||
if documenttypes.IsOLE2Encrypted(fullBuf) {
|
||||
return documenttypes.MimeTypeOLE2Encrypted, nil
|
||||
}
|
||||
return documenttypes.MimeTypeInvalid, nil
|
||||
}
|
||||
|
||||
// 3. PDF (5-byte prefix + suffix, with tolerance for trailing whitespace)
|
||||
if bytes.HasPrefix(startBuffer, sigPDF) {
|
||||
endBuffer, err := s.getBytesBuffer(ctx, params, length-16, length)
|
||||
if err != nil {
|
||||
return documenttypes.MimeTypeInvalid, err
|
||||
}
|
||||
endBuffer = bytes.TrimRight(endBuffer, " \t\r\n")
|
||||
if bytes.HasSuffix(endBuffer, PDFSignatureEnd) {
|
||||
return documenttypes.MimeTypePDF, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. TIFF (4 bytes -- little-endian or big-endian)
|
||||
if bytes.HasPrefix(startBuffer, sigTIFF_LE) || bytes.HasPrefix(startBuffer, sigTIFF_BE) {
|
||||
return documenttypes.MimeTypeTIFF, nil
|
||||
}
|
||||
|
||||
// 5. ZIP / Office (4 bytes) -- disambiguate DOCX/XLSX/PPTX by Content_Types.xml
|
||||
if bytes.HasPrefix(startBuffer, sigZIP) {
|
||||
return s.detectOfficeType(ctx, params, length)
|
||||
}
|
||||
|
||||
// 6. JPEG (3 bytes)
|
||||
if bytes.HasPrefix(startBuffer, sigJPEG) {
|
||||
return documenttypes.MimeTypeJPEG, nil
|
||||
}
|
||||
|
||||
// 7. BMP (2 bytes)
|
||||
if bytes.HasPrefix(startBuffer, sigBMP) {
|
||||
return documenttypes.MimeTypeBMP, nil
|
||||
}
|
||||
|
||||
// 8. EML -- text-based, check for RFC 822 headers
|
||||
if s.looksLikeEML(ctx, params, length) {
|
||||
return documenttypes.MimeTypeEML, nil
|
||||
}
|
||||
|
||||
// 9. TXT -- fallback, only if extension is .txt and content passes text checks
|
||||
if extension == ".txt" {
|
||||
if s.looksLikeTXT(startBuffer) {
|
||||
return documenttypes.MimeTypeTXT, nil
|
||||
}
|
||||
}
|
||||
|
||||
return documenttypes.MimeTypeInvalid, nil
|
||||
}
|
||||
|
||||
// detectOfficeType reads the full ZIP file, decompresses [Content_Types].xml,
|
||||
// and inspects it to distinguish between DOCX, XLSX, and PPTX.
|
||||
func (s *Service) detectOfficeType(ctx context.Context, params *CleanParams, length int64) (documenttypes.MimeType, error) {
|
||||
// Fetch the full file so the ZIP central directory (at end of file) is available.
|
||||
buf, err := s.getBytesBuffer(ctx, params, 0, length)
|
||||
if err != nil {
|
||||
return documenttypes.MimeTypeInvalid, err
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
|
||||
if err != nil {
|
||||
return documenttypes.MimeTypeInvalid, nil
|
||||
}
|
||||
|
||||
// Find and decompress [Content_Types].xml.
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "[Content_Types].xml" {
|
||||
continue
|
||||
}
|
||||
rc, openErr := f.Open()
|
||||
if openErr != nil {
|
||||
return documenttypes.MimeTypeInvalid, nil
|
||||
}
|
||||
ctData, readErr := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if readErr != nil {
|
||||
return documenttypes.MimeTypeInvalid, nil
|
||||
}
|
||||
content := string(ctData)
|
||||
if strings.Contains(content, "wordprocessingml.document.main+xml") {
|
||||
return documenttypes.MimeTypeDOCX, nil
|
||||
}
|
||||
if strings.Contains(content, "spreadsheetml.sheet.main+xml") {
|
||||
return documenttypes.MimeTypeXLSX, nil
|
||||
}
|
||||
if strings.Contains(content, "presentationml.presentation.main+xml") {
|
||||
return documenttypes.MimeTypePPTX, nil
|
||||
}
|
||||
return documenttypes.MimeTypeInvalid, nil
|
||||
}
|
||||
|
||||
// ZIP file but no [Content_Types].xml -- not a recognized Office format
|
||||
return documenttypes.MimeTypeInvalid, nil
|
||||
}
|
||||
|
||||
// looksLikeEML checks if the file starts with RFC 822 email headers.
|
||||
func (s *Service) looksLikeEML(ctx context.Context, params *CleanParams, length int64) bool {
|
||||
readLen := min(length, 8192)
|
||||
buf, err := s.getBytesBuffer(ctx, params, 0, readLen)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have at least 3 header-like lines
|
||||
matches := emlHeaderPattern.FindAll(buf, -1)
|
||||
if len(matches) < 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have at least one of the required email headers
|
||||
content := string(buf)
|
||||
for _, h := range emlRequiredHeaders {
|
||||
if bytes.Contains([]byte(content), []byte(h)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// looksLikeTXT verifies the first bytes don't match any known binary signature
|
||||
// and contain no null bytes (which would indicate binary content).
|
||||
// UTF-16 files with a BOM are allowed through -- the null bytes are expected encoding.
|
||||
func (s *Service) looksLikeTXT(startBuffer []byte) bool {
|
||||
// Reject if it matches any known binary signature
|
||||
binarySignatures := [][]byte{sigPNG, sigOLE2, sigPDF, sigTIFF_LE, sigTIFF_BE, sigZIP, sigJPEG, sigBMP}
|
||||
for _, sig := range binarySignatures {
|
||||
if bytes.HasPrefix(startBuffer, sig) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UTF-16 files with BOM are valid text -- skip null-byte check for these.
|
||||
if len(startBuffer) >= 2 {
|
||||
if startBuffer[0] == 0xFF && startBuffer[1] == 0xFE { // UTF-16LE BOM
|
||||
return true
|
||||
}
|
||||
if startBuffer[0] == 0xFE && startBuffer[1] == 0xFF { // UTF-16BE BOM
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for null bytes in the start buffer
|
||||
if bytes.Contains(startBuffer, []byte{0x00}) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) getBytesBuffer(ctx context.Context, params *CleanParams, start int64, length int64) ([]byte, error) {
|
||||
@@ -111,9 +291,9 @@ func (s *Service) getBytesBuffer(ctx context.Context, params *CleanParams, start
|
||||
defer out.Body.Close()
|
||||
|
||||
buffer := make([]byte, length)
|
||||
n, err := out.Body.Read(buffer)
|
||||
if err != nil && n == 0 {
|
||||
return nil, fmt.Errorf("unable to read object body: %w", err)
|
||||
n, err := io.ReadFull(out.Body, buffer)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return nil, fmt.Errorf("reading S3 object body: %w", err)
|
||||
}
|
||||
|
||||
return buffer[:n], nil
|
||||
|
||||
@@ -213,3 +213,214 @@ func TestClean_RejectsNonPDFWithPDFContentType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClean_AcceptsPDFWithTrailingNewline verifies that valid PDF files are
|
||||
// accepted even when trailing whitespace (newlines) follows the %%EOF marker.
|
||||
// Many PDF generators append a trailing newline; the cleaner must tolerate it.
|
||||
func TestClean_AcceptsPDFWithTrailingNewline(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
svc := documentclean.New(cfg)
|
||||
|
||||
uniqueSuffix := uuid.New().String()[:8]
|
||||
clientID := "pdf_trailing_nl_" + uniqueSuffix
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test PDF Trailing Newline " + uniqueSuffix,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Structurally valid minimal PDF (cross-reference table, catalog, page).
|
||||
// The base ends with %%EOF and no trailing newline; each sub-test appends its own suffix.
|
||||
pdfBase := []byte("%PDF-1.4\n1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n" +
|
||||
"2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n" +
|
||||
"3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>\nendobj\n" +
|
||||
"xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n" +
|
||||
"trailer\n<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
suffix string
|
||||
}{
|
||||
{name: "%%EOF\\n (Unix)", suffix: "\n"},
|
||||
{name: "%%EOF\\r\\n (Windows)", suffix: "\r\n"},
|
||||
{name: "%%EOF (no newline)", suffix: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
content := append([]byte{}, pdfBase...)
|
||||
content = append(content, []byte(tt.suffix)...)
|
||||
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
}
|
||||
|
||||
_, err := cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(content),
|
||||
ContentType: aws.String("application/pdf"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
headResp, err := cfg.GetStoreClient().HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etag := *headResp.ETag
|
||||
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: etag,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := svc.Clean(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Passed, "valid PDF with trailing whitespace %q after %%%%EOF should be accepted", tt.suffix)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClean_AcceptsTXTWithExtension verifies that a valid UTF-8 text file
|
||||
// is accepted when the S3 key includes a .txt file extension.
|
||||
func TestClean_AcceptsTXTWithExtension(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
svc := documentclean.New(cfg)
|
||||
|
||||
uniqueSuffix := uuid.New().String()[:8]
|
||||
clientID := "txt_ext_" + uniqueSuffix
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test TXT Extension " + uniqueSuffix,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
txtContent := []byte("Hello, this is a plain text file.\nIt has multiple lines.\nNo binary content here.")
|
||||
|
||||
fileType := "txt"
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
FileType: &fileType,
|
||||
}
|
||||
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(txtContent),
|
||||
ContentType: aws.String("text/plain"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
headResp, err := cfg.GetStoreClient().HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etag := *headResp.ETag
|
||||
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: etag,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := svc.Clean(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Passed, "valid TXT file with .txt extension should be accepted")
|
||||
}
|
||||
|
||||
// TestClean_RejectsTXTWithoutExtension verifies that a text file without
|
||||
// a .txt extension on the S3 key is rejected (regression guard for old uploads).
|
||||
func TestClean_RejectsTXTWithoutExtension(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
svc := documentclean.New(cfg)
|
||||
|
||||
uniqueSuffix := uuid.New().String()[:8]
|
||||
clientID := "txt_noext_" + uniqueSuffix
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test TXT No Extension " + uniqueSuffix,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
txtContent := []byte("Hello, this is a plain text file.\nIt has multiple lines.\nNo binary content here.")
|
||||
|
||||
// No FileType set -- simulates old uploads without extension
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
}
|
||||
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(txtContent),
|
||||
ContentType: aws.String("text/plain"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
headResp, err := cfg.GetStoreClient().HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etag := *headResp.ETag
|
||||
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: etag,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := svc.Clean(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Passed, "TXT file without .txt extension should be rejected")
|
||||
require.NotNil(t, result.FailReason)
|
||||
assert.Equal(t, "invalid_mimetype", *result.FailReason)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
// BMP implements the File interface for BMP image validation.
|
||||
// It validates magic bytes, dimensions, and content.
|
||||
// BMP DPI metadata is not checked as it is unreliable.
|
||||
type BMP struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewBMPFromReader creates a new BMP validator by reading all bytes from r.
|
||||
func NewBMPFromReader(r io.Reader) (*BMP, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewBMP(data), nil
|
||||
}
|
||||
|
||||
// NewBMP creates a new BMP validator from raw file bytes.
|
||||
func NewBMP(data []byte) *BMP {
|
||||
return &BMP{data: data}
|
||||
}
|
||||
|
||||
// IsCorrupt validates the BMP file for corruption or unsupported content.
|
||||
func (s *BMP) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
if reason := s.checkMagicBytes(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
img, err := bmp.Decode(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
return invalidReason(InvalidDocumentRead)
|
||||
}
|
||||
|
||||
if len(s.data) > imageMaxFileSize {
|
||||
return invalidReason(InvalidDocumentLargeFile)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
if reason := checkImageDimensions(bounds.Dx(), bounds.Dy()); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if isSolidColor(img) {
|
||||
return invalidReason(InvalidDocumentEmptyContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns 1 since BMP files are always single-page.
|
||||
func (s *BMP) GetPageCount(_ context.Context) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// GetPage returns the raw BMP data for page index 0.
|
||||
func (s *BMP) GetPage(_ context.Context, index int) ([]byte, error) {
|
||||
if index != 0 {
|
||||
return nil, fmt.Errorf("bmp: invalid page index %d: BMP files have exactly 1 page", index)
|
||||
}
|
||||
return s.data, nil
|
||||
}
|
||||
|
||||
// checkMagicBytes verifies the BMP file signature: 42 4D ("BM").
|
||||
func (s *BMP) checkMagicBytes() *InvalidDocumentReason {
|
||||
if len(s.data) < 2 {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
if s.data[0] != 0x42 || s.data[1] != 0x4D {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBMP_ValidFile(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.bmp")
|
||||
require.NoError(t, err)
|
||||
|
||||
b := NewBMP(data)
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := b.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason)
|
||||
})
|
||||
|
||||
t.Run("GetPageCount returns 1", func(t *testing.T) {
|
||||
count, err := b.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBMP_InvalidMagicBytes(t *testing.T) {
|
||||
data := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
b := NewBMP(data)
|
||||
|
||||
reason := b.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestBMP_CorruptData(t *testing.T) {
|
||||
// Valid BMP magic bytes ("BM") followed by garbage data.
|
||||
data := make([]byte, 128)
|
||||
data[0] = 0x42 // 'B'
|
||||
data[1] = 0x4D // 'M'
|
||||
// Remaining bytes are zeros -- not a valid BMP file.
|
||||
|
||||
b := NewBMP(data)
|
||||
reason := b.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentRead, *reason)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
|
||||
@@ -13,7 +15,17 @@ type MimeType string
|
||||
|
||||
const (
|
||||
MimeTypePDF MimeType = "application/pdf"
|
||||
MimeTypeTIFF MimeType = "image/tiff"
|
||||
MimeTypeJPEG MimeType = "image/jpeg"
|
||||
MimeTypePNG MimeType = "image/png"
|
||||
MimeTypeBMP MimeType = "image/bmp"
|
||||
MimeTypeDOCX MimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
MimeTypeXLSX MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
MimeTypePPTX MimeType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
MimeTypeTXT MimeType = "text/plain"
|
||||
MimeTypeEML MimeType = "message/rfc822"
|
||||
MimeTypeBinaryOctetStream MimeType = "binary/octet-stream"
|
||||
MimeTypeOLE2Encrypted MimeType = "ole2_encrypted"
|
||||
MimeTypeInvalid MimeType = "invalid"
|
||||
)
|
||||
|
||||
@@ -41,14 +53,37 @@ func GetFile(ctx context.Context, cfg objectstore.ConfigProvider, params GetFile
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file data: %w", err)
|
||||
}
|
||||
|
||||
var file File
|
||||
switch params.Mimetype {
|
||||
case MimeTypePDF:
|
||||
pdf, err := NewPDFFromReader(resp.Body)
|
||||
pdf, err := NewPDFFromReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file = pdf
|
||||
case MimeTypeTIFF:
|
||||
file = NewTIFF(data)
|
||||
case MimeTypeJPEG:
|
||||
file = NewJPEG(data)
|
||||
case MimeTypePNG:
|
||||
file = NewPNG(data)
|
||||
case MimeTypeBMP:
|
||||
file = NewBMP(data)
|
||||
case MimeTypeDOCX:
|
||||
file = NewDOCX(data)
|
||||
case MimeTypeXLSX:
|
||||
file = NewXLSX(data)
|
||||
case MimeTypePPTX:
|
||||
file = NewPPTX(data)
|
||||
case MimeTypeTXT:
|
||||
file = NewTXT(data)
|
||||
case MimeTypeEML:
|
||||
file = NewEML(data)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid mimetype: %s", params.Mimetype)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DOCX represents a Word OOXML document for validation and page extraction.
|
||||
type DOCX struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewDOCX creates a new DOCX validator from raw file data.
|
||||
func NewDOCX(data []byte) *DOCX {
|
||||
return &DOCX{data: data}
|
||||
}
|
||||
|
||||
// docxBody is a minimal XML representation of the word/document.xml body,
|
||||
// capturing text runs, drawings, pictures, and section breaks.
|
||||
type docxBody struct {
|
||||
XMLName xml.Name `xml:"document"`
|
||||
Body docxBodyInner `xml:"body"`
|
||||
}
|
||||
|
||||
type docxBodyInner struct {
|
||||
Paragraphs []docxParagraph `xml:"p"`
|
||||
SectPr *struct{} `xml:"sectPr"`
|
||||
}
|
||||
|
||||
type docxParagraph struct {
|
||||
Runs []docxRun `xml:"r"`
|
||||
Drawings []struct{} `xml:"drawing"`
|
||||
Pictures []struct{} `xml:"pict"`
|
||||
SectPr *struct{} `xml:"pPr>sectPr"`
|
||||
}
|
||||
|
||||
type docxRun struct {
|
||||
Text []string `xml:"t"`
|
||||
Drawings []struct{} `xml:"drawing"`
|
||||
Pictures []struct{} `xml:"pict"`
|
||||
}
|
||||
|
||||
// docxSettings is a minimal representation of word/settings.xml for
|
||||
// detecting document protection with encryption attributes.
|
||||
type docxSettings struct {
|
||||
XMLName xml.Name `xml:"settings"`
|
||||
DocumentProtection *docxDocProtection `xml:"documentProtection"`
|
||||
}
|
||||
|
||||
type docxDocProtection struct {
|
||||
CryptAlgorithmSid string `xml:"cryptAlgorithmSid,attr"`
|
||||
CryptSpinCount string `xml:"cryptSpinCount,attr"`
|
||||
Hash string `xml:"hash,attr"`
|
||||
}
|
||||
|
||||
// IsCorrupt validates the DOCX document, returning the reason it is invalid
|
||||
// or nil if the document passes all checks.
|
||||
func (s *DOCX) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
// 1. Check for OLE2 encryption (encrypted OOXML files use OLE2 wrapper)
|
||||
if IsOLE2Encrypted(s.data) {
|
||||
reason := InvalidDocumentPasswordProtected
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 2. Check ZIP signature
|
||||
if !hasZIPSignature(s.data) {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 3. Open ZIP and verify content type
|
||||
zr, err := openOfficeZIP(s.data)
|
||||
if err != nil {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
ct, err := readZIPFile(zr, "[Content_Types].xml")
|
||||
if err != nil || !strings.Contains(string(ct), "wordprocessingml.document.main+xml") {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 4. Reject if macros are present
|
||||
if hasVBAMacros(zr) {
|
||||
reason := InvalidDocumentContainsMacros
|
||||
return &reason
|
||||
}
|
||||
if r := s.hasEncryptionProtection(zr); r != nil {
|
||||
return r
|
||||
}
|
||||
|
||||
// 5. Verify word/document.xml exists and parses
|
||||
docXML, err := readZIPFile(zr, "word/document.xml")
|
||||
if err != nil {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
var doc docxBody
|
||||
if xmlErr := xml.Unmarshal(docXML, &doc); xmlErr != nil {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 6. File size check
|
||||
if len(s.data) > officeMaxFileSize {
|
||||
reason := InvalidDocumentLargeFile
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 7. Content check: text, drawings, or pictures
|
||||
if !s.hasContent(&doc) {
|
||||
reason := InvalidDocumentEmptyContent
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 8. Page count check
|
||||
pageCount := s.countPages(&doc)
|
||||
if pageCount < 1 {
|
||||
reason := InvalidDocumentZeroPageCount
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount estimates the number of pages by counting section breaks + 1.
|
||||
// Returns at least 1 if the document has content.
|
||||
func (s *DOCX) GetPageCount(_ context.Context) (int, error) {
|
||||
zr, err := openOfficeZIP(s.data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
docXML, err := readZIPFile(zr, "word/document.xml")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var doc docxBody
|
||||
if xmlErr := xml.Unmarshal(docXML, &doc); xmlErr != nil {
|
||||
return 0, xmlErr
|
||||
}
|
||||
|
||||
count := max(s.countPages(&doc), 1)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetPage returns the raw document bytes for the requested page index.
|
||||
// For single-page docs, returns the full document bytes regardless of index.
|
||||
func (s *DOCX) GetPage(_ context.Context, _ int) ([]byte, error) {
|
||||
return s.data, nil
|
||||
}
|
||||
|
||||
// hasEncryptionProtection checks word/settings.xml for documentProtection
|
||||
// elements with encryption attributes.
|
||||
func (s *DOCX) hasEncryptionProtection(zr *zip.Reader) *InvalidDocumentReason {
|
||||
settingsXML, err := readZIPFile(zr, "word/settings.xml")
|
||||
if err != nil {
|
||||
// No settings file is not an error -- just no protection
|
||||
return nil
|
||||
}
|
||||
|
||||
var settings docxSettings
|
||||
if xmlErr := xml.Unmarshal(settingsXML, &settings); xmlErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if settings.DocumentProtection != nil &&
|
||||
(settings.DocumentProtection.CryptAlgorithmSid != "" ||
|
||||
settings.DocumentProtection.Hash != "") {
|
||||
reason := InvalidDocumentPasswordProtected
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasContent checks whether the document contains any text, drawings, or pictures.
|
||||
func (s *DOCX) hasContent(doc *docxBody) bool {
|
||||
for _, p := range doc.Body.Paragraphs {
|
||||
if len(p.Drawings) > 0 || len(p.Pictures) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, r := range p.Runs {
|
||||
if len(r.Drawings) > 0 || len(r.Pictures) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, t := range r.Text {
|
||||
if strings.TrimSpace(t) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countPages estimates page count from section breaks.
|
||||
// Each <w:sectPr> in a paragraph's properties represents a section break.
|
||||
// The final section is represented by the body-level sectPr.
|
||||
func (s *DOCX) countPages(doc *docxBody) int {
|
||||
count := 0
|
||||
for _, p := range doc.Body.Paragraphs {
|
||||
if p.SectPr != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
// The body-level sectPr represents the last section
|
||||
if doc.Body.SectPr != nil {
|
||||
count++
|
||||
}
|
||||
// Minimum 1 if there are paragraphs at all
|
||||
if count == 0 && len(doc.Body.Paragraphs) > 0 {
|
||||
count = 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// hasZIPSignature checks if data starts with the ZIP local file header signature.
|
||||
func hasZIPSignature(data []byte) bool {
|
||||
return len(data) >= 4 &&
|
||||
data[0] == 0x50 && data[1] == 0x4B &&
|
||||
data[2] == 0x03 && data[3] == 0x04
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDOCX_ValidFile(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
docx := NewDOCX(data)
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := docx.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason, "valid.docx should not be corrupt")
|
||||
})
|
||||
|
||||
t.Run("GetPageCount >= 1", func(t *testing.T) {
|
||||
count, err := docx.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, count, 1)
|
||||
})
|
||||
|
||||
t.Run("GetPage returns data", func(t *testing.T) {
|
||||
page, err := docx.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, page)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDOCX_InvalidMagicBytes(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data := []byte("this is definitely not a ZIP or DOCX file")
|
||||
|
||||
docx := NewDOCX(data)
|
||||
reason := docx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestDOCX_NotDOCX(t *testing.T) {
|
||||
// Create a valid ZIP that does not contain DOCX content types.
|
||||
ctx := t.Context()
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
// Write a [Content_Types].xml without the DOCX content type
|
||||
w, err := zw.Create("[Content_Types].xml")
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
</Types>`))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, zw.Close())
|
||||
|
||||
docx := NewDOCX(buf.Bytes())
|
||||
reason := docx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestDOCX_WithMacros(t *testing.T) {
|
||||
// macro.docx should be rejected -- documents with macros are not accepted.
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/macro.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
docx := NewDOCX(data)
|
||||
reason := docx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentContainsMacros, *reason)
|
||||
}
|
||||
|
||||
func TestDOCX_Encrypted(t *testing.T) {
|
||||
// encrypted.docx has the OLE2 signature but is not a parseable OLE2 compound
|
||||
// document (malformed fixture). IsOLE2Encrypted returns false, then the ZIP
|
||||
// signature check fails, so it returns InvalidDocumentMimeType.
|
||||
// A real encrypted OLE2 file would be caught by IsOLE2Encrypted and return
|
||||
// InvalidDocumentPasswordProtected.
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/encrypted.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
docx := NewDOCX(data)
|
||||
reason := docx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason,
|
||||
"malformed OLE2 file should fail ZIP signature check")
|
||||
}
|
||||
|
||||
func TestDOCX_EmptyContent(t *testing.T) {
|
||||
// Build a minimal DOCX with proper structure but no text content.
|
||||
ctx := t.Context()
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
// [Content_Types].xml -- must include wordprocessingml.document.main+xml
|
||||
w, err := zw.Create("[Content_Types].xml")
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Override PartName="/word/document.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// word/document.xml -- valid structure but no text, no drawings, no pictures
|
||||
w, err = zw.Create("word/document.xml")
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:t></w:t>
|
||||
</w:r>
|
||||
</w:p>
|
||||
<w:sectPr/>
|
||||
</w:body>
|
||||
</w:document>`))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, zw.Close())
|
||||
|
||||
docx := NewDOCX(buf.Bytes())
|
||||
reason := docx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentEmptyContent, *reason)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxEMLFileSize is the maximum allowed file size for email files (100MB).
|
||||
maxEMLFileSize = 100 * 1024 * 1024
|
||||
// emlHeaderScanSize is the number of bytes to scan for RFC 822 headers.
|
||||
emlHeaderScanSize = 8192
|
||||
)
|
||||
|
||||
// htmlTagPattern matches HTML tags for stripping to plain text.
|
||||
var htmlTagPattern = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// requiredEMLHeaders is the set of headers of which at least one must be present
|
||||
// for the file to be considered a valid email message.
|
||||
var requiredEMLHeaders = []string{"From", "To", "Subject", "Date"}
|
||||
|
||||
// EML implements the File interface for RFC 822 email message validation.
|
||||
// It validates headers, detects attachments (rejecting if found), and
|
||||
// verifies text body content exists.
|
||||
type EML struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewEML creates a new EML validator from raw file bytes.
|
||||
func NewEML(data []byte) *EML {
|
||||
return &EML{data: data}
|
||||
}
|
||||
|
||||
// IsCorrupt validates the email file for corruption or missing content.
|
||||
// It checks RFC 822 headers, parses the message, rejects if attachments
|
||||
// are present, validates file size, and ensures body content exists.
|
||||
func (s *EML) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
if reason := s.validateHeaders(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
r := InvalidDocumentRead
|
||||
return &r
|
||||
}
|
||||
|
||||
if reason := s.detectAttachments(msg); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if len(s.data) > maxEMLFileSize {
|
||||
r := InvalidDocumentLargeFile
|
||||
return &r
|
||||
}
|
||||
|
||||
body, reason := s.extractBody()
|
||||
if reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if strings.TrimSpace(body) == "" {
|
||||
r := InvalidDocumentEmptyContent
|
||||
return &r
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns the number of pages. Email messages are always 1 page.
|
||||
func (s *EML) GetPageCount(_ context.Context) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// GetPage returns the body text of the email. Only page index 0 is valid.
|
||||
func (s *EML) GetPage(_ context.Context, index int) ([]byte, error) {
|
||||
if index != 0 {
|
||||
return nil, fmt.Errorf("invalid page index %d: EML files have exactly 1 page", index)
|
||||
}
|
||||
|
||||
body, reason := s.extractBody()
|
||||
if reason != nil {
|
||||
return nil, fmt.Errorf("failed to extract EML body: %s", *reason)
|
||||
}
|
||||
|
||||
return []byte(body), nil
|
||||
}
|
||||
|
||||
// validateHeaders checks that the file begins with valid RFC 822 headers.
|
||||
// At least one of From, To, Subject, or Date must be present, and the header
|
||||
// block must be followed by a blank line separator.
|
||||
func (s *EML) validateHeaders() *InvalidDocumentReason {
|
||||
scanLen := min(len(s.data), emlHeaderScanSize)
|
||||
|
||||
headerBlock := string(s.data[:scanLen])
|
||||
|
||||
// Headers must be followed by a blank line (CRLF+CRLF or LF+LF).
|
||||
blankLineIdx := strings.Index(headerBlock, "\r\n\r\n")
|
||||
if blankLineIdx == -1 {
|
||||
blankLineIdx = strings.Index(headerBlock, "\n\n")
|
||||
}
|
||||
if blankLineIdx == -1 {
|
||||
r := InvalidDocumentMimeType
|
||||
return &r
|
||||
}
|
||||
|
||||
headerSection := headerBlock[:blankLineIdx]
|
||||
|
||||
// Check that at least one required header is present.
|
||||
foundRequired := false
|
||||
for _, required := range requiredEMLHeaders {
|
||||
// Match header at start of line: "HeaderName:" (case-insensitive).
|
||||
pattern := "(?m)^(?i)" + regexp.QuoteMeta(required) + `\s*:`
|
||||
matched, _ := regexp.MatchString(pattern, headerSection)
|
||||
if matched {
|
||||
foundRequired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundRequired {
|
||||
r := InvalidDocumentMimeType
|
||||
return &r
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectAttachments checks if the message contains any MIME attachments or
|
||||
// non-text parts. If found, the document is rejected -- the uploader must
|
||||
// remove attachments before resubmitting.
|
||||
func (s *EML) detectAttachments(msg *mail.Message) *InvalidDocumentReason {
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
// Simple message with no Content-Type -- treat as text/plain, no attachments.
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
// If we cannot parse Content-Type, the message is likely simple text.
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
// Single-part message: only text/plain and text/html are acceptable body types.
|
||||
// Non-text content types (e.g., application/pdf) indicate the message body is
|
||||
// binary content that should be rejected as an attachment.
|
||||
if !strings.HasPrefix(mediaType, "text/plain") && !strings.HasPrefix(mediaType, "text/html") {
|
||||
r := InvalidDocumentContainsAttachments
|
||||
return &r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
r := InvalidDocumentRead
|
||||
return &r
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
r := InvalidDocumentRead
|
||||
return &r
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(bytes.NewReader(body), boundary)
|
||||
|
||||
for {
|
||||
part, partErr := mr.NextPart()
|
||||
if partErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if partErr != nil {
|
||||
r := InvalidDocumentRead
|
||||
return &r
|
||||
}
|
||||
|
||||
if isAttachmentPart(part) {
|
||||
r := InvalidDocumentContainsAttachments
|
||||
return &r
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAttachmentPart determines whether a MIME part is an attachment or non-text
|
||||
// content that should cause rejection.
|
||||
func isAttachmentPart(part *multipart.Part) bool {
|
||||
disposition := part.Header.Get("Content-Disposition")
|
||||
if strings.HasPrefix(disposition, "attachment") {
|
||||
return true
|
||||
}
|
||||
|
||||
ct := part.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
// Default to text/plain per RFC 2045 -- not an attachment.
|
||||
return false
|
||||
}
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return mediaType != "text/plain" && mediaType != "text/html"
|
||||
}
|
||||
|
||||
// extractBody parses the current message data and extracts text content.
|
||||
// For multipart messages, it concatenates text/plain and text/html parts.
|
||||
// HTML content is converted to plain text by stripping tags.
|
||||
func (s *EML) extractBody() (string, *InvalidDocumentReason) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
r := InvalidDocumentRead
|
||||
return "", &r
|
||||
}
|
||||
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
return s.readBodyAsText(msg)
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return s.readBodyAsText(msg)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
body, readErr := io.ReadAll(msg.Body)
|
||||
if readErr != nil {
|
||||
r := InvalidDocumentRead
|
||||
return "", &r
|
||||
}
|
||||
return s.textFromPart(mediaType, body), nil
|
||||
}
|
||||
|
||||
return s.extractMultipartBody(msg, params["boundary"])
|
||||
}
|
||||
|
||||
// readBodyAsText reads the entire message body as plain text.
|
||||
func (s *EML) readBodyAsText(msg *mail.Message) (string, *InvalidDocumentReason) {
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
r := InvalidDocumentRead
|
||||
return "", &r
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// extractMultipartBody reads text parts from a multipart message and concatenates them.
|
||||
func (s *EML) extractMultipartBody(msg *mail.Message, boundary string) (string, *InvalidDocumentReason) {
|
||||
if boundary == "" {
|
||||
r := InvalidDocumentRead
|
||||
return "", &r
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
r := InvalidDocumentRead
|
||||
return "", &r
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(bytes.NewReader(body), boundary)
|
||||
var textContent strings.Builder
|
||||
|
||||
for {
|
||||
part, partErr := mr.NextPart()
|
||||
if partErr != nil {
|
||||
break
|
||||
}
|
||||
|
||||
partMedia := s.partMediaType(part)
|
||||
if partMedia != "text/plain" && partMedia != "text/html" {
|
||||
continue
|
||||
}
|
||||
|
||||
partData, readErr := io.ReadAll(part)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
text := s.textFromPart(partMedia, partData)
|
||||
if textContent.Len() > 0 && text != "" {
|
||||
textContent.WriteString("\n")
|
||||
}
|
||||
textContent.WriteString(text)
|
||||
}
|
||||
|
||||
return textContent.String(), nil
|
||||
}
|
||||
|
||||
// partMediaType extracts the media type from a MIME part's Content-Type header.
|
||||
func (s *EML) partMediaType(part *multipart.Part) string {
|
||||
ct := part.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return "text/plain"
|
||||
}
|
||||
parsed, _, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
return "text/plain"
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// textFromPart converts a MIME part body to plain text. For text/html content,
|
||||
// HTML tags are stripped. For text/plain, the content is returned as-is.
|
||||
func (s *EML) textFromPart(mediaType string, data []byte) string {
|
||||
if mediaType == "text/html" {
|
||||
return stripHTMLTags(string(data))
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// stripHTMLTags removes HTML tags from a string using a regex pattern,
|
||||
// returning the plain text content.
|
||||
func stripHTMLTags(html string) string {
|
||||
return htmlTagPattern.ReplaceAllString(html, "")
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEML_ValidFile(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.eml")
|
||||
require.NoError(t, err)
|
||||
|
||||
eml := NewEML(data)
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := eml.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason)
|
||||
})
|
||||
|
||||
t.Run("GetPageCount returns 1", func(t *testing.T) {
|
||||
count, err := eml.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
|
||||
t.Run("GetPage returns body", func(t *testing.T) {
|
||||
page, err := eml.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, page)
|
||||
assert.Contains(t, string(page), "This is the body of the test email.")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEML_InvalidHeaders(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Random text without any RFC 822 headers or blank line separator.
|
||||
data := []byte("This is just random text without any email headers at all.")
|
||||
|
||||
eml := NewEML(data)
|
||||
reason := eml.IsCorrupt(ctx)
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestEML_EmptyBody(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Valid headers followed by empty body.
|
||||
data := []byte("From: test@example.com\r\nTo: recipient@example.com\r\nSubject: Empty\r\nDate: Mon, 01 Jan 2024 00:00:00 +0000\r\n\r\n")
|
||||
|
||||
eml := NewEML(data)
|
||||
reason := eml.IsCorrupt(ctx)
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentEmptyContent, *reason)
|
||||
}
|
||||
|
||||
func TestEML_WithAttachment(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Multipart EML with a text body and a binary attachment.
|
||||
// Emails with attachments should be rejected -- uploader must strip before resubmitting.
|
||||
data := []byte("From: test@example.com\r\n" +
|
||||
"To: recipient@example.com\r\n" +
|
||||
"Subject: Test\r\n" +
|
||||
"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: multipart/mixed; boundary=\"boundary123\"\r\n" +
|
||||
"\r\n" +
|
||||
"--boundary123\r\n" +
|
||||
"Content-Type: text/plain\r\n" +
|
||||
"\r\n" +
|
||||
"This is the body.\r\n" +
|
||||
"--boundary123\r\n" +
|
||||
"Content-Type: application/pdf\r\n" +
|
||||
"Content-Disposition: attachment; filename=\"test.pdf\"\r\n" +
|
||||
"\r\n" +
|
||||
"fake pdf data\r\n" +
|
||||
"--boundary123--\r\n")
|
||||
|
||||
eml := NewEML(data)
|
||||
reason := eml.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentContainsAttachments, *reason)
|
||||
}
|
||||
|
||||
func TestEML_SinglePartNonText(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Single-part message with binary content type should be rejected.
|
||||
data := []byte("From: test@example.com\r\n" +
|
||||
"To: recipient@example.com\r\n" +
|
||||
"Subject: Binary\r\n" +
|
||||
"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" +
|
||||
"Content-Type: application/pdf\r\n" +
|
||||
"\r\n" +
|
||||
"fake pdf content\r\n")
|
||||
|
||||
eml := NewEML(data)
|
||||
reason := eml.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentContainsAttachments, *reason,
|
||||
"single-part non-text content should be rejected as attachment")
|
||||
}
|
||||
|
||||
func TestEML_SinglePartTextPlain(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Single-part text/plain message should be accepted.
|
||||
data := []byte("From: test@example.com\r\n" +
|
||||
"To: recipient@example.com\r\n" +
|
||||
"Subject: Plain text\r\n" +
|
||||
"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"\r\n" +
|
||||
"This is a plain text email body.\r\n")
|
||||
|
||||
eml := NewEML(data)
|
||||
reason := eml.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason, "single-part text/plain message should be valid")
|
||||
}
|
||||
|
||||
func TestEML_SinglePartTextHTML(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Single-part text/html message should be accepted.
|
||||
data := []byte("From: test@example.com\r\n" +
|
||||
"To: recipient@example.com\r\n" +
|
||||
"Subject: HTML email\r\n" +
|
||||
"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" +
|
||||
"Content-Type: text/html; charset=utf-8\r\n" +
|
||||
"\r\n" +
|
||||
"<html><body><p>Hello world</p></body></html>\r\n")
|
||||
|
||||
eml := NewEML(data)
|
||||
reason := eml.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason, "single-part text/html message should be valid")
|
||||
}
|
||||
|
||||
func TestEML_GetPageInvalidIndex(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.eml")
|
||||
require.NoError(t, err)
|
||||
|
||||
eml := NewEML(data)
|
||||
_, pageErr := eml.GetPage(ctx, 1)
|
||||
require.Error(t, pageErr)
|
||||
assert.Contains(t, pageErr.Error(), "invalid page index")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// imageMaxFileSize is the maximum allowed file size for image files (500MB).
|
||||
const imageMaxFileSize = 500 * 1024 * 1024
|
||||
|
||||
// isSolidColor samples the center pixel and 4 corners of an image, returning true
|
||||
// if all sampled pixels are identical. This detects blank/empty image content.
|
||||
func isSolidColor(img image.Image) bool {
|
||||
bounds := img.Bounds()
|
||||
w := bounds.Dx()
|
||||
h := bounds.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Sample points: center and 4 corners
|
||||
points := []image.Point{
|
||||
{X: bounds.Min.X + w/2, Y: bounds.Min.Y + h/2},
|
||||
{X: bounds.Min.X, Y: bounds.Min.Y},
|
||||
{X: bounds.Max.X - 1, Y: bounds.Min.Y},
|
||||
{X: bounds.Min.X, Y: bounds.Max.Y - 1},
|
||||
{X: bounds.Max.X - 1, Y: bounds.Max.Y - 1},
|
||||
}
|
||||
|
||||
ref := img.At(points[0].X, points[0].Y)
|
||||
rr, rg, rb, ra := ref.RGBA()
|
||||
|
||||
for _, p := range points[1:] {
|
||||
c := img.At(p.X, p.Y)
|
||||
cr, cg, cb, ca := c.RGBA()
|
||||
if cr != rr || cg != rg || cb != rb || ca != ra {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isFullyTransparent checks whether all sampled pixels have zero alpha,
|
||||
// indicating the image has no visible content.
|
||||
func isFullyTransparent(img image.Image) bool {
|
||||
bounds := img.Bounds()
|
||||
w := bounds.Dx()
|
||||
h := bounds.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
points := []image.Point{
|
||||
{X: bounds.Min.X + w/2, Y: bounds.Min.Y + h/2},
|
||||
{X: bounds.Min.X, Y: bounds.Min.Y},
|
||||
{X: bounds.Max.X - 1, Y: bounds.Min.Y},
|
||||
{X: bounds.Min.X, Y: bounds.Max.Y - 1},
|
||||
{X: bounds.Max.X - 1, Y: bounds.Max.Y - 1},
|
||||
}
|
||||
|
||||
for _, p := range points {
|
||||
_, _, _, a := img.At(p.X, p.Y).RGBA()
|
||||
if a != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// invalidReason is a helper that returns a pointer to an InvalidDocumentReason.
|
||||
func invalidReason(r InvalidDocumentReason) *InvalidDocumentReason {
|
||||
return &r
|
||||
}
|
||||
|
||||
// checkImageDimensions validates width and height against Textract limits.
|
||||
func checkImageDimensions(width, height int) *InvalidDocumentReason {
|
||||
if width < TEXTRACT_MIN_DIMENSION || height < TEXTRACT_MIN_DIMENSION {
|
||||
return invalidReason(InvalidDocumentSmallDimensions)
|
||||
}
|
||||
if width > TEXTRACT_MAX_DIMENSION || height > TEXTRACT_MAX_DIMENSION {
|
||||
return invalidReason(InvalidDocumentLargeDimensions)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasNonZeroPixelData checks whether an image has at least one non-zero pixel value.
|
||||
func hasNonZeroPixelData(img image.Image) bool {
|
||||
bounds := img.Bounds()
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
if r != 0 || g != 0 || b != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// colorModelHasAlpha returns true if the image's color model supports alpha transparency.
|
||||
func colorModelHasAlpha(img image.Image) bool {
|
||||
switch img.ColorModel() {
|
||||
case color.NRGBAModel, color.NRGBA64Model, color.RGBAModel, color.RGBA64Model, color.AlphaModel, color.Alpha16Model:
|
||||
return true
|
||||
}
|
||||
// Also check by concrete type for paletted images
|
||||
switch img.(type) {
|
||||
case *image.NRGBA, *image.NRGBA64, *image.RGBA, *image.RGBA64, *image.Alpha, *image.Alpha16:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,13 +3,19 @@ package documenttypes
|
||||
type InvalidDocumentReason string
|
||||
|
||||
const (
|
||||
InvalidDocumentMimeType InvalidDocumentReason = "invalid_mimetype"
|
||||
InvalidDocumentRead InvalidDocumentReason = "invalid_read"
|
||||
InvalidDocumentReadPages InvalidDocumentReason = "invalid_read_pages"
|
||||
InvalidDocumentZeroPageCount InvalidDocumentReason = "zero_page_count"
|
||||
InvalidDocumentLargeFile InvalidDocumentReason = "large_file"
|
||||
InvalidDocumentSmallDimensions InvalidDocumentReason = "small_dimensions"
|
||||
InvalidDocumentLargeDimensions InvalidDocumentReason = "large_dimensions"
|
||||
InvalidDocumentSmallDPI InvalidDocumentReason = "small_dpi"
|
||||
InvalidDocumentLargeDPI InvalidDocumentReason = "large_dpi"
|
||||
InvalidDocumentMimeType InvalidDocumentReason = "invalid_mimetype"
|
||||
InvalidDocumentRead InvalidDocumentReason = "invalid_read"
|
||||
InvalidDocumentReadPages InvalidDocumentReason = "invalid_read_pages"
|
||||
InvalidDocumentZeroPageCount InvalidDocumentReason = "zero_page_count"
|
||||
InvalidDocumentLargeFile InvalidDocumentReason = "large_file"
|
||||
InvalidDocumentSmallDimensions InvalidDocumentReason = "small_dimensions"
|
||||
InvalidDocumentLargeDimensions InvalidDocumentReason = "large_dimensions"
|
||||
InvalidDocumentSmallDPI InvalidDocumentReason = "small_dpi"
|
||||
InvalidDocumentLargeDPI InvalidDocumentReason = "large_dpi"
|
||||
InvalidDocumentPasswordProtected InvalidDocumentReason = "password_protected"
|
||||
InvalidDocumentContainsMacros InvalidDocumentReason = "contains_macros"
|
||||
InvalidDocumentEmptyContent InvalidDocumentReason = "empty_content"
|
||||
InvalidDocumentBinaryContent InvalidDocumentReason = "binary_content"
|
||||
InvalidDocumentUnsupportedEncoding InvalidDocumentReason = "unsupported_encoding"
|
||||
InvalidDocumentContainsAttachments InvalidDocumentReason = "contains_attachments"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
)
|
||||
|
||||
// JPEG implements the File interface for JPEG image validation.
|
||||
// It validates magic bytes, dimensions, DPI (via EXIF or JFIF), and content.
|
||||
type JPEG struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewJPEGFromReader creates a new JPEG validator by reading all bytes from r.
|
||||
func NewJPEGFromReader(r io.Reader) (*JPEG, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewJPEG(data), nil
|
||||
}
|
||||
|
||||
// NewJPEG creates a new JPEG validator from raw file bytes.
|
||||
func NewJPEG(data []byte) *JPEG {
|
||||
return &JPEG{data: data}
|
||||
}
|
||||
|
||||
// IsCorrupt validates the JPEG file for corruption or unsupported content.
|
||||
func (s *JPEG) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
if reason := s.checkMagicBytes(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
img, err := jpeg.Decode(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
return invalidReason(InvalidDocumentRead)
|
||||
}
|
||||
|
||||
if len(s.data) > imageMaxFileSize {
|
||||
return invalidReason(InvalidDocumentLargeFile)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
if reason := checkImageDimensions(bounds.Dx(), bounds.Dy()); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if reason := s.checkDPI(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if isSolidColor(img) {
|
||||
return invalidReason(InvalidDocumentEmptyContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns 1 since JPEG files are always single-page.
|
||||
func (s *JPEG) GetPageCount(_ context.Context) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// GetPage returns the raw JPEG data for page index 0.
|
||||
func (s *JPEG) GetPage(_ context.Context, index int) ([]byte, error) {
|
||||
if index != 0 {
|
||||
return nil, fmt.Errorf("jpeg: invalid page index %d: JPEG files have exactly 1 page", index)
|
||||
}
|
||||
return s.data, nil
|
||||
}
|
||||
|
||||
// checkMagicBytes verifies the JPEG SOI marker: FF D8 FF.
|
||||
func (s *JPEG) checkMagicBytes() *InvalidDocumentReason {
|
||||
if len(s.data) < 3 {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
if s.data[0] != 0xFF || s.data[1] != 0xD8 || s.data[2] != 0xFF {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDPI attempts to read DPI from EXIF data first, then falls back to
|
||||
// JFIF APP0 header parsing. Returns nil if DPI is acceptable or absent (defaults to 72).
|
||||
func (s *JPEG) checkDPI() *InvalidDocumentReason {
|
||||
xDPI, yDPI := s.getExifDPI()
|
||||
if xDPI == 0 && yDPI == 0 {
|
||||
xDPI, yDPI = s.getJFIFDPI()
|
||||
}
|
||||
|
||||
// Default to 72 if no DPI metadata found
|
||||
if xDPI == 0 {
|
||||
xDPI = float64(TEXTRACT_MIN_DPI)
|
||||
}
|
||||
if yDPI == 0 {
|
||||
yDPI = float64(TEXTRACT_MIN_DPI)
|
||||
}
|
||||
|
||||
if xDPI < float64(TEXTRACT_MIN_DPI) || yDPI < float64(TEXTRACT_MIN_DPI) {
|
||||
return invalidReason(InvalidDocumentSmallDPI)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getExifDPI reads XResolution and YResolution from EXIF metadata.
|
||||
// Returns (0, 0) if EXIF data is not present or cannot be read.
|
||||
func (s *JPEG) getExifDPI() (xDPI, yDPI float64) {
|
||||
x, err := exif.Decode(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
xTag, err := x.Get(exif.XResolution)
|
||||
if err == nil {
|
||||
num, den, ratErr := xTag.Rat2(0)
|
||||
if ratErr == nil && den != 0 {
|
||||
xDPI = float64(num) / float64(den)
|
||||
}
|
||||
}
|
||||
|
||||
yTag, err := x.Get(exif.YResolution)
|
||||
if err == nil {
|
||||
num, den, ratErr := yTag.Rat2(0)
|
||||
if ratErr == nil && den != 0 {
|
||||
yDPI = float64(num) / float64(den)
|
||||
}
|
||||
}
|
||||
|
||||
// Check ResolutionUnit: 3 = dots per centimeter, convert to DPI
|
||||
unitTag, err := x.Get(exif.ResolutionUnit)
|
||||
if err == nil {
|
||||
unitVal, intErr := unitTag.Int(0)
|
||||
if intErr == nil && unitVal == 3 {
|
||||
xDPI *= 2.54
|
||||
yDPI *= 2.54
|
||||
}
|
||||
}
|
||||
|
||||
return xDPI, yDPI
|
||||
}
|
||||
|
||||
// getJFIFDPI parses the JFIF APP0 header to extract DPI values.
|
||||
// After FF D8, looks for FF E0 marker, skips length, checks "JFIF\0" identifier,
|
||||
// reads density unit at offset 7, and XDensity/YDensity at offsets 8-11.
|
||||
// Unit 1 = DPI, Unit 2 = DPCM (converted to DPI).
|
||||
func (s *JPEG) getJFIFDPI() (xDPI, yDPI float64) {
|
||||
// Minimum: SOI(2) + marker(2) + length(2) + JFIF\0(5) + version(2) + unit(1) + density(4) = 18
|
||||
if len(s.data) < 18 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// After SOI (FF D8), check for APP0 marker (FF E0)
|
||||
if s.data[2] != 0xFF || s.data[3] != 0xE0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// APP0 segment starts at offset 4 (after marker bytes)
|
||||
segStart := 4
|
||||
segLen := int(binary.BigEndian.Uint16(s.data[segStart : segStart+2]))
|
||||
if segStart+segLen > len(s.data) || segLen < 14 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Check JFIF identifier: "JFIF\0" at segStart+2
|
||||
jfifID := s.data[segStart+2 : segStart+7]
|
||||
if string(jfifID) != "JFIF\x00" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Density unit at segStart+9 (offset 7 within segment data after length)
|
||||
densityUnit := s.data[segStart+9]
|
||||
xDensity := binary.BigEndian.Uint16(s.data[segStart+10 : segStart+12])
|
||||
yDensity := binary.BigEndian.Uint16(s.data[segStart+12 : segStart+14])
|
||||
|
||||
slog.Debug("jpeg: JFIF density", "unit", densityUnit, "x", xDensity, "y", yDensity)
|
||||
|
||||
switch densityUnit {
|
||||
case 1: // Dots per inch
|
||||
return float64(xDensity), float64(yDensity)
|
||||
case 2: // Dots per centimeter, convert to DPI
|
||||
return float64(xDensity) * 2.54, float64(yDensity) * 2.54
|
||||
default:
|
||||
// Unit 0 = no units (aspect ratio only), treat as no DPI info
|
||||
return 0, 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJPEG_ValidFile(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.jpeg")
|
||||
require.NoError(t, err)
|
||||
|
||||
j := NewJPEG(data)
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := j.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason)
|
||||
})
|
||||
|
||||
t.Run("GetPageCount returns 1", func(t *testing.T) {
|
||||
count, err := j.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
|
||||
t.Run("GetPage(0) returns data", func(t *testing.T) {
|
||||
page, err := j.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, page)
|
||||
})
|
||||
}
|
||||
|
||||
func TestJPEG_InvalidMagicBytes(t *testing.T) {
|
||||
data := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
j := NewJPEG(data)
|
||||
|
||||
reason := j.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestJPEG_SmallDimensions(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/small.jpeg")
|
||||
require.NoError(t, err)
|
||||
|
||||
j := NewJPEG(data)
|
||||
reason := j.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentSmallDimensions, *reason)
|
||||
}
|
||||
|
||||
func TestJPEG_CorruptData(t *testing.T) {
|
||||
// Valid JPEG SOI marker (FF D8 FF) followed by garbage bytes.
|
||||
data := make([]byte, 128)
|
||||
data[0] = 0xFF
|
||||
data[1] = 0xD8
|
||||
data[2] = 0xFF
|
||||
// Rest is zeros -- not a decodable JPEG.
|
||||
|
||||
j := NewJPEG(data)
|
||||
reason := j.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentRead, *reason)
|
||||
}
|
||||
|
||||
func TestJPEG_SolidColor(t *testing.T) {
|
||||
// Generate a solid-color JPEG programmatically (100x100 all white).
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
for y := range 100 {
|
||||
for x := range 100 {
|
||||
img.Set(x, y, white)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
|
||||
require.NoError(t, err)
|
||||
|
||||
j := NewJPEG(buf.Bytes())
|
||||
reason := j.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentEmptyContent, *reason)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/richardlehane/mscfb"
|
||||
)
|
||||
|
||||
// officeMaxFileSize is the maximum allowed file size for Office documents (500MB).
|
||||
const officeMaxFileSize = 500 * 1024 * 1024
|
||||
|
||||
// openOfficeZIP opens raw data as a ZIP archive and returns a zip.Reader.
|
||||
// Returns an error if the data is not a valid ZIP.
|
||||
func openOfficeZIP(data []byte) (*zip.Reader, error) {
|
||||
return zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
}
|
||||
|
||||
// hasVBAMacros scans ZIP entries for vbaProject.bin in any path,
|
||||
// indicating the document contains VBA macros.
|
||||
func hasVBAMacros(zr *zip.Reader) bool {
|
||||
for _, f := range zr.File {
|
||||
if strings.HasSuffix(strings.ToLower(f.Name), "vbaproject.bin") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsOLE2Encrypted checks if data starts with the OLE2 compound document signature
|
||||
// and contains an EncryptedPackage stream, which indicates an encrypted OOXML document.
|
||||
func IsOLE2Encrypted(data []byte) bool {
|
||||
// OLE2 signature: D0 CF 11 E0 A1 B1 1A E1
|
||||
ole2Sig := []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
if len(data) < len(ole2Sig) || !bytes.Equal(data[:len(ole2Sig)], ole2Sig) {
|
||||
return false
|
||||
}
|
||||
|
||||
doc, err := mscfb.New(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for entry, err := doc.Next(); err == nil; entry, err = doc.Next() {
|
||||
if entry.Name == "EncryptedPackage" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// readZIPFile reads and returns the contents of a named file within a ZIP archive.
|
||||
func readZIPFile(zr *zip.Reader, name string) ([]byte, error) {
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open %s: %w", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("file %s not found in ZIP", name)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenOfficeZIP_Valid(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
zr, err := openOfficeZIP(data)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, zr)
|
||||
assert.Greater(t, len(zr.File), 0, "ZIP should contain entries")
|
||||
}
|
||||
|
||||
func TestOpenOfficeZIP_Invalid(t *testing.T) {
|
||||
garbage := []byte("this is not a zip file at all")
|
||||
|
||||
zr, err := openOfficeZIP(garbage)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, zr)
|
||||
}
|
||||
|
||||
func TestHasVBAMacros_WithMacros(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/macro.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
zr, err := openOfficeZIP(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, hasVBAMacros(zr), "macro.docx should contain VBA macros")
|
||||
}
|
||||
|
||||
func TestHasVBAMacros_WithoutMacros(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
zr, err := openOfficeZIP(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hasVBAMacros(zr), "valid.docx should not contain VBA macros")
|
||||
}
|
||||
|
||||
func TestIsOLE2Encrypted(t *testing.T) {
|
||||
t.Run("OLE2 signature without valid structure returns false", func(t *testing.T) {
|
||||
// encrypted.docx has the OLE2 signature bytes but is not a fully valid
|
||||
// OLE2 compound document with an EncryptedPackage stream, so
|
||||
// IsOLE2Encrypted returns false (mscfb cannot parse it).
|
||||
data, err := os.ReadFile("testdata/multiformat/encrypted.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it does start with OLE2 signature
|
||||
ole2Sig := []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
require.True(t, len(data) >= len(ole2Sig))
|
||||
assert.Equal(t, ole2Sig, data[:len(ole2Sig)], "fixture should start with OLE2 signature")
|
||||
|
||||
// But the file is not a parseable OLE2 compound document
|
||||
assert.False(t, IsOLE2Encrypted(data),
|
||||
"malformed OLE2 file should not be detected as encrypted")
|
||||
})
|
||||
|
||||
t.Run("valid docx returns false", func(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, IsOLE2Encrypted(data), "valid.docx should not be detected as OLE2 encrypted")
|
||||
})
|
||||
|
||||
t.Run("garbage bytes return false", func(t *testing.T) {
|
||||
data := []byte("not ole2 at all")
|
||||
assert.False(t, IsOLE2Encrypted(data))
|
||||
})
|
||||
|
||||
t.Run("too short returns false", func(t *testing.T) {
|
||||
data := []byte{0xD0, 0xCF}
|
||||
assert.False(t, IsOLE2Encrypted(data))
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,24 @@ func ToDBMimeType(t MimeType) repository.Cleanmimetype {
|
||||
switch t {
|
||||
case MimeTypePDF:
|
||||
return repository.CleanmimetypeApplicationPdf
|
||||
case MimeTypeTIFF:
|
||||
return repository.CleanmimetypeImageTiff
|
||||
case MimeTypeJPEG:
|
||||
return repository.CleanmimetypeImageJpeg
|
||||
case MimeTypePNG:
|
||||
return repository.CleanmimetypeImagePng
|
||||
case MimeTypeBMP:
|
||||
return repository.CleanmimetypeImageBmp
|
||||
case MimeTypeDOCX:
|
||||
return repository.CleanmimetypeApplicationDocx
|
||||
case MimeTypeXLSX:
|
||||
return repository.CleanmimetypeApplicationXlsx
|
||||
case MimeTypePPTX:
|
||||
return repository.CleanmimetypeApplicationPptx
|
||||
case MimeTypeTXT:
|
||||
return repository.CleanmimetypeTextPlain
|
||||
case MimeTypeEML:
|
||||
return repository.CleanmimetypeMessageRfc822
|
||||
}
|
||||
|
||||
return repository.Cleanmimetype("")
|
||||
@@ -15,6 +33,24 @@ func ParseDBMimeType(t repository.Cleanmimetype) MimeType {
|
||||
switch t {
|
||||
case repository.CleanmimetypeApplicationPdf:
|
||||
return MimeTypePDF
|
||||
case repository.CleanmimetypeImageTiff:
|
||||
return MimeTypeTIFF
|
||||
case repository.CleanmimetypeImageJpeg:
|
||||
return MimeTypeJPEG
|
||||
case repository.CleanmimetypeImagePng:
|
||||
return MimeTypePNG
|
||||
case repository.CleanmimetypeImageBmp:
|
||||
return MimeTypeBMP
|
||||
case repository.CleanmimetypeApplicationDocx:
|
||||
return MimeTypeDOCX
|
||||
case repository.CleanmimetypeApplicationXlsx:
|
||||
return MimeTypeXLSX
|
||||
case repository.CleanmimetypeApplicationPptx:
|
||||
return MimeTypePPTX
|
||||
case repository.CleanmimetypeTextPlain:
|
||||
return MimeTypeTXT
|
||||
case repository.CleanmimetypeMessageRfc822:
|
||||
return MimeTypeEML
|
||||
}
|
||||
|
||||
return MimeTypeInvalid
|
||||
@@ -40,6 +76,18 @@ func ToDBFailType(t InvalidDocumentReason) repository.Cleanfailtype {
|
||||
return repository.CleanfailtypeSmallDpi
|
||||
case InvalidDocumentLargeDPI:
|
||||
return repository.CleanfailtypeLargeDpi
|
||||
case InvalidDocumentPasswordProtected:
|
||||
return repository.CleanfailtypePasswordProtected
|
||||
case InvalidDocumentContainsMacros:
|
||||
return repository.CleanfailtypeContainsMacros
|
||||
case InvalidDocumentEmptyContent:
|
||||
return repository.CleanfailtypeEmptyContent
|
||||
case InvalidDocumentBinaryContent:
|
||||
return repository.CleanfailtypeBinaryContent
|
||||
case InvalidDocumentUnsupportedEncoding:
|
||||
return repository.CleanfailtypeUnsupportedEncoding
|
||||
case InvalidDocumentContainsAttachments:
|
||||
return repository.CleanfailtypeContainsAttachments
|
||||
}
|
||||
|
||||
return repository.Cleanfailtype("")
|
||||
@@ -65,6 +113,18 @@ func ParseDBFailType(t repository.Cleanfailtype) InvalidDocumentReason {
|
||||
return InvalidDocumentSmallDPI
|
||||
case repository.CleanfailtypeLargeDpi:
|
||||
return InvalidDocumentLargeDPI
|
||||
case repository.CleanfailtypePasswordProtected:
|
||||
return InvalidDocumentPasswordProtected
|
||||
case repository.CleanfailtypeContainsMacros:
|
||||
return InvalidDocumentContainsMacros
|
||||
case repository.CleanfailtypeEmptyContent:
|
||||
return InvalidDocumentEmptyContent
|
||||
case repository.CleanfailtypeBinaryContent:
|
||||
return InvalidDocumentBinaryContent
|
||||
case repository.CleanfailtypeUnsupportedEncoding:
|
||||
return InvalidDocumentUnsupportedEncoding
|
||||
case repository.CleanfailtypeContainsAttachments:
|
||||
return InvalidDocumentContainsAttachments
|
||||
}
|
||||
|
||||
return InvalidDocumentReason("")
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// pngPixelBudget is the maximum allowed pixel count (width * height) for PNG files.
|
||||
const pngPixelBudget = 100_000_000
|
||||
|
||||
// PNG implements the File interface for PNG image validation.
|
||||
// It validates magic bytes, dimensions, pixel budget, DPI (via pHYs chunk), and content.
|
||||
type PNG struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewPNGFromReader creates a new PNG validator by reading all bytes from r.
|
||||
func NewPNGFromReader(r io.Reader) (*PNG, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewPNG(data), nil
|
||||
}
|
||||
|
||||
// NewPNG creates a new PNG validator from raw file bytes.
|
||||
func NewPNG(data []byte) *PNG {
|
||||
return &PNG{data: data}
|
||||
}
|
||||
|
||||
// IsCorrupt validates the PNG file for corruption or unsupported content.
|
||||
func (s *PNG) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
if reason := s.checkMagicBytes(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
// Pre-check IHDR dimensions before decoding to prevent decompression bombs.
|
||||
// IHDR is always the first chunk after the 8-byte PNG signature:
|
||||
// bytes 16-19 = width, bytes 20-23 = height (big-endian uint32).
|
||||
if len(s.data) < 24 {
|
||||
return invalidReason(InvalidDocumentRead)
|
||||
}
|
||||
ihdrWidth := binary.BigEndian.Uint32(s.data[16:20])
|
||||
ihdrHeight := binary.BigEndian.Uint32(s.data[20:24])
|
||||
if int64(ihdrWidth)*int64(ihdrHeight) > pngPixelBudget {
|
||||
return invalidReason(InvalidDocumentLargeFile)
|
||||
}
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
return invalidReason(InvalidDocumentRead)
|
||||
}
|
||||
|
||||
if len(s.data) > imageMaxFileSize {
|
||||
return invalidReason(InvalidDocumentLargeFile)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
|
||||
if reason := checkImageDimensions(width, height); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if reason := s.checkDPI(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
// Check for fully transparent images (PNG-specific alpha check)
|
||||
if colorModelHasAlpha(img) && isFullyTransparent(img) {
|
||||
return invalidReason(InvalidDocumentEmptyContent)
|
||||
}
|
||||
|
||||
if isSolidColor(img) {
|
||||
return invalidReason(InvalidDocumentEmptyContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns 1 since PNG files are always single-page.
|
||||
func (s *PNG) GetPageCount(_ context.Context) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// GetPage returns the raw PNG data for page index 0.
|
||||
func (s *PNG) GetPage(_ context.Context, index int) ([]byte, error) {
|
||||
if index != 0 {
|
||||
return nil, fmt.Errorf("png: invalid page index %d: PNG files have exactly 1 page", index)
|
||||
}
|
||||
return s.data, nil
|
||||
}
|
||||
|
||||
// checkMagicBytes verifies the 8-byte PNG signature: 89 50 4E 47 0D 0A 1A 0A.
|
||||
func (s *PNG) checkMagicBytes() *InvalidDocumentReason {
|
||||
pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
if len(s.data) < len(pngSig) {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
if !bytes.Equal(s.data[:len(pngSig)], pngSig) {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDPI reads the pHYs chunk from PNG data to determine DPI.
|
||||
// Returns nil if DPI is acceptable or if no pHYs chunk is present (defaults to 72).
|
||||
func (s *PNG) checkDPI() *InvalidDocumentReason {
|
||||
xDPI, yDPI := s.parsePHYsChunk()
|
||||
|
||||
// Default to 72 if no pHYs chunk found
|
||||
if xDPI == 0 {
|
||||
xDPI = float64(TEXTRACT_MIN_DPI)
|
||||
}
|
||||
if yDPI == 0 {
|
||||
yDPI = float64(TEXTRACT_MIN_DPI)
|
||||
}
|
||||
|
||||
if xDPI < float64(TEXTRACT_MIN_DPI) || yDPI < float64(TEXTRACT_MIN_DPI) {
|
||||
return invalidReason(InvalidDocumentSmallDPI)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePHYsChunk iterates PNG chunks after the 8-byte signature looking for "pHYs".
|
||||
// Each chunk: 4 bytes length + 4 bytes type + data + 4 bytes CRC.
|
||||
// pHYs data: 4 bytes X pixels per unit, 4 bytes Y pixels per unit, 1 byte unit type.
|
||||
// Unit 1 = meter; conversion: dpi = ppu / 39.3701.
|
||||
func (s *PNG) parsePHYsChunk() (xDPI, yDPI float64) {
|
||||
// PNG signature is 8 bytes, minimum chunk is 12 bytes (4 len + 4 type + 4 CRC)
|
||||
if len(s.data) < 20 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
offset := 8 // Skip PNG signature
|
||||
for offset+12 <= len(s.data) {
|
||||
chunkLen := binary.BigEndian.Uint32(s.data[offset : offset+4])
|
||||
chunkType := string(s.data[offset+4 : offset+8])
|
||||
|
||||
// Ensure we have enough data for the chunk
|
||||
chunkEnd := offset + 12 + int(chunkLen)
|
||||
if chunkEnd > len(s.data) {
|
||||
break
|
||||
}
|
||||
|
||||
if chunkType == "pHYs" && chunkLen == 9 {
|
||||
dataStart := offset + 8
|
||||
xPPU := binary.BigEndian.Uint32(s.data[dataStart : dataStart+4])
|
||||
yPPU := binary.BigEndian.Uint32(s.data[dataStart+4 : dataStart+8])
|
||||
unit := s.data[dataStart+8]
|
||||
|
||||
slog.Debug("png: pHYs chunk", "xPPU", xPPU, "yPPU", yPPU, "unit", unit)
|
||||
|
||||
if unit == 1 { // Meter
|
||||
return float64(xPPU) / 39.3701, float64(yPPU) / 39.3701
|
||||
}
|
||||
// Unit 0 = unknown, no conversion possible
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Move past this chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC)
|
||||
offset = chunkEnd
|
||||
}
|
||||
|
||||
return 0, 0
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPNG_ValidFile(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.png")
|
||||
require.NoError(t, err)
|
||||
|
||||
p := NewPNG(data)
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := p.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason)
|
||||
})
|
||||
|
||||
t.Run("GetPageCount returns 1", func(t *testing.T) {
|
||||
count, err := p.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPNG_InvalidMagicBytes(t *testing.T) {
|
||||
data := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09}
|
||||
p := NewPNG(data)
|
||||
|
||||
reason := p.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestPNG_SolidColor(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/solid_color.png")
|
||||
require.NoError(t, err)
|
||||
|
||||
p := NewPNG(data)
|
||||
reason := p.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentEmptyContent, *reason)
|
||||
}
|
||||
|
||||
func TestPNG_DecompressionBomb(t *testing.T) {
|
||||
// Craft a minimal PNG with valid signature and IHDR declaring enormous
|
||||
// dimensions (50000x50000 = 2.5 billion pixels) but a tiny file size.
|
||||
// The IHDR pre-check should reject this before attempting full decode.
|
||||
data := make([]byte, 64)
|
||||
// PNG signature
|
||||
copy(data[0:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
|
||||
// IHDR chunk: length=13, type="IHDR"
|
||||
data[8] = 0x00
|
||||
data[9] = 0x00
|
||||
data[10] = 0x00
|
||||
data[11] = 0x0D
|
||||
copy(data[12:16], []byte("IHDR"))
|
||||
// Width: 50000 (0x0000C350) big-endian
|
||||
data[16] = 0x00
|
||||
data[17] = 0x00
|
||||
data[18] = 0xC3
|
||||
data[19] = 0x50
|
||||
// Height: 50000 (0x0000C350) big-endian
|
||||
data[20] = 0x00
|
||||
data[21] = 0x00
|
||||
data[22] = 0xC3
|
||||
data[23] = 0x50
|
||||
|
||||
p := NewPNG(data)
|
||||
reason := p.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentLargeFile, *reason,
|
||||
"PNG with enormous IHDR dimensions should be rejected before decode")
|
||||
}
|
||||
|
||||
func TestPNG_CorruptData(t *testing.T) {
|
||||
// Valid 8-byte PNG signature followed by garbage data.
|
||||
data := make([]byte, 128)
|
||||
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
|
||||
// Remaining bytes are zeros -- not a valid PNG stream.
|
||||
|
||||
p := NewPNG(data)
|
||||
reason := p.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentRead, *reason)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PPTX represents a PowerPoint OOXML presentation for validation and slide extraction.
|
||||
type PPTX struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewPPTX creates a new PPTX validator from raw file data.
|
||||
func NewPPTX(data []byte) *PPTX {
|
||||
return &PPTX{data: data}
|
||||
}
|
||||
|
||||
// pptxPresentation is a minimal XML representation of ppt/presentation.xml,
|
||||
// capturing the modify verifier for encryption detection.
|
||||
type pptxPresentation struct {
|
||||
XMLName xml.Name `xml:"presentation"`
|
||||
ModifyVerifier *pptxModifyVerifier `xml:"modifyVerifier"`
|
||||
}
|
||||
|
||||
type pptxModifyVerifier struct {
|
||||
CryptProviderType string `xml:"cryptProviderType,attr"`
|
||||
CryptAlgorithmSid string `xml:"cryptAlgorithmSid,attr"`
|
||||
HashData string `xml:"hashData,attr"`
|
||||
SaltData string `xml:"saltData,attr"`
|
||||
}
|
||||
|
||||
// pptxSlide is a minimal XML representation of a slide,
|
||||
// capturing text runs, pictures, and graphic frames to detect content.
|
||||
type pptxSlide struct {
|
||||
XMLName xml.Name `xml:"sld"`
|
||||
CSld pptxCSld `xml:"cSld"`
|
||||
}
|
||||
|
||||
type pptxCSld struct {
|
||||
SpTree pptxSpTree `xml:"spTree"`
|
||||
}
|
||||
|
||||
type pptxSpTree struct {
|
||||
Shapes []pptxShape `xml:"sp"`
|
||||
Pictures []struct{} `xml:"pic"`
|
||||
GraphicFrames []struct{} `xml:"graphicFrame"`
|
||||
}
|
||||
|
||||
type pptxShape struct {
|
||||
TxBody *pptxTxBody `xml:"txBody"`
|
||||
}
|
||||
|
||||
type pptxTxBody struct {
|
||||
Paragraphs []pptxParagraph `xml:"p"`
|
||||
}
|
||||
|
||||
type pptxParagraph struct {
|
||||
Runs []pptxRun `xml:"r"`
|
||||
}
|
||||
|
||||
type pptxRun struct {
|
||||
Text string `xml:"t"`
|
||||
}
|
||||
|
||||
// IsCorrupt validates the PPTX document, returning the reason it is invalid
|
||||
// or nil if the document passes all checks.
|
||||
func (s *PPTX) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
// 1. Check for OLE2 encryption (encrypted OOXML files use OLE2 wrapper)
|
||||
if IsOLE2Encrypted(s.data) {
|
||||
reason := InvalidDocumentPasswordProtected
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 2. Check ZIP signature
|
||||
if !hasZIPSignature(s.data) {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 3. Open ZIP and verify content type
|
||||
zr, err := openOfficeZIP(s.data)
|
||||
if err != nil {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
ct, err := readZIPFile(zr, "[Content_Types].xml")
|
||||
if err != nil || !strings.Contains(string(ct), "presentationml.presentation.main+xml") {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 4. Reject if macros are present
|
||||
if hasVBAMacros(zr) {
|
||||
reason := InvalidDocumentContainsMacros
|
||||
return &reason
|
||||
}
|
||||
if r := s.hasPasswordProtection(zr); r != nil {
|
||||
return r
|
||||
}
|
||||
|
||||
// 5. Verify presentation.xml exists and parses; at least one slide exists
|
||||
if r := s.validateStructure(zr); r != nil {
|
||||
return r
|
||||
}
|
||||
|
||||
// 6. File size check
|
||||
if len(s.data) > officeMaxFileSize {
|
||||
reason := InvalidDocumentLargeFile
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 7. Content check
|
||||
if !s.hasContent(zr) {
|
||||
reason := InvalidDocumentEmptyContent
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 8. Slide count check
|
||||
slideCount := s.countSlides(zr)
|
||||
if slideCount < 1 {
|
||||
reason := InvalidDocumentZeroPageCount
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns the number of slides in the presentation.
|
||||
func (s *PPTX) GetPageCount(_ context.Context) (int, error) {
|
||||
zr, err := openOfficeZIP(s.data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := max(s.countSlides(zr), 1)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetPage returns the raw presentation bytes for the requested page index.
|
||||
func (s *PPTX) GetPage(_ context.Context, _ int) ([]byte, error) {
|
||||
return s.data, nil
|
||||
}
|
||||
|
||||
// hasPasswordProtection checks ppt/presentation.xml for modifyVerifier
|
||||
// elements indicating password protection.
|
||||
func (s *PPTX) hasPasswordProtection(zr *zip.Reader) *InvalidDocumentReason {
|
||||
presXML, err := readZIPFile(zr, "ppt/presentation.xml")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pres pptxPresentation
|
||||
if xmlErr := xml.Unmarshal(presXML, &pres); xmlErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if pres.ModifyVerifier != nil &&
|
||||
(pres.ModifyVerifier.CryptProviderType != "" ||
|
||||
pres.ModifyVerifier.HashData != "" ||
|
||||
pres.ModifyVerifier.SaltData != "") {
|
||||
reason := InvalidDocumentPasswordProtected
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStructure verifies that ppt/presentation.xml exists and parses,
|
||||
// and at least one slide XML file is present.
|
||||
func (s *PPTX) validateStructure(zr *zip.Reader) *InvalidDocumentReason {
|
||||
presXML, err := readZIPFile(zr, "ppt/presentation.xml")
|
||||
if err != nil {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
|
||||
var pres pptxPresentation
|
||||
if xmlErr := xml.Unmarshal(presXML, &pres); xmlErr != nil {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
|
||||
if s.countSlides(zr) < 1 {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasContent checks whether any slide has text content, pictures, or graphic frames.
|
||||
func (s *PPTX) hasContent(zr *zip.Reader) bool {
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasPrefix(f.Name, "ppt/slides/slide") ||
|
||||
!strings.HasSuffix(f.Name, ".xml") {
|
||||
continue
|
||||
}
|
||||
|
||||
slideXML, err := readZIPFile(zr, f.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var slide pptxSlide
|
||||
if xmlErr := xml.Unmarshal(slideXML, &slide); xmlErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for pictures or graphic frames
|
||||
if len(slide.CSld.SpTree.Pictures) > 0 || len(slide.CSld.SpTree.GraphicFrames) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for text content in shapes
|
||||
for _, sp := range slide.CSld.SpTree.Shapes {
|
||||
if sp.TxBody == nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range sp.TxBody.Paragraphs {
|
||||
for _, r := range p.Runs {
|
||||
if strings.TrimSpace(r.Text) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// countSlides counts the number of ppt/slides/slide*.xml entries in the ZIP.
|
||||
func (s *PPTX) countSlides(zr *zip.Reader) int {
|
||||
count := 0
|
||||
for _, f := range zr.File {
|
||||
if strings.HasPrefix(f.Name, "ppt/slides/slide") &&
|
||||
strings.HasSuffix(f.Name, ".xml") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPPTX_ValidFile(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.pptx")
|
||||
require.NoError(t, err)
|
||||
|
||||
pptx := NewPPTX(data)
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := pptx.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason, "valid.pptx should not be corrupt")
|
||||
})
|
||||
|
||||
t.Run("GetPageCount >= 1", func(t *testing.T) {
|
||||
count, err := pptx.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, count, 1)
|
||||
})
|
||||
|
||||
t.Run("GetPage returns data", func(t *testing.T) {
|
||||
page, err := pptx.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, page)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPPTX_InvalidMagicBytes(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data := []byte("this is not a ZIP or PPTX file")
|
||||
|
||||
pptx := NewPPTX(data)
|
||||
reason := pptx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestPPTX_Encrypted(t *testing.T) {
|
||||
// encrypted.docx has the OLE2 signature but is not a parseable OLE2 compound
|
||||
// document (malformed fixture). IsOLE2Encrypted returns false, then the ZIP
|
||||
// signature check fails, so it returns InvalidDocumentMimeType.
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/encrypted.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
pptx := NewPPTX(data)
|
||||
reason := pptx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason,
|
||||
"malformed OLE2 file should fail ZIP signature check")
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
//go:build ignore
|
||||
|
||||
// generate_fixtures.go creates minimal but valid test fixture files for
|
||||
// document format testing. Run with:
|
||||
//
|
||||
// go run internal/document/types/testdata/generate_fixtures.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
tiff "github.com/chai2010/tiff"
|
||||
"golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
// outDir is the directory where all fixtures will be written.
|
||||
const outDir = "internal/document/types/testdata/multiformat"
|
||||
|
||||
func main() {
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
fatal("create output dir", err)
|
||||
}
|
||||
|
||||
generators := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"valid.tiff", genValidTIFF},
|
||||
{"valid.jpeg", genValidJPEG},
|
||||
{"valid.png", genValidPNG},
|
||||
{"valid.bmp", genValidBMP},
|
||||
{"valid.docx", genValidDOCX},
|
||||
{"valid.xlsx", genValidXLSX},
|
||||
{"valid.pptx", genValidPPTX},
|
||||
{"valid.txt", genValidTXT},
|
||||
{"valid.eml", genValidEML},
|
||||
{"empty.txt", genEmptyTXT},
|
||||
{"binary.txt", genBinaryTXT},
|
||||
{"encrypted.docx", genEncryptedDOCX},
|
||||
{"solid_color.png", genSolidColorPNG},
|
||||
{"small.jpeg", genSmallJPEG},
|
||||
{"macro.docx", genMacroDOCX},
|
||||
}
|
||||
|
||||
for _, g := range generators {
|
||||
fmt.Printf("generating %s ... ", g.name)
|
||||
if err := g.fn(); err != nil {
|
||||
fatal(g.name, err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(outDir, g.name))
|
||||
if err != nil {
|
||||
fatal(g.name+" stat", err)
|
||||
}
|
||||
fmt.Printf("OK (%d bytes)\n", info.Size())
|
||||
}
|
||||
|
||||
fmt.Println("\nAll fixtures generated successfully.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func fatal(context string, err error) {
|
||||
fmt.Fprintf(os.Stderr, "FATAL [%s]: %v\n", context, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// writeFile writes data to outDir/name.
|
||||
func writeFile(name string, data []byte) error {
|
||||
return os.WriteFile(filepath.Join(outDir, name), data, 0o644)
|
||||
}
|
||||
|
||||
// newTestImage creates a 100x100 white image with a red rectangle in the
|
||||
// centre (40,40)-(60,60) so it passes solid-color detection checks.
|
||||
func newTestImage(width, height int) *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
red := color.NRGBA{R: 255, G: 0, B: 0, A: 255}
|
||||
|
||||
// Fill white background.
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img.SetNRGBA(x, y, white)
|
||||
}
|
||||
}
|
||||
// Draw red rectangle in the centre.
|
||||
cx, cy := width/2, height/2
|
||||
for y := cy - height/5; y < cy+height/5; y++ {
|
||||
for x := cx - width/5; x < cx+width/5; x++ {
|
||||
if x >= 0 && x < width && y >= 0 && y < height {
|
||||
img.SetNRGBA(x, y, red)
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// createZipFile builds a ZIP archive from the given entries and writes it.
|
||||
func createZipFile(name string, entries []zipEntry) error {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
for _, e := range entries {
|
||||
fw, err := zw.Create(e.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip create %s: %w", e.Name, err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(e.Body)); err != nil {
|
||||
return fmt.Errorf("zip write %s: %w", e.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return fmt.Errorf("zip close: %w", err)
|
||||
}
|
||||
return writeFile(name, buf.Bytes())
|
||||
}
|
||||
|
||||
type zipEntry struct {
|
||||
Name string
|
||||
Body string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Valid image fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func genValidTIFF() error {
|
||||
img := newTestImage(100, 100)
|
||||
var buf bytes.Buffer
|
||||
if err := tiff.Encode(&buf, img, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile("valid.tiff", buf.Bytes())
|
||||
}
|
||||
|
||||
func genValidJPEG() error {
|
||||
img := newTestImage(100, 100)
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile("valid.jpeg", buf.Bytes())
|
||||
}
|
||||
|
||||
func genValidPNG() error {
|
||||
img := newTestImage(100, 100)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile("valid.png", buf.Bytes())
|
||||
}
|
||||
|
||||
func genValidBMP() error {
|
||||
img := newTestImage(100, 100)
|
||||
var buf bytes.Buffer
|
||||
if err := bmp.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile("valid.bmp", buf.Bytes())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Valid Office fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func genValidDOCX() error {
|
||||
return createZipFile("valid.docx", []zipEntry{
|
||||
{
|
||||
Name: "[Content_Types].xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>`,
|
||||
},
|
||||
{
|
||||
Name: "_rels/.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
|
||||
Target="word/document.xml"/>
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "word/_rels/document.xml.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "word/document.xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:t>Test document content</w:t>
|
||||
</w:r>
|
||||
</w:p>
|
||||
</w:body>
|
||||
</w:document>`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func genValidXLSX() error {
|
||||
return createZipFile("valid.xlsx", []zipEntry{
|
||||
{
|
||||
Name: "[Content_Types].xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/sharedStrings.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
||||
</Types>`,
|
||||
},
|
||||
{
|
||||
Name: "_rels/.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
|
||||
Target="xl/workbook.xml"/>
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "xl/_rels/workbook.xml.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
|
||||
Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"
|
||||
Target="sharedStrings.xml"/>
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "xl/workbook.xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>
|
||||
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
|
||||
</sheets>
|
||||
</workbook>`,
|
||||
},
|
||||
{
|
||||
Name: "xl/worksheets/sheet1.xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData>
|
||||
<row r="1">
|
||||
<c r="A1" t="s"><v>0</v></c>
|
||||
</row>
|
||||
</sheetData>
|
||||
</worksheet>`,
|
||||
},
|
||||
{
|
||||
Name: "xl/sharedStrings.xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
|
||||
<si><t>Test data</t></si>
|
||||
</sst>`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func genValidPPTX() error {
|
||||
return createZipFile("valid.pptx", []zipEntry{
|
||||
{
|
||||
Name: "[Content_Types].xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/ppt/presentation.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
||||
<Override PartName="/ppt/slides/slide1.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
|
||||
</Types>`,
|
||||
},
|
||||
{
|
||||
Name: "_rels/.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
|
||||
Target="ppt/presentation.xml"/>
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "ppt/_rels/presentation.xml.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
|
||||
Target="slides/slide1.xml"/>
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "ppt/presentation.xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<p:sldIdLst>
|
||||
<p:sldId id="256" r:id="rId1"/>
|
||||
</p:sldIdLst>
|
||||
</p:presentation>`,
|
||||
},
|
||||
{
|
||||
Name: "ppt/slides/_rels/slide1.xml.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "ppt/slides/slide1.xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr/>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="2" name="Title 1"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr/>
|
||||
</p:nvSpPr>
|
||||
<p:spPr/>
|
||||
<p:txBody>
|
||||
<a:bodyPr/>
|
||||
<a:lstStyle/>
|
||||
<a:p><a:r><a:t>Test slide content</a:t></a:r></a:p>
|
||||
</p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Valid text fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func genValidTXT() error {
|
||||
return writeFile("valid.txt", []byte("Hello, this is a test document.\nIt has multiple lines.\n"))
|
||||
}
|
||||
|
||||
func genValidEML() error {
|
||||
content := "From: sender@example.com\r\n" +
|
||||
"To: recipient@example.com\r\n" +
|
||||
"Subject: Test Email\r\n" +
|
||||
"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" +
|
||||
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
|
||||
"\r\n" +
|
||||
"This is the body of the test email.\r\n"
|
||||
return writeFile("valid.eml", []byte(content))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invalid / edge-case fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func genEmptyTXT() error {
|
||||
return writeFile("empty.txt", []byte{})
|
||||
}
|
||||
|
||||
func genBinaryTXT() error {
|
||||
// Mix of printable text and null bytes.
|
||||
data := []byte("Hello\x00World\x00\x00This has\x00null bytes\x00inside")
|
||||
return writeFile("binary.txt", data)
|
||||
}
|
||||
|
||||
func genEncryptedDOCX() error {
|
||||
// OLE2 compound document signature followed by garbage data.
|
||||
// This simulates an encrypted or password-protected Office file.
|
||||
ole2Sig := []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
garbage := make([]byte, 512)
|
||||
for i := range garbage {
|
||||
garbage[i] = byte(i % 256)
|
||||
}
|
||||
data := append(ole2Sig, garbage...)
|
||||
return writeFile("encrypted.docx", data)
|
||||
}
|
||||
|
||||
func genSolidColorPNG() error {
|
||||
// 100x100 solid white image (no variation).
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 100, 100))
|
||||
white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
for y := 0; y < 100; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
img.SetNRGBA(x, y, white)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile("solid_color.png", buf.Bytes())
|
||||
}
|
||||
|
||||
func genSmallJPEG() error {
|
||||
// 10x10 JPEG -- too small for many document processing scenarios.
|
||||
img := newTestImage(10, 10)
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile("small.jpeg", buf.Bytes())
|
||||
}
|
||||
|
||||
func genMacroDOCX() error {
|
||||
// Valid DOCX structure with an extra word/vbaProject.bin entry,
|
||||
// indicating the document contains macros.
|
||||
return createZipFile("macro.docx", []zipEntry{
|
||||
{
|
||||
Name: "[Content_Types].xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>
|
||||
<Override PartName="/word/document.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>`,
|
||||
},
|
||||
{
|
||||
Name: "_rels/.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
|
||||
Target="word/document.xml"/>
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "word/_rels/document.xml.rels",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject"
|
||||
Target="vbaProject.bin"/>
|
||||
</Relationships>`,
|
||||
},
|
||||
{
|
||||
Name: "word/document.xml",
|
||||
Body: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:t>Document with macros</w:t>
|
||||
</w:r>
|
||||
</w:p>
|
||||
</w:body>
|
||||
</w:document>`,
|
||||
},
|
||||
{
|
||||
Name: "word/vbaProject.bin",
|
||||
Body: "FAKE_VBA_PROJECT_BINARY_CONTENT",
|
||||
},
|
||||
})
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 680 B |
Binary file not shown.
|
After Width: | Height: | Size: 294 B |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test Email
|
||||
Date: Mon, 01 Jan 2024 00:00:00 +0000
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
This is the body of the test email.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 315 B |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
Hello, this is a test document.
|
||||
It has multiple lines.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,183 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"github.com/chai2010/tiff"
|
||||
)
|
||||
|
||||
// TIFF implements the File interface for TIFF image validation.
|
||||
// It supports multi-frame TIFF files with DPI validation via IFD tags.
|
||||
type TIFF struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewTIFFFromReader creates a new TIFF validator by reading all bytes from r.
|
||||
func NewTIFFFromReader(r io.Reader) (*TIFF, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewTIFF(data), nil
|
||||
}
|
||||
|
||||
// NewTIFF creates a new TIFF validator from raw file bytes.
|
||||
func NewTIFF(data []byte) *TIFF {
|
||||
return &TIFF{data: data}
|
||||
}
|
||||
|
||||
// IsCorrupt validates the TIFF file for corruption or unsupported content.
|
||||
func (s *TIFF) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
if reason := s.checkMagicBytes(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
reader, err := tiff.OpenReader(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
return invalidReason(InvalidDocumentRead)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
frameCount := reader.ImageNum()
|
||||
if frameCount < 1 {
|
||||
return invalidReason(InvalidDocumentZeroPageCount)
|
||||
}
|
||||
|
||||
if len(s.data) > imageMaxFileSize {
|
||||
return invalidReason(InvalidDocumentLargeFile)
|
||||
}
|
||||
|
||||
hasContent := false
|
||||
for i := range frameCount {
|
||||
if reason := s.validateFrame(reader, i); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
img, err := reader.DecodeImage(i, 0)
|
||||
if err != nil {
|
||||
slog.Debug("tiff: failed to decode frame", "index", i, "error", err)
|
||||
return invalidReason(InvalidDocumentRead)
|
||||
}
|
||||
|
||||
if !hasContent && hasNonZeroPixelData(img) {
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasContent {
|
||||
return invalidReason(InvalidDocumentEmptyContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns the number of IFD frames in the TIFF file.
|
||||
func (s *TIFF) GetPageCount(_ context.Context) (int, error) {
|
||||
reader, err := tiff.OpenReader(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("tiff: failed to open reader: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
return reader.ImageNum(), nil
|
||||
}
|
||||
|
||||
// GetPage encodes the specified frame as PNG bytes.
|
||||
func (s *TIFF) GetPage(_ context.Context, index int) ([]byte, error) {
|
||||
reader, err := tiff.OpenReader(bytes.NewReader(s.data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tiff: failed to open reader: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
if index < 0 || index >= reader.ImageNum() {
|
||||
return nil, fmt.Errorf("tiff: page index %d out of range [0, %d)", index, reader.ImageNum())
|
||||
}
|
||||
|
||||
img, err := reader.DecodeImage(index, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tiff: failed to decode frame %d: %w", index, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, fmt.Errorf("tiff: failed to encode frame %d as PNG: %w", index, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// checkMagicBytes verifies the TIFF file signature.
|
||||
// Little-endian: 49 49 2A 00, Big-endian: 4D 4D 00 2A.
|
||||
func (s *TIFF) checkMagicBytes() *InvalidDocumentReason {
|
||||
if len(s.data) < 4 {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
|
||||
le := s.data[0] == 0x49 && s.data[1] == 0x49 && s.data[2] == 0x2A && s.data[3] == 0x00
|
||||
be := s.data[0] == 0x4D && s.data[1] == 0x4D && s.data[2] == 0x00 && s.data[3] == 0x2A
|
||||
|
||||
if !le && !be {
|
||||
return invalidReason(InvalidDocumentMimeType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFrame checks dimensions and DPI for a single TIFF frame.
|
||||
func (s *TIFF) validateFrame(reader *tiff.Reader, frameIndex int) *InvalidDocumentReason {
|
||||
cfg, err := reader.ImageConfig(frameIndex, 0)
|
||||
if err != nil {
|
||||
return invalidReason(InvalidDocumentRead)
|
||||
}
|
||||
|
||||
if reason := checkImageDimensions(cfg.Width, cfg.Height); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
return s.validateFrameDPI(reader, frameIndex)
|
||||
}
|
||||
|
||||
// validateFrameDPI reads XResolution/YResolution tags from the IFD entry
|
||||
// and validates that DPI meets the minimum threshold.
|
||||
// Defaults to 72 DPI if resolution tags are absent.
|
||||
func (s *TIFF) validateFrameDPI(reader *tiff.Reader, frameIndex int) *InvalidDocumentReason {
|
||||
if frameIndex >= len(reader.Ifd) || len(reader.Ifd[frameIndex]) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ifd := reader.Ifd[frameIndex][0]
|
||||
tags := ifd.TagGetter()
|
||||
|
||||
xDPI := float64(TEXTRACT_MIN_DPI)
|
||||
yDPI := float64(TEXTRACT_MIN_DPI)
|
||||
|
||||
resUnit, unitOK := tags.GetResolutionUnit()
|
||||
|
||||
xRes, xOK := tags.GetXResolution()
|
||||
if xOK && xRes[1] != 0 {
|
||||
xDPI = float64(xRes[0]) / float64(xRes[1])
|
||||
if unitOK && resUnit == tiff.TagValue_ResolutionUnitType_PerCM {
|
||||
xDPI *= 2.54
|
||||
}
|
||||
}
|
||||
|
||||
yRes, yOK := tags.GetYResolution()
|
||||
if yOK && yRes[1] != 0 {
|
||||
yDPI = float64(yRes[0]) / float64(yRes[1])
|
||||
if unitOK && resUnit == tiff.TagValue_ResolutionUnitType_PerCM {
|
||||
yDPI *= 2.54
|
||||
}
|
||||
}
|
||||
|
||||
if xDPI < float64(TEXTRACT_MIN_DPI) || yDPI < float64(TEXTRACT_MIN_DPI) {
|
||||
return invalidReason(InvalidDocumentSmallDPI)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTIFF_ValidFile(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.tiff")
|
||||
require.NoError(t, err)
|
||||
|
||||
tif := NewTIFF(data)
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := tif.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason)
|
||||
})
|
||||
|
||||
t.Run("GetPageCount >= 1", func(t *testing.T) {
|
||||
count, err := tif.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, count, 1)
|
||||
})
|
||||
|
||||
t.Run("GetPage(0) returns data", func(t *testing.T) {
|
||||
page, err := tif.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, page)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTIFF_InvalidMagicBytes(t *testing.T) {
|
||||
// Raw bytes that are not a valid TIFF signature.
|
||||
data := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||
tif := NewTIFF(data)
|
||||
|
||||
reason := tif.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestTIFF_TooShort(t *testing.T) {
|
||||
// Less than 4 bytes -- cannot contain a valid TIFF header.
|
||||
data := []byte{0x49, 0x49}
|
||||
tif := NewTIFF(data)
|
||||
|
||||
reason := tif.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestTIFF_CorruptData(t *testing.T) {
|
||||
// Valid little-endian TIFF magic bytes followed by garbage.
|
||||
data := make([]byte, 128)
|
||||
data[0] = 0x49
|
||||
data[1] = 0x49
|
||||
data[2] = 0x2A
|
||||
data[3] = 0x00
|
||||
// Remainder is zeros/garbage -- the IFD offset is invalid.
|
||||
|
||||
tif := NewTIFF(data)
|
||||
reason := tif.IsCorrupt(t.Context())
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentRead, *reason)
|
||||
}
|
||||
|
||||
func TestTIFF_LargeFileSize(t *testing.T) {
|
||||
// Verify the imageMaxFileSize constant exists and has the expected value.
|
||||
assert.Equal(t, 500*1024*1024, imageMaxFileSize)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/dimchansky/utfbom"
|
||||
"github.com/saintfish/chardet"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxTXTFileSize is the maximum allowed file size for plain text files (100MB).
|
||||
maxTXTFileSize = 100 * 1024 * 1024
|
||||
// binaryCheckSize is the number of bytes to scan for null bytes and binary signatures.
|
||||
binaryCheckSize = 8192
|
||||
// signatureCheckSize is the number of bytes to check for binary file signatures.
|
||||
signatureCheckSize = 8
|
||||
)
|
||||
|
||||
// TXT implements the File interface for plain text document validation.
|
||||
// It checks for binary content, encoding, and file size constraints.
|
||||
type TXT struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewTXT creates a new TXT validator from raw file bytes.
|
||||
func NewTXT(data []byte) *TXT {
|
||||
return &TXT{data: data}
|
||||
}
|
||||
|
||||
// IsCorrupt validates the plain text file for corruption or unsupported content.
|
||||
// It checks binary signatures, null bytes, file size, encoding, and empty content.
|
||||
func (s *TXT) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
if reason := s.checkBinarySignatures(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if reason := s.checkNullBytes(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if reason := s.checkFileSize(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
decoded, reason := s.decodeToUTF8()
|
||||
if reason != nil {
|
||||
return reason
|
||||
}
|
||||
|
||||
if strings.TrimSpace(string(decoded)) == "" {
|
||||
r := InvalidDocumentEmptyContent
|
||||
return &r
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns the number of pages. Plain text is always 1 page.
|
||||
func (s *TXT) GetPageCount(_ context.Context) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// GetPage returns the file content decoded to UTF-8. Only page index 0 is valid.
|
||||
func (s *TXT) GetPage(_ context.Context, index int) ([]byte, error) {
|
||||
if index != 0 {
|
||||
return nil, fmt.Errorf("invalid page index %d: TXT files have exactly 1 page", index)
|
||||
}
|
||||
|
||||
decoded, reason := s.decodeToUTF8()
|
||||
if reason != nil {
|
||||
return nil, fmt.Errorf("failed to decode TXT content: %s", *reason)
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// checkBinarySignatures tests the first bytes of the file against known binary format
|
||||
// signatures. Returns InvalidDocumentMimeType if the file matches a binary format.
|
||||
func (s *TXT) checkBinarySignatures() *InvalidDocumentReason {
|
||||
if len(s.data) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
header := s.data
|
||||
if len(header) > signatureCheckSize {
|
||||
header = header[:signatureCheckSize]
|
||||
}
|
||||
|
||||
// Known binary file signatures to reject.
|
||||
signatures := [][]byte{
|
||||
{0x25, 0x50, 0x44, 0x46, 0x2D}, // PDF: %PDF-
|
||||
{0x50, 0x4B, 0x03, 0x04}, // ZIP (DOCX/XLSX/PPTX)
|
||||
{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}, // OLE2 (DOC/XLS/PPT)
|
||||
{0x49, 0x49, 0x2A, 0x00}, // TIFF little-endian
|
||||
{0x4D, 0x4D, 0x00, 0x2A}, // TIFF big-endian
|
||||
{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, // PNG
|
||||
{0xFF, 0xD8, 0xFF}, // JPEG
|
||||
{0x42, 0x4D}, // BMP
|
||||
}
|
||||
|
||||
for _, sig := range signatures {
|
||||
if len(header) >= len(sig) && bytes.Equal(header[:len(sig)], sig) {
|
||||
r := InvalidDocumentMimeType
|
||||
return &r
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNullBytes scans the first binaryCheckSize bytes for null bytes (0x00),
|
||||
// which indicate binary content. Returns InvalidDocumentBinaryContent if found.
|
||||
// UTF-16 files with BOM are exempt -- null bytes are expected in their encoding.
|
||||
func (s *TXT) checkNullBytes() *InvalidDocumentReason {
|
||||
if s.hasUTF16BOM() {
|
||||
return nil
|
||||
}
|
||||
|
||||
scanLen := min(len(s.data), binaryCheckSize)
|
||||
|
||||
for i := range scanLen {
|
||||
if s.data[i] == 0x00 {
|
||||
r := InvalidDocumentBinaryContent
|
||||
return &r
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasUTF16BOM returns true if the data starts with a UTF-16 LE or BE byte order mark.
|
||||
func (s *TXT) hasUTF16BOM() bool {
|
||||
if len(s.data) < 2 {
|
||||
return false
|
||||
}
|
||||
// UTF-16LE BOM: FF FE
|
||||
if s.data[0] == 0xFF && s.data[1] == 0xFE {
|
||||
return true
|
||||
}
|
||||
// UTF-16BE BOM: FE FF
|
||||
if s.data[0] == 0xFE && s.data[1] == 0xFF {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkFileSize validates the file is not empty and not larger than maxTXTFileSize.
|
||||
func (s *TXT) checkFileSize() *InvalidDocumentReason {
|
||||
if len(s.data) == 0 {
|
||||
r := InvalidDocumentEmptyContent
|
||||
return &r
|
||||
}
|
||||
|
||||
if len(s.data) > maxTXTFileSize {
|
||||
r := InvalidDocumentLargeFile
|
||||
return &r
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeToUTF8 detects the encoding of the file data and decodes it to UTF-8.
|
||||
// It tries BOM detection first, then falls back to heuristic charset detection.
|
||||
// Returns the decoded bytes and nil on success, or nil and an InvalidDocumentReason on failure.
|
||||
func (s *TXT) decodeToUTF8() ([]byte, *InvalidDocumentReason) {
|
||||
// Step 1: Try BOM detection using utfbom.Skip which returns encoding info.
|
||||
bomReader, encoding := utfbom.Skip(bytes.NewReader(s.data))
|
||||
|
||||
switch encoding {
|
||||
case utfbom.UTF8:
|
||||
// UTF-8 with BOM -- read the data past the BOM.
|
||||
data, err := io.ReadAll(bomReader)
|
||||
if err != nil {
|
||||
r := InvalidDocumentRead
|
||||
return nil, &r
|
||||
}
|
||||
return data, nil
|
||||
|
||||
case utfbom.UTF16BigEndian:
|
||||
// Re-read from original data so the UTF-16 decoder can see the BOM.
|
||||
decoder := unicode.UTF16(unicode.BigEndian, unicode.ExpectBOM).NewDecoder()
|
||||
decoded, err := io.ReadAll(transform.NewReader(bytes.NewReader(s.data), decoder))
|
||||
if err != nil {
|
||||
r := InvalidDocumentUnsupportedEncoding
|
||||
return nil, &r
|
||||
}
|
||||
return decoded, nil
|
||||
|
||||
case utfbom.UTF16LittleEndian:
|
||||
decoder := unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM).NewDecoder()
|
||||
decoded, err := io.ReadAll(transform.NewReader(bytes.NewReader(s.data), decoder))
|
||||
if err != nil {
|
||||
r := InvalidDocumentUnsupportedEncoding
|
||||
return nil, &r
|
||||
}
|
||||
return decoded, nil
|
||||
|
||||
case utfbom.Unknown:
|
||||
// No BOM found -- fall through to heuristic detection.
|
||||
}
|
||||
|
||||
// Step 2: Heuristic charset detection for BOM-less files.
|
||||
detector := chardet.NewTextDetector()
|
||||
result, err := detector.DetectBest(s.data)
|
||||
if err != nil || result == nil {
|
||||
r := InvalidDocumentUnsupportedEncoding
|
||||
return nil, &r
|
||||
}
|
||||
|
||||
charset := strings.ToUpper(result.Charset)
|
||||
|
||||
switch charset {
|
||||
case "UTF-8", "ASCII":
|
||||
return s.data, nil
|
||||
|
||||
case "ISO-8859-1":
|
||||
decoded, decErr := io.ReadAll(transform.NewReader(
|
||||
bytes.NewReader(s.data), charmap.ISO8859_1.NewDecoder()))
|
||||
if decErr != nil {
|
||||
r := InvalidDocumentUnsupportedEncoding
|
||||
return nil, &r
|
||||
}
|
||||
return decoded, nil
|
||||
|
||||
case "WINDOWS-1252":
|
||||
decoded, decErr := io.ReadAll(transform.NewReader(
|
||||
bytes.NewReader(s.data), charmap.Windows1252.NewDecoder()))
|
||||
if decErr != nil {
|
||||
r := InvalidDocumentUnsupportedEncoding
|
||||
return nil, &r
|
||||
}
|
||||
return decoded, nil
|
||||
|
||||
default:
|
||||
r := InvalidDocumentUnsupportedEncoding
|
||||
return nil, &r
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTXT_ValidFile(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
txt := NewTXT(data)
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason)
|
||||
})
|
||||
|
||||
t.Run("GetPageCount returns 1", func(t *testing.T) {
|
||||
count, err := txt.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
|
||||
t.Run("GetPage returns content", func(t *testing.T) {
|
||||
page, err := txt.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, page)
|
||||
assert.Contains(t, string(page), "Hello, this is a test document.")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTXT_EmptyFile(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/empty.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
txt := NewTXT(data)
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentEmptyContent, *reason)
|
||||
}
|
||||
|
||||
func TestTXT_BinaryContent(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/binary.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
txt := NewTXT(data)
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentBinaryContent, *reason)
|
||||
}
|
||||
|
||||
func TestTXT_BinarySignature(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// PDF magic bytes: %PDF-
|
||||
data := []byte{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34}
|
||||
|
||||
txt := NewTXT(data)
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestTXT_WhitespaceOnly(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data := []byte(" \n\t\n \n")
|
||||
|
||||
txt := NewTXT(data)
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentEmptyContent, *reason)
|
||||
}
|
||||
|
||||
func TestTXT_LargeFile(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Verify the constant value rather than allocating 100MB+.
|
||||
// maxTXTFileSize is 100 * 1024 * 1024 = 104857600.
|
||||
// Create a TXT with a data length that just exceeds the limit via a
|
||||
// slice header trick: we only need checkFileSize to see len(data) > maxTXTFileSize.
|
||||
// Since we cannot avoid allocation entirely (the struct holds []byte), we create
|
||||
// a minimal slice that reports the right length by extending a small backing array.
|
||||
//
|
||||
// Actually the simplest approach: create a byte slice of length maxTXTFileSize+1.
|
||||
// To avoid 100MB allocation in tests, we test the checkFileSize method directly
|
||||
// with a struct whose data field is set to a large-length slice.
|
||||
// Go slices from make() are zero-initialized but not physically allocated until touched.
|
||||
// However, the null-byte check scans up to 8192 bytes, so the first 8192 bytes will be
|
||||
// null and trigger InvalidDocumentBinaryContent before reaching the size check.
|
||||
//
|
||||
// Instead, let's test the checkFileSize method directly.
|
||||
largeSize := maxTXTFileSize + 1
|
||||
txt := &TXT{data: make([]byte, largeSize)}
|
||||
reason := txt.checkFileSize()
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentLargeFile, *reason)
|
||||
|
||||
// Also verify that a file at exactly the limit passes the size check.
|
||||
txt2 := &TXT{data: make([]byte, maxTXTFileSize)}
|
||||
reason2 := txt2.checkFileSize()
|
||||
assert.Nil(t, reason2)
|
||||
|
||||
// Verify via IsCorrupt that the large file is caught (it will hit binary check first
|
||||
// due to zero bytes, so we verify the size check independently above).
|
||||
reason3 := txt.IsCorrupt(ctx)
|
||||
require.NotNil(t, reason3, "large file with zero bytes should be detected as invalid")
|
||||
}
|
||||
|
||||
func TestTXT_UTF8Content(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data := []byte("This is plain ASCII and UTF-8 compatible text.\nLine two.")
|
||||
|
||||
txt := NewTXT(data)
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason)
|
||||
|
||||
page, err := txt.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(data), string(page))
|
||||
}
|
||||
|
||||
func TestTXT_UTF16LE_WithBOM(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid_utf16le.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
txt := NewTXT(data)
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason, "UTF-16LE file with BOM should be valid")
|
||||
})
|
||||
|
||||
t.Run("GetPage returns decoded content", func(t *testing.T) {
|
||||
page, err := txt.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(page), "Hello")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTXT_UTF16BE_WithBOM(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid_utf16be.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
txt := NewTXT(data)
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := txt.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason, "UTF-16BE file with BOM should be valid")
|
||||
})
|
||||
|
||||
t.Run("GetPage returns decoded content", func(t *testing.T) {
|
||||
page, err := txt.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(page), "Hello")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTXT_GetPageInvalidIndex(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data := []byte("Hello")
|
||||
txt := NewTXT(data)
|
||||
|
||||
_, err := txt.GetPage(ctx, 1)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid page index")
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// XLSX represents an Excel OOXML spreadsheet for validation and sheet extraction.
|
||||
type XLSX struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewXLSX creates a new XLSX validator from raw file data.
|
||||
func NewXLSX(data []byte) *XLSX {
|
||||
return &XLSX{data: data}
|
||||
}
|
||||
|
||||
// xlsxWorkbook is a minimal XML representation of xl/workbook.xml,
|
||||
// capturing sheets and file sharing (password) info.
|
||||
type xlsxWorkbook struct {
|
||||
XMLName xml.Name `xml:"workbook"`
|
||||
Sheets xlsxSheets `xml:"sheets"`
|
||||
FileSharing *xlsxFileSharing `xml:"fileSharing"`
|
||||
}
|
||||
|
||||
type xlsxSheets struct {
|
||||
Sheet []xlsxSheet `xml:"sheet"`
|
||||
}
|
||||
|
||||
type xlsxSheet struct {
|
||||
Name string `xml:"name,attr"`
|
||||
}
|
||||
|
||||
type xlsxFileSharing struct {
|
||||
Password string `xml:"password,attr"`
|
||||
HashValue string `xml:"hashValue,attr"`
|
||||
ReservationPassword string `xml:"reservationPassword,attr"`
|
||||
}
|
||||
|
||||
// xlsxSheetData is a minimal XML representation of a worksheet,
|
||||
// capturing rows and cells to detect content.
|
||||
type xlsxSheetData struct {
|
||||
XMLName xml.Name `xml:"worksheet"`
|
||||
Rows []xlsxRow `xml:"sheetData>row"`
|
||||
}
|
||||
|
||||
type xlsxRow struct {
|
||||
Cells []xlsxCell `xml:"c"`
|
||||
}
|
||||
|
||||
type xlsxCell struct {
|
||||
Value string `xml:"v"`
|
||||
}
|
||||
|
||||
// xlsxSharedStrings is a minimal representation of xl/sharedStrings.xml.
|
||||
type xlsxSharedStrings struct {
|
||||
XMLName xml.Name `xml:"sst"`
|
||||
Items []xlsxSSItem `xml:"si"`
|
||||
}
|
||||
|
||||
type xlsxSSItem struct {
|
||||
Text string `xml:"t"`
|
||||
}
|
||||
|
||||
// IsCorrupt validates the XLSX document, returning the reason it is invalid
|
||||
// or nil if the document passes all checks.
|
||||
func (s *XLSX) IsCorrupt(_ context.Context) *InvalidDocumentReason {
|
||||
// 1. Check for OLE2 encryption (encrypted OOXML files use OLE2 wrapper)
|
||||
if IsOLE2Encrypted(s.data) {
|
||||
reason := InvalidDocumentPasswordProtected
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 2. Check ZIP signature
|
||||
if !hasZIPSignature(s.data) {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 3. Open ZIP and verify content type
|
||||
zr, err := openOfficeZIP(s.data)
|
||||
if err != nil {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
ct, err := readZIPFile(zr, "[Content_Types].xml")
|
||||
if err != nil || !strings.Contains(string(ct), "spreadsheetml.sheet.main+xml") {
|
||||
reason := InvalidDocumentMimeType
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 4. Reject if macros are present
|
||||
if hasVBAMacros(zr) {
|
||||
reason := InvalidDocumentContainsMacros
|
||||
return &reason
|
||||
}
|
||||
if r := s.hasPasswordProtection(zr); r != nil {
|
||||
return r
|
||||
}
|
||||
|
||||
// 5. Verify workbook and at least one sheet exist and parse
|
||||
if r := s.validateStructure(zr); r != nil {
|
||||
return r
|
||||
}
|
||||
|
||||
// 6. File size check
|
||||
if len(s.data) > officeMaxFileSize {
|
||||
reason := InvalidDocumentLargeFile
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 7. Content check
|
||||
if !s.hasContent(zr) {
|
||||
reason := InvalidDocumentEmptyContent
|
||||
return &reason
|
||||
}
|
||||
|
||||
// 8. Sheet count check
|
||||
sheetCount, countErr := s.sheetCount(zr)
|
||||
if countErr != nil || sheetCount < 1 {
|
||||
reason := InvalidDocumentZeroPageCount
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageCount returns the number of sheets in the workbook.
|
||||
func (s *XLSX) GetPageCount(_ context.Context) (int, error) {
|
||||
zr, err := openOfficeZIP(s.data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count, err := s.sheetCount(zr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetPage returns the raw spreadsheet bytes for the requested page index.
|
||||
func (s *XLSX) GetPage(_ context.Context, _ int) ([]byte, error) {
|
||||
return s.data, nil
|
||||
}
|
||||
|
||||
// hasPasswordProtection checks xl/workbook.xml for fileSharing elements
|
||||
// with password hashes indicating protection.
|
||||
func (s *XLSX) hasPasswordProtection(zr *zip.Reader) *InvalidDocumentReason {
|
||||
wbXML, err := readZIPFile(zr, "xl/workbook.xml")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var wb xlsxWorkbook
|
||||
if xmlErr := xml.Unmarshal(wbXML, &wb); xmlErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if wb.FileSharing != nil &&
|
||||
(wb.FileSharing.Password != "" ||
|
||||
wb.FileSharing.HashValue != "" ||
|
||||
wb.FileSharing.ReservationPassword != "") {
|
||||
reason := InvalidDocumentPasswordProtected
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStructure verifies that xl/workbook.xml and at least one
|
||||
// xl/worksheets/sheet*.xml exist and parse as valid XML.
|
||||
func (s *XLSX) validateStructure(zr *zip.Reader) *InvalidDocumentReason {
|
||||
wbXML, err := readZIPFile(zr, "xl/workbook.xml")
|
||||
if err != nil {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
|
||||
var wb xlsxWorkbook
|
||||
if xmlErr := xml.Unmarshal(wbXML, &wb); xmlErr != nil {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
|
||||
// Check at least one worksheet file exists
|
||||
hasSheet := false
|
||||
for _, f := range zr.File {
|
||||
if strings.HasPrefix(f.Name, "xl/worksheets/sheet") &&
|
||||
strings.HasSuffix(f.Name, ".xml") {
|
||||
hasSheet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSheet {
|
||||
reason := InvalidDocumentRead
|
||||
return &reason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasContent checks whether any sheet has a row with a cell containing a
|
||||
// non-empty value, or if shared strings has entries.
|
||||
func (s *XLSX) hasContent(zr *zip.Reader) bool {
|
||||
// Check shared strings first (faster for large spreadsheets)
|
||||
if s.hasSharedStrings(zr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check individual sheets for cell values
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasPrefix(f.Name, "xl/worksheets/sheet") ||
|
||||
!strings.HasSuffix(f.Name, ".xml") {
|
||||
continue
|
||||
}
|
||||
|
||||
sheetXML, err := readZIPFile(zr, f.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var sheet xlsxSheetData
|
||||
if xmlErr := xml.Unmarshal(sheetXML, &sheet); xmlErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, row := range sheet.Rows {
|
||||
for _, cell := range row.Cells {
|
||||
if strings.TrimSpace(cell.Value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// hasSharedStrings checks if xl/sharedStrings.xml has any entries.
|
||||
func (s *XLSX) hasSharedStrings(zr *zip.Reader) bool {
|
||||
ssXML, err := readZIPFile(zr, "xl/sharedStrings.xml")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var ss xlsxSharedStrings
|
||||
if xmlErr := xml.Unmarshal(ssXML, &ss); xmlErr != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return len(ss.Items) > 0
|
||||
}
|
||||
|
||||
// sheetCount returns the number of sheets defined in the workbook.
|
||||
func (s *XLSX) sheetCount(zr *zip.Reader) (int, error) {
|
||||
wbXML, err := readZIPFile(zr, "xl/workbook.xml")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var wb xlsxWorkbook
|
||||
if xmlErr := xml.Unmarshal(wbXML, &wb); xmlErr != nil {
|
||||
return 0, xmlErr
|
||||
}
|
||||
|
||||
return len(wb.Sheets.Sheet), nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package documenttypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestXLSX_ValidFile(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/valid.xlsx")
|
||||
require.NoError(t, err)
|
||||
|
||||
xlsx := NewXLSX(data)
|
||||
|
||||
t.Run("IsCorrupt returns nil", func(t *testing.T) {
|
||||
reason := xlsx.IsCorrupt(ctx)
|
||||
assert.Nil(t, reason, "valid.xlsx should not be corrupt")
|
||||
})
|
||||
|
||||
t.Run("GetPageCount >= 1", func(t *testing.T) {
|
||||
count, err := xlsx.GetPageCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, count, 1)
|
||||
})
|
||||
|
||||
t.Run("GetPage returns data", func(t *testing.T) {
|
||||
page, err := xlsx.GetPage(ctx, 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, page)
|
||||
})
|
||||
}
|
||||
|
||||
func TestXLSX_InvalidMagicBytes(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data := []byte("this is not a ZIP or XLSX file")
|
||||
|
||||
xlsx := NewXLSX(data)
|
||||
reason := xlsx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason)
|
||||
}
|
||||
|
||||
func TestXLSX_Encrypted(t *testing.T) {
|
||||
// encrypted.docx has the OLE2 signature but is not a parseable OLE2 compound
|
||||
// document (malformed fixture). IsOLE2Encrypted returns false, then the ZIP
|
||||
// signature check fails, so it returns InvalidDocumentMimeType.
|
||||
ctx := t.Context()
|
||||
data, err := os.ReadFile("testdata/multiformat/encrypted.docx")
|
||||
require.NoError(t, err)
|
||||
|
||||
xlsx := NewXLSX(data)
|
||||
reason := xlsx.IsCorrupt(ctx)
|
||||
|
||||
require.NotNil(t, reason)
|
||||
assert.Equal(t, InvalidDocumentMimeType, *reason,
|
||||
"malformed OLE2 file should fail ZIP signature check")
|
||||
}
|
||||
@@ -213,6 +213,9 @@ func (s *Service) UploadWithResult(ctx context.Context, file File) (UploadResult
|
||||
Part: &newPart,
|
||||
BatchID: file.BatchID,
|
||||
}
|
||||
if ext := strings.TrimPrefix(filepath.Ext(filename), "."); ext != "" {
|
||||
result.Key.FileType = &ext
|
||||
}
|
||||
keyStr := result.Key.String()
|
||||
bucket := s.cfg.GetBucket()
|
||||
|
||||
|
||||
@@ -214,3 +214,68 @@ func TestUploadAndReturnKey(t *testing.T) {
|
||||
assert.Equal(t, "client_id", key.ClientID)
|
||||
assert.NotEmpty(t, key.String())
|
||||
}
|
||||
|
||||
// TestUpload_SetsFileTypeOnKey verifies that UploadWithResult sets the FileType
|
||||
// field on the BucketKey based on the filename extension.
|
||||
func TestUpload_SetsFileTypeOnKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{}
|
||||
test.CreateDB(t, cfg)
|
||||
acfg := test.CreateAWSContainer(t, cfg)
|
||||
test.SetStoreClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
test.CreateBucket(t, cfg)
|
||||
createTestClient(t, cfg, "client_id", "client")
|
||||
|
||||
svc := documentupload.New(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
wantFileType *string
|
||||
}{
|
||||
{
|
||||
name: "pdf extension",
|
||||
filename: "test.pdf",
|
||||
wantFileType: strPtr("pdf"),
|
||||
},
|
||||
{
|
||||
name: "txt extension",
|
||||
filename: "notes.txt",
|
||||
wantFileType: strPtr("txt"),
|
||||
},
|
||||
{
|
||||
name: "docx extension",
|
||||
filename: "report.docx",
|
||||
wantFileType: strPtr("docx"),
|
||||
},
|
||||
{
|
||||
name: "no extension",
|
||||
filename: "noext",
|
||||
wantFileType: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file := documentupload.File{
|
||||
ClientID: "client_id",
|
||||
Content: strings.NewReader("file content"),
|
||||
Filename: tt.filename,
|
||||
}
|
||||
|
||||
result, err := svc.UploadWithResult(t.Context(), file)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.wantFileType == nil {
|
||||
assert.Nil(t, result.Key.FileType, "FileType should be nil for filename without extension")
|
||||
} else {
|
||||
require.NotNil(t, result.Key.FileType, "FileType should not be nil for filename with extension")
|
||||
assert.Equal(t, *tt.wantFileType, *result.Key.FileType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user