diff --git a/config/config.go b/config/config.go index 96c6f03..1a770ce 100644 --- a/config/config.go +++ b/config/config.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v3" "nos3/internal/infrastructure/broker" + "nos3/internal/infrastructure/clamav" "nos3/internal/infrastructure/database" "nos3/internal/infrastructure/grpcclient" "nos3/internal/infrastructure/minio" @@ -28,6 +29,7 @@ type Config struct { GRPCClient grpcclient.ClientConfig `yaml:"manager"` GRPCServer grpcclient.ServerConfig `yaml:"grpc_server"` Logger logger.Config `yaml:"logger"` + ClamAVConfig clamav.ScannerConfig `yaml:"clamav_scanner"` } func Load(path string) (*Config, error) { @@ -61,6 +63,7 @@ func Load(path string) (*Config, error) { config.MinIOClient.SecretKey = os.Getenv("MINIO_ROOT_PASSWORD") config.DBConfig.URI = os.Getenv("DATABASE_URI") config.BrokerConfig.URI = os.Getenv("BROKER_URI") + config.ClamAVConfig.Address = os.Getenv("CLAMAV_ADDRESS") if err = config.basicCheck(); err != nil { return nil, Error{ diff --git a/config/config.yml b/config/config.yml index c9ab09d..4b2f5fd 100644 --- a/config/config.yml +++ b/config/config.yml @@ -105,4 +105,9 @@ logger: # targets is targets for logs to be written to. targets: [file, console] - \ No newline at end of file + +clamav_scanner: + + address: "" + + timeout: diff --git a/go.mod b/go.mod index cb0d796..3020972 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nos3 go 1.24.1 require ( + github.com/barasher/go-exiftool v1.10.0 github.com/gabriel-vasile/mimetype v1.4.9 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 @@ -12,6 +13,7 @@ require ( github.com/redis/go-redis/v9 v9.8.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 + github.com/swimmingrieux/go-clamd v0.0.0-20251116164441-e8eb8db2ea4c github.com/testcontainers/testcontainers-go v0.37.0 go.mongodb.org/mongo-driver v1.17.3 google.golang.org/grpc v1.70.0 @@ -109,12 +111,12 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect golang.org/x/arch v0.15.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect ) diff --git a/go.sum b/go.sum index 26f8d1e..7102f45 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNN github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= +github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -194,6 +196,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swimmingrieux/go-clamd v0.0.0-20251116164441-e8eb8db2ea4c h1:6h4UQvFHiMm4Z9/vH/5yMO80+F3p+jrXpCsJG9pW6dM= +github.com/swimmingrieux/go-clamd v0.0.0-20251116164441-e8eb8db2ea4c/go.mod h1:1XQNbsdPSew/aqEAk2Uz3qcAbX15zGgh2aQMaBlizyk= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -254,10 +258,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -267,14 +271,14 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -291,18 +295,18 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/domain/entity/malware_scan_result.go b/internal/domain/entity/malware_scan_result.go new file mode 100644 index 0000000..6a95e71 --- /dev/null +++ b/internal/domain/entity/malware_scan_result.go @@ -0,0 +1,47 @@ +package entity + +type MalwareScanStatus int + +const ( + MalwareScanStatusUnknown MalwareScanStatus = iota + MalwareScanStatusClean + MalwareScanStatusInfected + MalwareScanStatusError +) + +func (s MalwareScanStatus) String() string { + switch s { + case MalwareScanStatusClean: + return "clean" + case MalwareScanStatusInfected: + return "infected" + case MalwareScanStatusError: + return "error" + case MalwareScanStatusUnknown: + return "unknown" + } + + return "invalid" +} + +type MalwareScanResult struct { + Status MalwareScanStatus `json:"status"` + Error string `json:"error,omitempty"` + Threats []string `json:"threats,omitempty"` +} + +func (r MalwareScanResult) IsClean() bool { + return r.Status == MalwareScanStatusClean +} + +func (r MalwareScanResult) IsInfected() bool { + return r.Status == MalwareScanStatusInfected +} + +func (r MalwareScanResult) HasError() bool { + return r.Status == MalwareScanStatusError +} + +func (r MalwareScanResult) ThreatCount() int { + return len(r.Threats) +} diff --git a/internal/domain/repository/clamav/client.go b/internal/domain/repository/clamav/client.go new file mode 100644 index 0000000..de7ae5d --- /dev/null +++ b/internal/domain/repository/clamav/client.go @@ -0,0 +1,12 @@ +package clamav + +import ( + "io" + + "github.com/swimmingrieux/go-clamd" +) + +type ClamdClient interface { + Ping() error + ScanStream(reader io.Reader, abort chan bool) (chan *clamd.ScanResult, error) +} diff --git a/internal/domain/repository/clamav/scanner.go b/internal/domain/repository/clamav/scanner.go new file mode 100644 index 0000000..a9ba82d --- /dev/null +++ b/internal/domain/repository/clamav/scanner.go @@ -0,0 +1,13 @@ +package clamav + +import ( + "context" + "io" + + "nos3/internal/domain/entity" +) + +// Scanner defines the interface for scanning files for malware. +type Scanner interface { + ScanStream(ctx context.Context, reader io.Reader) (entity.MalwareScanResult, error) +} diff --git a/internal/domain/repository/cliexecuter/command_executor.go b/internal/domain/repository/cliexecuter/command_executor.go new file mode 100644 index 0000000..6b7e4f2 --- /dev/null +++ b/internal/domain/repository/cliexecuter/command_executor.go @@ -0,0 +1,5 @@ +package cliexecuter + +type CommandExecutor interface { + Execute(cmd string, args ...string) ([]byte, error) +} diff --git a/internal/domain/repository/exif/exif_processor.go b/internal/domain/repository/exif/exif_processor.go new file mode 100644 index 0000000..5b60e7a --- /dev/null +++ b/internal/domain/repository/exif/exif_processor.go @@ -0,0 +1,9 @@ +package exif + +import "context" + +// ExifProcessor handles EXIF removal operations. +type Processor interface { + RemoveExifData(ctx context.Context, filePath string) error + Close() error +} diff --git a/internal/domain/repository/exif/extension_provider.go b/internal/domain/repository/exif/extension_provider.go new file mode 100644 index 0000000..6e6a504 --- /dev/null +++ b/internal/domain/repository/exif/extension_provider.go @@ -0,0 +1,6 @@ +package exif + +// ExtensionProvider provides supported file extensions. +type ExtensionProvider interface { + GetSupportedExtensions() (map[string]bool, error) +} diff --git a/internal/domain/repository/exif/file_validator.go b/internal/domain/repository/exif/file_validator.go new file mode 100644 index 0000000..7198e15 --- /dev/null +++ b/internal/domain/repository/exif/file_validator.go @@ -0,0 +1,6 @@ +package exif + +// FileValidator validates file types. +type FileValidator interface { + ValidateFileType(filePath string) error +} diff --git a/internal/infrastructure/clamav/config.go b/internal/infrastructure/clamav/config.go new file mode 100644 index 0000000..c8570c4 --- /dev/null +++ b/internal/infrastructure/clamav/config.go @@ -0,0 +1,6 @@ +package clamav + +type ScannerConfig struct { + Address string `yaml:"address"` + Timeout int `yaml:"timeout"` +} diff --git a/internal/infrastructure/clamav/error.go b/internal/infrastructure/clamav/error.go new file mode 100644 index 0000000..0250a2c --- /dev/null +++ b/internal/infrastructure/clamav/error.go @@ -0,0 +1,40 @@ +package clamav + +import "fmt" + +type ErrorCode int + +const ( + ErrorCodeUnknown ErrorCode = iota + ErrorCodeConnectionFailed + ErrorCodeScanFailed + ErrorCodeTimeout + ErrorCodeMalwareDetected + ErrorCodeInvalidInput +) + +type MalwareError struct { + Code ErrorCode + Message string + Details string +} + +func (e *MalwareError) Error() string { + if e.Details != "" { + return fmt.Sprintf("%s: %s", e.Message, e.Details) + } + + return e.Message +} + +func (e *MalwareError) IsTimeout() bool { + return e.Code == ErrorCodeTimeout +} + +func (e *MalwareError) IsMalwareDetected() bool { + return e.Code == ErrorCodeMalwareDetected +} + +func (e *MalwareError) IsConnectionError() bool { + return e.Code == ErrorCodeConnectionFailed +} diff --git a/internal/infrastructure/clamav/scanner.go b/internal/infrastructure/clamav/scanner.go new file mode 100644 index 0000000..34e95dc --- /dev/null +++ b/internal/infrastructure/clamav/scanner.go @@ -0,0 +1,168 @@ +package clamav + +import ( + "context" + "io" + "time" + + "nos3/internal/domain/repository/clamav" + + "github.com/swimmingrieux/go-clamd" + + "nos3/internal/domain/entity" + grpcRepository "nos3/internal/domain/repository/grpcclient" + "nos3/pkg/logger" +) + +type Scanner struct { + clamd clamav.ClamdClient + timeout time.Duration + grpcClient grpcRepository.IClient +} + +func NewScanner(cfg ScannerConfig, grpcClient grpcRepository.IClient) (*Scanner, error) { + logger.Info("connecting to ClamAV daemon", "address", cfg.Address) + + clamdClient := clamd.NewClamd(cfg.Address) + + scanner := &Scanner{ + clamd: clamdClient, + timeout: time.Duration(cfg.Timeout) * time.Millisecond, + grpcClient: grpcClient, + } + + if err := scanner.ping(); err != nil { + if _, logErr := grpcClient.AddLog(context.Background(), + "failed to connect to ClamAV daemon", err.Error()); logErr != nil { + logger.Error("can't send log to manager", "err", logErr) + } + + return nil, err + } + + return scanner, nil +} + +func (s *Scanner) ping() error { + return s.clamd.Ping() +} + +func (s *Scanner) ScanStream(ctx context.Context, reader io.Reader) (entity.MalwareScanResult, error) { + scanCtx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + + abort := make(chan bool, 1) + + go func() { + select { + case <-scanCtx.Done(): + abort <- true + case <-ctx.Done(): + abort <- true + } + }() + + resultChan, err := s.clamd.ScanStream(reader, abort) + if err != nil { + s.logError(ctx, "failed to initiate stream scan", err.Error()) + + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: err.Error(), + }, &MalwareError{ + Code: ErrorCodeScanFailed, + Message: "failed to initiate stream scan", + Details: err.Error(), + } + } + + return s.processResults(ctx, resultChan) +} + +func (s *Scanner) processResults(ctx context.Context, + resultChan chan *clamd.ScanResult, +) (entity.MalwareScanResult, error) { + var threats []string + + for { + select { + case result, ok := <-resultChan: + if !ok { + return s.buildResult(threats), nil + } + + err := s.handleScanResult(result, &threats) + if err != nil { + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: err.Error(), + Threats: threats, + }, err + } + + case <-ctx.Done(): + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: "context cancelled", + Threats: threats, + }, &MalwareError{ + Code: ErrorCodeTimeout, + Message: "scan cancelled", + Details: "context cancelled", + } + + case <-time.After(s.timeout): + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: "scan took too long", + Threats: threats, + }, &MalwareError{ + Code: ErrorCodeTimeout, + Message: "scan timeout", + Details: "scan took too long", + } + } + } +} + +func (s *Scanner) handleScanResult(result *clamd.ScanResult, threats *[]string) error { + switch result.Status { + case clamd.RES_OK: + return nil + + case clamd.RES_FOUND: + *threats = append(*threats, result.Description) + + return nil + + case clamd.RES_ERROR, clamd.RES_PARSE_ERROR: + return &MalwareError{ + Code: ErrorCodeScanFailed, + Message: "scan failed", + Details: result.Description, + } + } + + return nil +} + +func (s *Scanner) buildResult(threats []string) entity.MalwareScanResult { + if len(threats) > 0 { + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusInfected, + Threats: threats, + } + } + + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusClean, + Threats: nil, + } +} + +func (s *Scanner) logError(ctx context.Context, message, details string) { + logger.Error(message, "details", details) + if _, logErr := s.grpcClient.AddLog(ctx, message, details); logErr != nil { + logger.Error("can't send log to manager", "err", logErr) + } +} diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go new file mode 100644 index 0000000..d59caed --- /dev/null +++ b/internal/infrastructure/clamav/scanner_test.go @@ -0,0 +1,417 @@ +package clamav + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/swimmingrieux/go-clamd" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "nos3/internal/domain/entity" + "nos3/internal/domain/repository/clamav" + "nos3/internal/infrastructure/grpcclient/gen" +) + +const ( + ClamAVImage = "clamav/clamav:latest" + ClamAVPort = "3310/tcp" + ClamAVAddressFormat = "tcp://%s" + CleanFileContent = "This is a clean test file with no malware." + EmptyFileContent = "" + LargeCleanFileContent = "This is a clean file content. " + InfectedContent = "infected content" + UnparseableContent = "unparseable content" + SomeContent = "some content" + MalwareDescTrojan = "Trojan.Generic.123" + MalwareDescVirus = "Virus.Win32.Test" + MalwareDescMalwareSuspicious = "Malware.Suspicious.456" + ScanErrorCorruptedData = "Unable to scan file: corrupted data" + ParseErrorFileFormatNotRecog = "File format not recognized" + MalwareDescTrojanTest = "Trojan.Test.123" + MalwareDescVirusTest = "Virus.Test.456" + Timeout = 30000 + StartupTimeoutDuration = 60 * time.Second + LargeFileContentRepeatCount = 350000 +) + +type MockGRPC struct { + mock.Mock +} + +func (m *MockGRPC) RegisterService(_ context.Context, _, _ string) (*gen.RegisterServiceResponse, error) { + args := m.Called() + + return args.Get(0).(*gen.RegisterServiceResponse), args.Error(1) +} + +func (m *MockGRPC) AddLog(_ context.Context, msg, stack string) (*gen.AddLogResponse, error) { + args := m.Called(msg, stack) + + return args.Get(0).(*gen.AddLogResponse), args.Error(1) +} + +func (m *MockGRPC) AddReport(_ context.Context, _ string, _ []string, _, _, _, _ string) ( + *gen.AddReportResponse, error, +) { + args := m.Called() + + return args.Get(0).(*gen.AddReportResponse), args.Error(1) +} + +type MockClamdClient struct { + mock.Mock +} + +func (m *MockClamdClient) Ping() error { + args := m.Called() + + return args.Error(0) +} + +func (m *MockClamdClient) ScanStream(reader io.Reader, abort chan bool) (chan *clamd.ScanResult, error) { + args := m.Called(reader, abort) + + return args.Get(0).(chan *clamd.ScanResult), args.Error(1) +} + +func createMockScanner(clamdClient clamav.ClamdClient, grpcClient *MockGRPC) *Scanner { + return &Scanner{ + clamd: clamdClient, + timeout: 30 * time.Second, + grpcClient: grpcClient, + } +} + +func createMockResultChannel(results []*clamd.ScanResult) chan *clamd.ScanResult { + resultChan := make(chan *clamd.ScanResult, len(results)) + for _, result := range results { + resultChan <- result + } + close(resultChan) + + return resultChan +} + +func setupClamAV(t *testing.T) (string, func()) { + t.Helper() + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: ClamAVImage, + ExposedPorts: []string{ClamAVPort}, + WaitingFor: wait.ForAll( + wait.ForListeningPort(ClamAVPort).WithStartupTimeout(StartupTimeoutDuration), + ), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal("Failed to start ClamAV container:", err) + } + + host, err := container.Host(ctx) + if err != nil { + t.Fatal("Failed to get container host:", err) + } + + port, err := container.MappedPort(ctx, "3310") + if err != nil { + t.Fatal("Failed to get mapped port:", err) + } + + address := fmt.Sprintf(ClamAVAddressFormat, net.JoinHostPort(host, port.Port())) + + waitForClamAVReady(t, address) + + return address, func() { + _ = container.Terminate(ctx) + } +} + +func waitForClamAVReady(t *testing.T, address string) { + t.Helper() + maxRetries := 30 + retryDelay := 2 * time.Second + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil) + + for i := 0; i < maxRetries; i++ { + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: Timeout, + }, mockGRPC) + + if err == nil && scanner != nil { + // Successfully connected and pinged + return + } + + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + } + + t.Fatal("ClamAV daemon did not become ready within timeout period") +} + +func TestScanStream_CleanFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil) + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: Timeout, + }, mockGRPC) + require.NoError(t, err) + + cleanContent := CleanFileContent + reader := strings.NewReader(cleanContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func TestScanStream_InfectedFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil) + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: Timeout, + }, mockGRPC) + require.NoError(t, err) + + reader := bytes.NewReader(clamd.EICAR) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusInfected, result.Status) + assert.NotEmpty(t, result.Threats) + assert.Contains(t, strings.ToUpper(result.Threats[0]), "EICAR") +} + +func TestScanStream_EmptyFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil) + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: Timeout, + }, mockGRPC) + require.NoError(t, err) + + reader := strings.NewReader(EmptyFileContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.False(t, result.HasError()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func TestScanStream_LargeCleanFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil) + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: Timeout, + }, mockGRPC) + require.NoError(t, err) + + largeContent := strings.Repeat(LargeCleanFileContent, LargeFileContentRepeatCount) + reader := strings.NewReader(largeContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.False(t, result.HasError()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func TestScanStream_BinaryCleanFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil) + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: Timeout, + }, mockGRPC) + require.NoError(t, err) + + binaryContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + binaryContent = append(binaryContent, bytes.Repeat([]byte{0x00, 0x01, 0x02, 0x03}, 1000)...) + reader := bytes.NewReader(binaryContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.False(t, result.HasError()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func TestScanStream_MultipleThreatsDetected(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_FOUND, Description: MalwareDescTrojan}, + {Status: clamd.RES_FOUND, Description: MalwareDescVirus}, + {Status: clamd.RES_FOUND, Description: MalwareDescMalwareSuspicious}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader(InfectedContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusInfected, result.Status) + assert.Equal(t, 3, len(result.Threats)) + assert.Contains(t, result.Threats, MalwareDescTrojan) + assert.Contains(t, result.Threats, MalwareDescVirus) + assert.Contains(t, result.Threats, MalwareDescMalwareSuspicious) +} + +func TestScanStream_ScanErrorDuringScan(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_ERROR, Description: ScanErrorCorruptedData}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader(SomeContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.Error(t, err) + assert.Equal(t, entity.MalwareScanStatusError, result.Status) + assert.Contains(t, result.Error, "scan failed") + + var malwareErr *MalwareError + ok := errors.As(err, &malwareErr) + + assert.True(t, ok) + assert.Equal(t, ErrorCodeScanFailed, malwareErr.Code) + assert.Equal(t, ScanErrorCorruptedData, malwareErr.Details) +} + +func TestScanStream_MixedResults(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_OK, Description: ""}, + {Status: clamd.RES_FOUND, Description: MalwareDescTrojanTest}, + {Status: clamd.RES_OK, Description: ""}, + {Status: clamd.RES_FOUND, Description: MalwareDescVirusTest}, + {Status: clamd.RES_OK, Description: ""}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader("mixed content") + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusInfected, result.Status) + assert.Equal(t, 2, len(result.Threats)) + assert.Contains(t, result.Threats, MalwareDescTrojanTest) + assert.Contains(t, result.Threats, MalwareDescVirusTest) +} + +func TestScanStream_ParseErrorDuringScan(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_PARSE_ERROR, Description: ParseErrorFileFormatNotRecog}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader(UnparseableContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.Error(t, err) + assert.Equal(t, entity.MalwareScanStatusError, result.Status) + assert.Contains(t, result.Error, "scan failed") + + var malwareErr *MalwareError + ok := errors.As(err, &malwareErr) + + assert.True(t, ok) + assert.Equal(t, ErrorCodeScanFailed, malwareErr.Code) + assert.Equal(t, ParseErrorFileFormatNotRecog, malwareErr.Details) +} diff --git a/internal/infrastructure/cliexecuter/system_command_executor.go b/internal/infrastructure/cliexecuter/system_command_executor.go new file mode 100644 index 0000000..6a7600d --- /dev/null +++ b/internal/infrastructure/cliexecuter/system_command_executor.go @@ -0,0 +1,13 @@ +package cliexecuter + +import "os/exec" + +type SystemCommandExecutor struct{} + +func NewSystemCommandExecutor() *SystemCommandExecutor { + return &SystemCommandExecutor{} +} + +func (s *SystemCommandExecutor) Execute(cmd string, args ...string) ([]byte, error) { + return exec.Command(cmd, args...).Output() +} diff --git a/internal/infrastructure/database/lister_test.go b/internal/infrastructure/database/lister_test.go index dcf17cb..11b7983 100644 --- a/internal/infrastructure/database/lister_test.go +++ b/internal/infrastructure/database/lister_test.go @@ -96,7 +96,7 @@ func TestGetByAuthor(t *testing.T) { defer cleanUp() mockGrpcClient := &MockGRPC{} - mockGrpcClient.On("AddLog", mock.Anything, mock.Anything, mock.Anything).Return(&gen.AddLogResponse{Success: true}, nil).Maybe() + mockGrpcClient.On("AddLog", mock.Anything, mock.Anything, mock.Anything).Return(&gen.AddLogResponse{Success: true}, nil) db, err := Connect(Config{ URI: uri, diff --git a/internal/infrastructure/exif/config.go b/internal/infrastructure/exif/config.go new file mode 100644 index 0000000..3705d81 --- /dev/null +++ b/internal/infrastructure/exif/config.go @@ -0,0 +1,11 @@ +package exif + +type ExtensionProviderConfig struct { + ExifToolCmd string `yaml:"exiftool_cmd"` + ExifToolListFlag string `yaml:"exiftool_list_flag"` +} + +type RemoverConfig struct { + Timeout int `yaml:"timeout_in_ms"` + ExifToolCmd string `yaml:"exiftool_cmd"` +} diff --git a/internal/infrastructure/exif/errors.go b/internal/infrastructure/exif/errors.go new file mode 100644 index 0000000..931e5d0 --- /dev/null +++ b/internal/infrastructure/exif/errors.go @@ -0,0 +1,41 @@ +package exif + +import "fmt" + +type ErrorCode int + +const ( + ErrorCodeUnknown ErrorCode = iota + ErrorCodeExifToolNotFound + ErrorCodeTempFileCreationFailed + ErrorCodeExifRemovalFailed + ErrorCodeTimeout + ErrorCodeInvalidInput + ErrorCodeUnsupportedFileType +) + +type Error struct { + Code ErrorCode + Message string + Details string +} + +func (e *Error) Error() string { + if e.Details != "" { + return fmt.Sprintf("%s: %s", e.Message, e.Details) + } + + return e.Message +} + +func (e *Error) IsTimeout() bool { + return e.Code == ErrorCodeTimeout +} + +func (e *Error) IsUnsupportedFileType() bool { + return e.Code == ErrorCodeUnsupportedFileType +} + +func (e *Error) IsTempFileError() bool { + return e.Code == ErrorCodeTempFileCreationFailed +} diff --git a/internal/infrastructure/exif/extension_provider.go b/internal/infrastructure/exif/extension_provider.go new file mode 100644 index 0000000..37c3f98 --- /dev/null +++ b/internal/infrastructure/exif/extension_provider.go @@ -0,0 +1,74 @@ +package exif + +import ( + "strings" + "sync" + + "nos3/internal/domain/repository/cliexecuter" +) + +type ExtensionProvider struct { + exiftoolCmd string + listFlag string + executor cliexecuter.CommandExecutor + supportedExtensions map[string]bool + initErr error + once sync.Once +} + +// NewExtensionProvider creates a new ExtensionProvider instance that can fetch +// and cache supported file extensions from ExifTool. +func NewExtensionProvider(cfg ExtensionProviderConfig, executor cliexecuter.CommandExecutor) *ExtensionProvider { + return &ExtensionProvider{ + exiftoolCmd: cfg.ExifToolCmd, + listFlag: cfg.ExifToolListFlag, + executor: executor, + } +} + +// GetSupportedExtensions returns a map of supported file extensions (with dot prefix). +// The extensions are fetched from ExifTool on first call and cached for subsequent calls. +// Returns a copy of the map to prevent external modification. +func (e *ExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { + e.once.Do(func() { + e.supportedExtensions, e.initErr = e.fetchSupportedExtensions() + }) + + if e.initErr != nil { + return nil, e.initErr + } + + result := make(map[string]bool, len(e.supportedExtensions)) + for k, v := range e.supportedExtensions { + result[k] = v + } + + return result, nil +} + +// fetchSupportedExtensions executes 'exiftool -listf' command to retrieve all +// supported file extensions. Each extension is stored with a dot prefix and in lowercase. +func (e *ExtensionProvider) fetchSupportedExtensions() (map[string]bool, error) { + output, err := e.executor.Execute(e.exiftoolCmd, e.listFlag) + if err != nil { + return nil, err + } + + extensions := make(map[string]bool) + lines := strings.Split(string(output), "\n") + + for _, line := range lines { + if strings.Contains(line, ":") { + continue + } + + fields := strings.Fields(line) + for _, ext := range fields { + if ext != "" { + extensions["."+strings.ToLower(ext)] = true + } + } + } + + return extensions, nil +} diff --git a/internal/infrastructure/exif/extension_provider_test.go b/internal/infrastructure/exif/extension_provider_test.go new file mode 100644 index 0000000..10fa68e --- /dev/null +++ b/internal/infrastructure/exif/extension_provider_test.go @@ -0,0 +1,310 @@ +package exif + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + DefaultExifToolCmd = "" + CustomExifToolCmd = "/usr/bin/exiftool" + InvalidExifToolCmd = "invalid-exiftool-path" + DefaultListFlag = "-listf" + SampleOutput = `JPG JPEG PNG GIF TIFF TIF BMP RAW CR2 NEF ARW DNG MP4 MOV AVI PDF` + MinimalOutput = `JPG PNG` + ThreeExtensionOutput = `JPG PNG GIF` + MixedCaseOutput = `JPG Jpeg PnG gif` + EmptyOutput = `` +) + +func createTestConfig(exiftoolCmd, listFlag string) ExtensionProviderConfig { + return ExtensionProviderConfig{ + ExifToolCmd: exiftoolCmd, + ExifToolListFlag: listFlag, + } +} + +func TestGetSupportedExtensions_Success(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(SampleOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.NotNil(t, extensions) + assert.True(t, extensions[".jpg"]) + assert.True(t, extensions[".png"]) + assert.True(t, extensions[".mp4"]) + assert.False(t, extensions[".txt"]) + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_CachingBehavior(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MinimalOutput), nil).Once() + + provider := NewExtensionProvider(cfg, mockExecutor) + + ext1, err1 := provider.GetSupportedExtensions() + require.NoError(t, err1) + require.Equal(t, 2, len(ext1)) + + ext1[".gif"] = true + + ext2, err2 := provider.GetSupportedExtensions() + require.NoError(t, err2) + assert.Equal(t, 2, len(ext2)) + assert.False(t, ext2[".gif"]) + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_ErrorHandling(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(InvalidExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", InvalidExifToolCmd, []string{DefaultListFlag}).Return(nil, errors.New("command not found")) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.Error(t, err) + assert.Nil(t, extensions) + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_EmptyExtensions(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(EmptyOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.NotNil(t, extensions) + assert.Equal(t, 0, len(extensions)) + mockExecutor.AssertExpectations(t) +} + +func TestFetchSupportedExtensions_ParseOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + expectedCount int + shouldContain []string + shouldNotExist []string + }{ + { + name: "standard output", + output: `Recognized file extensions: + JPG JPEG PNG GIF + MP4 MOV AVI + PDF DOC DOCX`, + expectedCount: 10, + shouldContain: []string{".jpg", ".jpeg", ".png", ".gif", ".mp4", ".pdf"}, + shouldNotExist: []string{".txt", ".exe"}, + }, + { + name: "single line", + output: `JPG PNG GIF`, + expectedCount: 3, + shouldContain: []string{".jpg", ".png", ".gif"}, + shouldNotExist: []string{".mp4"}, + }, + { + name: "with headers and data lines", + output: `Recognized file extensions: + JPG PNG + MP4 AVI`, + expectedCount: 4, + shouldContain: []string{".jpg", ".png", ".mp4", ".avi"}, + shouldNotExist: []string{".txt"}, + }, + { + name: "empty output", + output: "", + expectedCount: 0, + shouldContain: []string{}, + shouldNotExist: []string{".jpg"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(tt.output), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + result, err := provider.GetSupportedExtensions() + require.NoError(t, err) + require.Equal(t, tt.expectedCount, len(result)) + + for _, ext := range tt.shouldContain { + assert.True(t, result[ext], "should contain %s", ext) + } + + for _, ext := range tt.shouldNotExist { + assert.False(t, result[ext], "should not contain %s", ext) + } + + mockExecutor.AssertExpectations(t) + }) + } +} + +func TestNewExtensionProvider(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + exiftoolCmd string + listFlag string + }{ + {"with custom command", CustomExifToolCmd, DefaultListFlag}, + {"with default command", DefaultExifToolCmd, DefaultListFlag}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(tt.exiftoolCmd, tt.listFlag) + mockExecutor := &MockCommandExecutor{} + provider := NewExtensionProvider(cfg, mockExecutor) + + require.NotNil(t, provider) + assert.Equal(t, tt.exiftoolCmd, provider.exiftoolCmd) + assert.Equal(t, tt.listFlag, provider.listFlag) + require.NotNil(t, provider.executor) + assert.Nil(t, provider.supportedExtensions) + assert.Nil(t, provider.initErr) + }) + } +} + +func TestGetSupportedExtensions_WithCustomCommand(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(CustomExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", CustomExifToolCmd, []string{DefaultListFlag}).Return([]byte(ThreeExtensionOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.Equal(t, 3, len(extensions)) + assert.True(t, extensions[".jpg"]) + assert.True(t, extensions[".png"]) + assert.True(t, extensions[".gif"]) + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_CaseInsensitive(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MixedCaseOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + assert.True(t, extensions[".jpg"]) + assert.True(t, extensions[".jpeg"]) + assert.True(t, extensions[".png"]) + assert.True(t, extensions[".gif"]) + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_MultipleCallsUseCachedResult(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MinimalOutput), nil).Once() + + provider := NewExtensionProvider(cfg, mockExecutor) + + for i := 0; i < 5; i++ { + extensions, err := provider.GetSupportedExtensions() + require.NoError(t, err) + assert.Equal(t, 2, len(extensions)) + } + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_WithCustomListFlag(t *testing.T) { + t.Parallel() + + customFlag := "--list" + cfg := createTestConfig(DefaultExifToolCmd, customFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{customFlag}).Return([]byte(MinimalOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.NotNil(t, extensions) + assert.Equal(t, 2, len(extensions)) + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_ConcurrentAccess(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MinimalOutput), nil).Once() + + provider := NewExtensionProvider(cfg, mockExecutor) + + // Run multiple goroutines concurrently + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + extensions, err := provider.GetSupportedExtensions() + require.NoError(t, err) + assert.Equal(t, 2, len(extensions)) + done <- true + }() + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } + + mockExecutor.AssertExpectations(t) +} diff --git a/internal/infrastructure/exif/file_validator.go b/internal/infrastructure/exif/file_validator.go new file mode 100644 index 0000000..0708449 --- /dev/null +++ b/internal/infrastructure/exif/file_validator.go @@ -0,0 +1,46 @@ +package exif + +import ( + "path/filepath" + "strings" + + "nos3/internal/domain/repository/exif" +) + +type FileValidator struct { + extensionProvider exif.ExtensionProvider +} + +// NewFileValidator creates a new FileValidator instance that validates file types +// against ExifTool's supported extensions using the provided ExtensionProvider. +func NewFileValidator(extensionProvider exif.ExtensionProvider) exif.FileValidator { + return &FileValidator{ + extensionProvider: extensionProvider, + } +} + +// ValidateFileType checks if the given file path has an extension supported by ExifTool. +// Returns ErrorCodeUnsupportedFileType if the file type is not supported. +// Returns ErrorCodeExifToolNotFound if unable to retrieve supported extensions. +func (f *FileValidator) ValidateFileType(filePath string) error { + ext := strings.ToLower(filepath.Ext(filePath)) + + supportedExtensions, err := f.extensionProvider.GetSupportedExtensions() + if err != nil { + return &Error{ + Code: ErrorCodeExifToolNotFound, + Message: "failed to get supported extensions", + Details: err.Error(), + } + } + + if !supportedExtensions[ext] { + return &Error{ + Code: ErrorCodeUnsupportedFileType, + Message: "file type does not support EXIF data", + Details: "extension " + ext + " not supported by ExifTool", + } + } + + return nil +} diff --git a/internal/infrastructure/exif/file_validator_test.go b/internal/infrastructure/exif/file_validator_test.go new file mode 100644 index 0000000..7624340 --- /dev/null +++ b/internal/infrastructure/exif/file_validator_test.go @@ -0,0 +1,292 @@ +package exif + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFileValidator(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + + validator := NewFileValidator(mockProvider) + + require.NotNil(t, validator) + assert.IsType(t, &FileValidator{}, validator) +} + +func TestValidateFileType_SupportedFileTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + ext string + }{ + {"jpeg file", "/path/to/image.jpg", ".jpg"}, + {"jpeg uppercase", "/path/to/IMAGE.JPG", ".jpg"}, + {"jpeg with dots", "/path.to/file.image.jpeg", ".jpeg"}, + {"png file", "image.png", ".png"}, + {"png uppercase", "IMAGE.PNG", ".png"}, + {"tiff file", "/tmp/scan.tiff", ".tiff"}, + {"gif file", "animated.gif", ".gif"}, + {"raw file", "photo.raw", ".raw"}, + {"cr2 file", "canon.cr2", ".cr2"}, + {"nef file", "nikon.nef", ".nef"}, + {"mp4 video", "video.mp4", ".mp4"}, + {"mov video", "movie.mov", ".mov"}, + {"pdf document", "document.pdf", ".pdf"}, + {"path with spaces", "/path with spaces/file name.jpg", ".jpg"}, + {"relative path", "./relative/path/image.png", ".png"}, + {"no path", "simple.jpg", ".jpg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".tiff": true, + ".gif": true, + ".raw": true, + ".cr2": true, + ".nef": true, + ".mp4": true, + ".mov": true, + ".pdf": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + require.NoError(t, err) + mockProvider.AssertExpectations(t) + }) + } +} + +func TestValidateFileType_UnsupportedFileTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + ext string + }{ + {"text file", "/path/to/document.txt", ".txt"}, + {"text uppercase", "DOCUMENT.TXT", ".txt"}, + {"executable", "program.exe", ".exe"}, + {"shell script", "script.sh", ".sh"}, + {"python script", "script.py", ".py"}, + {"go source", "main.go", ".go"}, + {"json file", "config.json", ".json"}, + {"xml file", "data.xml", ".xml"}, + {"csv file", "data.csv", ".csv"}, + {"markdown", "README.md", ".md"}, + {"yaml file", "config.yaml", ".yaml"}, + {"no extension", "filename", ""}, + {"hidden file", ".gitignore", ".gitignore"}, + {"double extension unsupported", "file.tar.gz", ".gz"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + require.Error(t, err) + + var exifErr *Error + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) + assert.Contains(t, exifErr.Message, "file type does not support EXIF data") + assert.Contains(t, exifErr.Details, tt.ext) + + mockProvider.AssertExpectations(t) + }) + } +} + +func TestValidateFileType_ExtensionProviderError(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + providerErr := errors.New("failed to execute exiftool") + mockProvider.On("GetSupportedExtensions").Return(nil, providerErr) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType("/path/to/image.jpg") + + require.Error(t, err) + + var exifErr *Error + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeExifToolNotFound, exifErr.Code) + assert.Equal(t, "failed to get supported extensions", exifErr.Message) + assert.Equal(t, providerErr.Error(), exifErr.Details) + + mockProvider.AssertExpectations(t) +} + +func TestValidateFileType_EmptySupportedExtensions(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + emptyExts := map[string]bool{} + mockProvider.On("GetSupportedExtensions").Return(emptyExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType("/path/to/image.jpg") + + require.Error(t, err) + + var exifErr *Error + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) + + mockProvider.AssertExpectations(t) +} + +func TestValidateFileType_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + expectedExt string + isSupported bool + expectedError bool + }{ + { + name: "empty filepath", + filePath: "", + expectedExt: "", + isSupported: false, + expectedError: true, + }, + { + name: "dot only", + filePath: ".", + expectedExt: ".", + isSupported: false, + expectedError: true, + }, + { + name: "multiple dots", + filePath: "file.backup.old.jpg", + expectedExt: ".jpg", + isSupported: true, + expectedError: false, + }, + { + name: "path with dot but no extension", + filePath: "./directory/filename", + expectedExt: "", + isSupported: false, + expectedError: true, + }, + { + name: "very long extension", + filePath: "file.verylongextension", + expectedExt: ".verylongextension", + isSupported: false, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + if tt.expectedError { + require.Error(t, err) + if tt.expectedExt != "" { + var exifErr *Error + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) + } + } else { + require.NoError(t, err) + } + + mockProvider.AssertExpectations(t) + }) + } +} + +func TestValidateFileType_CaseSensitivity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + shouldPass bool + }{ + {"lowercase jpg", "image.jpg", true}, + {"uppercase JPG", "image.JPG", true}, + {"mixed case Jpg", "image.Jpg", true}, + {"mixed case jPg", "image.jPg", true}, + {"all caps JPEG", "IMAGE.JPEG", true}, + {"mixed path case", "Path/To/IMAGE.jpeg", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + if tt.shouldPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + + mockProvider.AssertExpectations(t) + }) + } +} diff --git a/internal/infrastructure/exif/processor.go b/internal/infrastructure/exif/processor.go new file mode 100644 index 0000000..2b73530 --- /dev/null +++ b/internal/infrastructure/exif/processor.go @@ -0,0 +1,118 @@ +package exif + +import ( + "context" + "time" + + "nos3/internal/domain/repository/exif" + + "github.com/barasher/go-exiftool" + + grpcRepository "nos3/internal/domain/repository/grpcclient" + "nos3/pkg/logger" +) + +type Processor struct { + exiftool *exiftool.Exiftool + timeout time.Duration + grpcClient grpcRepository.IClient +} + +// NewProcessor creates a new Processor instance that handles EXIF metadata removal. +// It initializes the ExifTool wrapper with stay-open mode for efficient processing. +// Returns an error if ExifTool initialization fails. +func NewProcessor(exiftoolCmd string, + timeout time.Duration, + grpcClient grpcRepository.IClient, +) (exif.Processor, error) { + logger.Info("initializing exiftool for EXIF processing") + + var et *exiftool.Exiftool + var err error + + if exiftoolCmd != "" { + et, err = exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(exiftoolCmd)) + } else { + et, err = exiftool.NewExiftool() + } + + if err != nil { + logError(context.Background(), grpcClient, "failed to initialize exiftool", err.Error()) + + return nil, &Error{ + Code: ErrorCodeExifToolNotFound, + Message: "failed to initialize exiftool", + Details: err.Error(), + } + } + + return &Processor{ + exiftool: et, + timeout: timeout, + grpcClient: grpcClient, + }, nil +} + +// RemoveExifData removes all EXIF metadata from the specified file. +// The operation is performed asynchronously with context cancellation support. +// Returns ErrorCodeExifRemovalFailed if metadata removal fails. +// Returns ErrorCodeTimeout if the operation exceeds the configured timeout. +func (e *Processor) RemoveExifData(ctx context.Context, filePath string) error { + done := make(chan error, 1) + + go func() { + defer close(done) + + fileMetadata := exiftool.FileMetadata{ + File: filePath, + Fields: map[string]interface{}{"All": ""}, + } + + e.exiftool.WriteMetadata([]exiftool.FileMetadata{fileMetadata}) + + if fileMetadata.Err != nil { + done <- &Error{ + Code: ErrorCodeExifRemovalFailed, + Message: "failed to remove EXIF data", + Details: fileMetadata.Err.Error(), + } + + return + } + + done <- nil + }() + + select { + case err := <-done: + if err != nil { + logError(ctx, e.grpcClient, "EXIF removal failed", err.Error()) + } + + return err + case <-ctx.Done(): + return &Error{ + Code: ErrorCodeTimeout, + Message: "EXIF removal timeout", + Details: "operation took too long", + } + } +} + +// Close closes the ExifTool process and releases associated resources. +// Should be called when the Processor is no longer needed. +func (e *Processor) Close() error { + if e.exiftool != nil { + return e.exiftool.Close() + } + + return nil +} + +// logError is a standalone function for logging errors to both local logger and gRPC client. +func logError(ctx context.Context, grpcClient grpcRepository.IClient, message, details string) { + logger.Error(message, "details", details) + if _, logErr := grpcClient.AddLog(ctx, message, details); logErr != nil { + logger.Error("can't send log to manager", "err", logErr) + } +} diff --git a/internal/infrastructure/exif/processor_test.go b/internal/infrastructure/exif/processor_test.go new file mode 100644 index 0000000..72e40f7 --- /dev/null +++ b/internal/infrastructure/exif/processor_test.go @@ -0,0 +1,232 @@ +package exif + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "nos3/internal/infrastructure/grpcclient/gen" +) + +const ( + TestTimeout = 5 * time.Second + TestExifComment = "Test EXIF comment with metadata" + TestExifArtist = "Test Artist" + TestExifCopyright = "Copyright 2024" +) + +func setupTestDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "exif_processor_test_*") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + + return dir +} + +func checkExifToolAvailable(t *testing.T) { + t.Helper() + cmd := exec.Command("exiftool", "-ver") + if err := cmd.Run(); err != nil { + t.Skip("exiftool not available on this system, skipping integration test") + } +} + +func createTestImageWithExif(t *testing.T, filePath string) { + t.Helper() + jpegData := []byte{ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, + 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, + 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12, + 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20, + 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, + 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, + 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, + 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x03, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, + 0x37, 0xFF, 0xD9, + } + err := os.WriteFile(filePath, jpegData, 0o600) + require.NoError(t, err) + cmd := exec.Command("exiftool", + "-overwrite_original", + "-Comment="+TestExifComment, + "-Artist="+TestExifArtist, + "-Copyright="+TestExifCopyright, + "-Make=TestCamera", + "-Model=TestModel", + "-Software=TestSoftware", + filePath, + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to add EXIF data: %s", string(output)) +} + +func createTestPNGWithExif(t *testing.T, filePath string) { + t.Helper() + pngData := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, + 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, + 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, + 0xAE, 0x42, 0x60, 0x82, + } + err := os.WriteFile(filePath, pngData, 0o600) + require.NoError(t, err) + cmd := exec.Command("exiftool", + "-overwrite_original", + "-Comment="+TestExifComment, + "-Artist="+TestExifArtist, + filePath, + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to add metadata: %s", string(output)) +} + +func hasExifData(t *testing.T, filePath string) bool { + t.Helper() + cmd := exec.Command("exiftool", "-a", "-G1", filePath) + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + outputStr := string(output) + + return strings.Contains(outputStr, TestExifComment) || + strings.Contains(outputStr, TestExifArtist) || + strings.Contains(outputStr, TestExifCopyright) || + strings.Contains(outputStr, "TestCamera") +} + +func TestProcessor_RemoveExifData_JPEG(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + testFile := filepath.Join(testDir, "test_image.jpg") + createTestImageWithExif(t, testFile) + + require.True(t, hasExifData(t, testFile), "Test image should have EXIF data") + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + err = processor.RemoveExifData(context.Background(), testFile) + assert.NoError(t, err) + + assert.False(t, hasExifData(t, testFile), "EXIF data should be removed") + fileInfo, err := os.Stat(testFile) + assert.NoError(t, err) + assert.Greater(t, fileInfo.Size(), int64(0), "File should not be empty") +} + +func TestProcessor_RemoveExifData_PNG(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + testFile := filepath.Join(testDir, "test_image.png") + createTestPNGWithExif(t, testFile) + + require.True(t, hasExifData(t, testFile), "Test PNG should have metadata") + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + err = processor.RemoveExifData(context.Background(), testFile) + assert.NoError(t, err) + + assert.False(t, hasExifData(t, testFile), "Metadata should be removed") + fileInfo, err := os.Stat(testFile) + assert.NoError(t, err) + assert.Greater(t, fileInfo.Size(), int64(0), "File should not be empty") +} + +func TestProcessor_RemoveExifData_NonExistentFile(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + nonExistentFile := filepath.Join(testDir, "does_not_exist.jpg") + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + err = processor.RemoveExifData(context.Background(), nonExistentFile) + if err != nil { + var exifErr *Error + if assert.ErrorAs(t, err, &exifErr) { + assert.Equal(t, ErrorCodeExifRemovalFailed, exifErr.Code) + } + } +} + +func TestProcessor_RemoveExifData_ContextCancellation(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + testFile := filepath.Join(testDir, "test_image.jpg") + createTestImageWithExif(t, testFile) + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err = processor.RemoveExifData(ctx, testFile) + assert.Error(t, err) + + var exifErr *Error + if assert.ErrorAs(t, err, &exifErr) { + assert.Equal(t, ErrorCodeTimeout, exifErr.Code) + assert.True(t, exifErr.IsTimeout()) + } +} + +func TestProcessor_Close(t *testing.T) { + checkExifToolAvailable(t) + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + + err = processor.Close() + assert.NoError(t, err) +} + +func TestProcessor_NewProcessor_InvalidExifToolPath(t *testing.T) { + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.Anything, mock.Anything).Return(&gen.AddLogResponse{}, nil).Maybe() + + processor, err := NewProcessor("/invalid/path/to/exiftool", TestTimeout, mockGRPC) + assert.Error(t, err) + assert.Nil(t, processor) + + var exifErr *Error + if assert.ErrorAs(t, err, &exifErr) { + assert.Equal(t, ErrorCodeExifToolNotFound, exifErr.Code) + } +} diff --git a/internal/infrastructure/exif/remover.go b/internal/infrastructure/exif/remover.go new file mode 100644 index 0000000..af31392 --- /dev/null +++ b/internal/infrastructure/exif/remover.go @@ -0,0 +1,68 @@ +package exif + +import ( + "context" + "errors" + "time" + + "nos3/internal/domain/repository/exif" + + grpcRepository "nos3/internal/domain/repository/grpcclient" + "nos3/pkg/logger" +) + +type Remover struct { + fileValidator exif.FileValidator + exifProcessor exif.Processor + timeout time.Duration + grpcClient grpcRepository.IClient +} + +// NewRemover creates a new Remover instance that orchestrates EXIF metadata removal. +// It uses dependency injection to receive validator and processor services. +// This is the main entry point for EXIF removal operations. +func NewRemover( + fileValidator exif.FileValidator, + exifProcessor exif.Processor, + timeout time.Duration, + grpcClient grpcRepository.IClient, +) *Remover { + logger.Info("initializing EXIF remover") + + return &Remover{ + fileValidator: fileValidator, + exifProcessor: exifProcessor, + timeout: timeout, + grpcClient: grpcClient, + } +} + +// RemoveExifFromFile removes EXIF metadata from the specified file. +// It first validates that the file type supports EXIF metadata. +// If the file type is unsupported, returns nil (not an error). +// For supported files, removes all EXIF data and returns any errors encountered. +func (r *Remover) RemoveExifFromFile(ctx context.Context, filePath string) error { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + if err := r.fileValidator.ValidateFileType(filePath); err != nil { + var exifErr *Error + if errors.As(err, &exifErr) && exifErr.Code == ErrorCodeUnsupportedFileType { + return nil + } + + return err + } + + if err := r.exifProcessor.RemoveExifData(ctx, filePath); err != nil { + return err + } + + return nil +} + +// Close closes the underlying EXIF processor and releases associated resources. +// Should be called when the Remover is no longer needed. +func (r *Remover) Close() error { + return r.exifProcessor.Close() +} diff --git a/internal/infrastructure/exif/remover_test.go b/internal/infrastructure/exif/remover_test.go new file mode 100644 index 0000000..0afb8e9 --- /dev/null +++ b/internal/infrastructure/exif/remover_test.go @@ -0,0 +1,274 @@ +package exif + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + RemoverTimeout = 5 * time.Second +) + +func TestNewRemover(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + require.NotNil(t, remover) + assert.Equal(t, RemoverTimeout, remover.timeout) + assert.Equal(t, mockValidator, remover.fileValidator) + assert.Equal(t, mockProcessor, remover.exifProcessor) + assert.Equal(t, mockGRPC, remover.grpcClient) +} + +func TestRemoveExifFromFile_Success(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + mockValidator.On("ValidateFileType", TestFilePath).Return(nil) + mockProcessor.On("RemoveExifData", mock.Anything, TestFilePath).Return(nil) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestFilePath) + + require.NoError(t, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertExpectations(t) +} + +func TestRemoveExifFromFile_UnsupportedFileType(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + unsupportedErr := &Error{ + Code: ErrorCodeUnsupportedFileType, + Message: "file type does not support EXIF data", + Details: ".txt not supported", + } + + mockValidator.On("ValidateFileType", TestUnsupportedPath).Return(unsupportedErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestUnsupportedPath) + + require.NoError(t, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertNotCalled(t, "RemoveExifData", mock.Anything, mock.Anything) +} + +func TestRemoveExifFromFile_ValidationError(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + validationErr := &Error{ + Code: ErrorCodeExifToolNotFound, + Message: "failed to get supported extensions", + Details: "exiftool not found", + } + + mockValidator.On("ValidateFileType", TestFilePath).Return(validationErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestFilePath) + + require.Error(t, err) + assert.Equal(t, validationErr, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertNotCalled(t, "RemoveExifData", mock.Anything, mock.Anything) +} + +func TestRemoveExifFromFile_ProcessorError(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + processorErr := &Error{ + Code: ErrorCodeExifRemovalFailed, + Message: "failed to remove EXIF data", + Details: "write error", + } + + mockValidator.On("ValidateFileType", TestCorruptedPath).Return(nil) + mockProcessor.On("RemoveExifData", mock.Anything, TestCorruptedPath).Return(processorErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestCorruptedPath) + + require.Error(t, err) + assert.Equal(t, processorErr, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertExpectations(t) +} + +func TestRemoveExifFromFile_ContextCancellation(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + mockValidator.On("ValidateFileType", TestFilePath).Return(nil) + mockProcessor.On("RemoveExifData", mock.Anything, TestFilePath). + Return(&Error{ + Code: ErrorCodeTimeout, + Message: "context cancelled", + Details: "operation cancelled", + }) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := remover.RemoveExifFromFile(ctx, TestFilePath) + + require.Error(t, err) + mockValidator.AssertExpectations(t) +} + +func TestRemoveExifFromFile_MultipleFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + validateErr error + processErr error + expectError bool + expectedCode ErrorCode + }{ + { + name: "successful removal", + filePath: "/images/photo1.jpg", + validateErr: nil, + processErr: nil, + expectError: false, + }, + { + name: "unsupported file", + filePath: "/docs/readme.txt", + validateErr: &Error{Code: ErrorCodeUnsupportedFileType}, + processErr: nil, + expectError: false, + }, + { + name: "validation fails", + filePath: "/images/photo2.jpg", + validateErr: &Error{Code: ErrorCodeExifToolNotFound}, + processErr: nil, + expectError: true, + expectedCode: ErrorCodeExifToolNotFound, + }, + { + name: "processing fails", + filePath: "/images/photo3.jpg", + validateErr: nil, + processErr: &Error{Code: ErrorCodeExifRemovalFailed}, + expectError: true, + expectedCode: ErrorCodeExifRemovalFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + mockValidator.On("ValidateFileType", tt.filePath).Return(tt.validateErr) + + if tt.validateErr == nil { + mockProcessor.On("RemoveExifData", mock.Anything, tt.filePath).Return(tt.processErr) + } + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), tt.filePath) + + if tt.expectError { + require.Error(t, err) + var exifErr *Error + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, tt.expectedCode, exifErr.Code) + } else { + require.NoError(t, err) + } + + mockValidator.AssertExpectations(t) + mockProcessor.AssertExpectations(t) + }) + } +} + +func TestClose(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + closeErr error + expectError bool + }{ + { + name: "successful close", + closeErr: nil, + expectError: false, + }, + { + name: "close error", + closeErr: errors.New("failed to close exiftool"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockProcessor{} + mockGRPC := &MockGRPC{} + + mockProcessor.On("Close").Return(tt.closeErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.Close() + + if tt.expectError { + require.Error(t, err) + assert.Equal(t, tt.closeErr, err) + } else { + require.NoError(t, err) + } + + mockProcessor.AssertExpectations(t) + }) + } +} diff --git a/internal/infrastructure/exif/testing_helpers_test.go b/internal/infrastructure/exif/testing_helpers_test.go new file mode 100644 index 0000000..05b4fd1 --- /dev/null +++ b/internal/infrastructure/exif/testing_helpers_test.go @@ -0,0 +1,101 @@ +package exif + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "nos3/internal/infrastructure/grpcclient/gen" +) + +// MockGRPC is a shared mock implementation of the gRPC client interface. +type MockGRPC struct { + mock.Mock +} + +func (m *MockGRPC) RegisterService(_ context.Context, _, _ string) (*gen.RegisterServiceResponse, error) { + args := m.Called() + + return args.Get(0).(*gen.RegisterServiceResponse), args.Error(1) +} + +func (m *MockGRPC) AddLog(_ context.Context, msg, stack string) (*gen.AddLogResponse, error) { + args := m.Called(msg, stack) + + return args.Get(0).(*gen.AddLogResponse), args.Error(1) +} + +func (m *MockGRPC) AddReport(_ context.Context, _ string, _ []string, _, _, _, _ string) ( + *gen.AddReportResponse, error, +) { + args := m.Called() + + return args.Get(0).(*gen.AddReportResponse), args.Error(1) +} + +// MockCommandExecutor is a mock for command execution. +type MockCommandExecutor struct { + mock.Mock +} + +func (m *MockCommandExecutor) Execute(cmd string, args ...string) ([]byte, error) { + argsList := m.Called(cmd, args) + if argsList.Get(0) == nil { + return nil, argsList.Error(1) + } + + return argsList.Get(0).([]byte), argsList.Error(1) +} + +// MockExtensionProvider is a mock for extension provider. +type MockExtensionProvider struct { + mock.Mock +} + +func (m *MockExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(map[string]bool), args.Error(1) +} + +// MockProcessor is a mock for exif processor. +type MockProcessor struct { + mock.Mock +} + +func (m *MockProcessor) RemoveExifData(ctx context.Context, filePath string) error { + args := m.Called(ctx, filePath) + + return args.Error(0) +} + +func (m *MockProcessor) Close() error { + args := m.Called() + + return args.Error(0) +} + +// MockFileValidator is a mock for file validator. +type MockFileValidator struct { + mock.Mock +} + +func (m *MockFileValidator) ValidateFileType(filePath string) error { + args := m.Called(filePath) + + return args.Error(0) +} + +const ( + TestImagePath = "/tmp/test_image.jpg" + TestVideoPath = "/tmp/test_video.mp4" + TestFilePath = "/test/image.jpg" + TestUnsupportedPath = "/test/document.txt" + TestCorruptedPath = "/test/corrupted.jpg" + TestTimeoutPath = "/test/timeout.jpg" + TestExifToolPath = "/usr/bin/exiftool" + InvalidExifToolPath = "/invalid/path/exiftool" +)