@uploadista/flow-security-clamscan 0.0.20 → 0.1.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,23 @@
1
-
2
- > @uploadista/flow-security-clamscan@0.0.20-beta.9 build /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan
3
- > tsc --noEmit && tsdown
4
-
5
- ℹ tsdown v0.18.0 powered by rolldown v1.0.0-beta.53
6
- ℹ config file: /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan/tsdown.config.ts
7
- ℹ entry: src/index.ts
8
- ℹ tsconfig: tsconfig.json
9
- ℹ Build start
10
- ℹ Cleaning 7 files
11
- ℹ [CJS] dist/index.cjs 2.89 kB │ gzip: 1.22 kB
12
- ℹ [CJS] 1 files, total: 2.89 kB
13
- ℹ [ESM] dist/index.mjs 2.27 kB │ gzip: 0.97 kB
14
- ℹ [ESM] dist/index.mjs.map 9.43 kB │ gzip: 2.94 kB
15
- ℹ [ESM] dist/index.d.mts.map 0.25 kB │ gzip: 0.17 kB
16
- ℹ [ESM] dist/index.d.mts 1.68 kB │ gzip: 0.73 kB
17
- ℹ [ESM] 4 files, total: 13.62 kB
18
- ✔ Build complete in 7684ms
19
- ℹ [CJS] dist/index.d.cts.map 0.25 kB │ gzip: 0.17 kB
20
- ℹ [CJS] dist/index.d.cts 1.68 kB │ gzip: 0.73 kB
21
- ℹ [CJS] 2 files, total: 1.92 kB
22
- ✔ Build complete in 7697ms
1
+
2
+ 
3
+ > @uploadista/flow-security-clamscan@0.1.0-beta.4 build /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan
4
+ > tsc --noEmit && tsdown
5
+
6
+ ℹ tsdown v0.19.0 powered by rolldown v1.0.0-beta.59
7
+ ℹ config file: /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan/tsdown.config.ts
8
+ ℹ entry: src/index.ts
9
+ ℹ tsconfig: tsconfig.json
10
+ ℹ Build start
11
+ ℹ Cleaning 7 files
12
+ ℹ [CJS] dist/index.cjs 2.89 kB │ gzip: 1.22 kB
13
+ ℹ [CJS] 1 files, total: 2.89 kB
14
+ ℹ [CJS] dist/index.d.cts.map 0.25 kB │ gzip: 0.17 kB
15
+ ℹ [CJS] dist/index.d.cts 1.68 kB │ gzip: 0.73 kB
16
+ ℹ [CJS] 2 files, total: 1.92 kB
17
+ ✔ Build complete in 6173ms
18
+ ℹ [ESM] dist/index.mjs 2.27 kB │ gzip: 0.97 kB
19
+ ℹ [ESM] dist/index.mjs.map 9.40 kB │ gzip: 2.93 kB
20
+ ℹ [ESM] dist/index.d.mts.map 0.25 kB │ gzip: 0.17 kB
21
+ ℹ [ESM] dist/index.d.mts 1.68 kB │ gzip: 0.73 kB
22
+ ℹ [ESM] 4 files, total: 13.59 kB
23
+ ✔ Build complete in 6202ms
@@ -0,0 +1,6 @@
1
+
2
+ 
3
+ > @uploadista/flow-security-clamscan@0.1.0-beta.2 check /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan
4
+ > biome check --write ./src
5
+
6
+ Checked 3 files in 57ms. No fixes applied.
@@ -0,0 +1,136 @@
1
+
2
+ 
3
+ > @uploadista/flow-security-clamscan@0.1.0-beta.2 test /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan
4
+ > vitest run
5
+
6
+ [?25l
7
+  RUN  v4.0.17 /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan
8
+
9
+ [?2026h
10
+  ❯ tests/clamscan-plugin.test.ts [queued]
11
+
12
+  Test Files 0 passed (1)
13
+  Tests 0 passed (0)
14
+  Start at 16:59:24
15
+  Duration 424ms
16
+ [?2026l[?2026h
17
+  ❯ tests/clamscan-plugin.test.ts [queued]
18
+
19
+  Test Files 0 passed (1)
20
+  Tests 0 passed (0)
21
+  Start at 16:59:24
22
+  Duration 954ms
23
+ [?2026l[?2026h
24
+  ❯ tests/clamscan-plugin.test.ts [queued]
25
+
26
+  Test Files 0 passed (1)
27
+  Tests 0 passed (0)
28
+  Start at 16:59:24
29
+  Duration 2.14s
30
+ [?2026l[?2026h
31
+  ❯ tests/clamscan-plugin.test.ts [queued]
32
+
33
+  Test Files 0 passed (1)
34
+  Tests 0 passed (0)
35
+  Start at 16:59:24
36
+  Duration 3.10s
37
+ [?2026l[?2026h
38
+  ❯ tests/clamscan-plugin.test.ts [queued]
39
+
40
+  Test Files 0 passed (1)
41
+  Tests 0 passed (0)
42
+  Start at 16:59:24
43
+  Duration 4.07s
44
+ [?2026l[?2026h
45
+  ❯ tests/clamscan-plugin.test.ts [queued]
46
+
47
+  Test Files 0 passed (1)
48
+  Tests 0 passed (0)
49
+  Start at 16:59:24
50
+  Duration 5.11s
51
+ [?2026l[?2026h
52
+  ❯ tests/clamscan-plugin.test.ts 0/25
53
+
54
+  Test Files 0 passed (1)
55
+  Tests 0 passed (25)
56
+  Start at 16:59:24
57
+  Duration 5.64s
58
+ [?2026l[?2026h
59
+  ❯ tests/clamscan-plugin.test.ts 1/25
60
+
61
+  Test Files 0 passed (1)
62
+  Tests 1 passed (25)
63
+  Start at 16:59:24
64
+  Duration 5.84s
65
+ [?2026l[?2026h
66
+  ❯ tests/clamscan-plugin.test.ts 8/25
67
+
68
+  Test Files 0 passed (1)
69
+  Tests 8 passed (25)
70
+  Start at 16:59:24
71
+  Duration 6.05s
72
+ [?2026l[?2026h
73
+  ❯ tests/clamscan-plugin.test.ts 9/25
74
+
75
+  Test Files 0 passed (1)
76
+  Tests 9 passed (25)
77
+  Start at 16:59:24
78
+  Duration 6.16s
79
+ [?2026l[?2026h
80
+  ❯ tests/clamscan-plugin.test.ts 20/25
81
+
82
+  Test Files 0 passed (1)
83
+  Tests 20 passed (25)
84
+  Start at 16:59:24
85
+  Duration 6.26s
86
+ [?2026l[?2026h
87
+  ❯ tests/clamscan-plugin.test.ts 24/25
88
+
89
+  Test Files 0 passed (1)
90
+  Tests 24 passed (25)
91
+  Start at 16:59:24
92
+  Duration 6.36s
93
+ [?2026l ✓ tests/clamscan-plugin.test.ts (25 tests) 628ms
94
+ ✓ ClamScan Virus Plugin (25)
95
+ ✓ virusScanPlugin factory (2)
96
+ ✓ should create a plugin layer with default configuration 9ms
97
+ ✓ should create a plugin layer with custom configuration 6ms
98
+ ✓ scan method (5)
99
+ ✓ should return clean result for non-infected file 87ms
100
+ ✓ should return infected result with virus names 14ms
101
+ ✓ should handle null virus array for infected files 15ms
102
+ ✓ should handle binary data correctly 10ms
103
+ ✓ should handle large file data 18ms
104
+ ✓ getVersion method (3)
105
+ ✓ should return ClamAV version string 45ms
106
+ ✓ should return 'Unknown' when version is undefined 5ms
107
+ ✓ should return 'Unknown' when version is empty 3ms
108
+ ✓ error handling (3)
109
+ ✓ should fail with CLAMAV_NOT_INSTALLED when initialization fails 24ms
110
+ ✓ should fail with VIRUS_SCAN_FAILED when scan fails 48ms
111
+ ✓ should fail with VIRUS_SCAN_FAILED when getVersion fails 1ms
112
+ ✓ configuration options (6)
113
+ ✓ should accept clamdscan preference 0ms
114
+ ✓ should accept clamscan preference 0ms
115
+ ✓ should accept socket configuration for clamd 0ms
116
+ ✓ should accept TCP host and port for clamd 0ms
117
+ ✓ should accept debug mode configuration 0ms
118
+ ✓ should accept remove_infected configuration 0ms
119
+ ✓ EICAR test file detection (1)
120
+ ✓ should detect EICAR test file as infected 40ms
121
+ ✓ temporary file handling (2)
122
+ ✓ should clean up temporary files after successful scan 18ms
123
+ ✓ should clean up temporary files even when scan fails 27ms
124
+ ✓ lazy initialization (1)
125
+ ✓ should initialize scanner lazily on first use 23ms
126
+ ✓ multiple virus detection (1)
127
+ ✓ should report multiple viruses when detected 85ms
128
+ ✓ empty file handling (1)
129
+ ✓ should handle empty file input 32ms
130
+
131
+  Test Files  1 passed (1)
132
+  Tests  25 passed (25)
133
+  Start at  16:59:24
134
+  Duration  6.49s (transform 1.52s, setup 0ms, import 5.22s, tests 628ms, environment 0ms)
135
+
136
+ [?25h
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["config: VirusScanPluginConfig"],"sources":["../src/clamscan-plugin.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport * as fs from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { UploadistaError } from \"@uploadista/core/errors\";\nimport type { ScanResult, VirusScanPluginShape } from \"@uploadista/core/flow\";\nimport { VirusScanPlugin } from \"@uploadista/core/flow\";\nimport { withOperationSpan } from \"@uploadista/observability\";\nimport NodeClam from \"clamscan\";\nimport { Effect, Layer } from \"effect\";\n\n/**\n * Configuration options for the ClamAV Virus Scan plugin\n */\nexport interface VirusScanPluginConfig {\n /**\n * Preference for scanning method\n * - \"clamdscan\": Use clamd daemon (faster, recommended)\n * - \"clamscan\": Use command-line binary\n */\n preference?: \"clamdscan\" | \"clamscan\";\n\n /**\n * Path to clamd socket (for daemon mode)\n * Default: /var/run/clamd.scan/clamd.sock\n */\n clamdscan_socket?: string;\n\n /**\n * TCP host for clamd (alternative to socket)\n */\n clamdscan_host?: string;\n\n /**\n * TCP port for clamd\n * Default: 3310\n */\n clamdscan_port?: number;\n\n /**\n * Whether to remove infected files automatically\n * Default: false (not recommended in flow context)\n */\n remove_infected?: boolean;\n\n /**\n * Debug mode for clamscan library\n * Default: false\n */\n debug_mode?: boolean;\n}\n\n/**\n * ClamAV implementation of the VirusScanPlugin\n *\n * This plugin uses the `clamscan` npm package to scan files for viruses\n * using ClamAV antivirus engine. It supports both clamd daemon mode (fast)\n * and binary mode (slower but more portable).\n *\n * @example\n * ```typescript\n * import { ClamScanPluginLayer } from \"@uploadista/flow-security-clamscan\";\n *\n * const program = Effect.gen(function* () {\n * const virusScan = yield* VirusScanPlugin;\n * const result = yield* virusScan.scan(fileBytes);\n * console.log(result.isClean ? \"Clean\" : \"Infected\");\n * }).pipe(Effect.provide(ClamScanPluginLayer));\n * ```\n */\nclass ClamScanPluginImpl implements VirusScanPluginShape {\n private clamscan: NodeClam | null = null;\n\n constructor(private config: VirusScanPluginConfig = {}) {}\n\n /**\n * Initialize the ClamAV scanner\n * This is called lazily on first use\n */\n private initScanner(): Effect.Effect<NodeClam, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n if (this.clamscan) {\n return this.clamscan;\n }\n\n const scanner = yield* Effect.tryPromise({\n try: async () => {\n // Initialize clamscan with configuration\n return await new NodeClam().init({\n preference: this.config.preference ?? \"clamdscan\",\n remove_infected: this.config.remove_infected ?? false,\n debug_mode: this.config.debug_mode ?? false,\n clamdscan: {\n socket: this.config.clamdscan_socket,\n host: this.config.clamdscan_host,\n port: this.config.clamdscan_port ?? 3310,\n timeout: 60000,\n local_fallback: true, // Fall back to binary if daemon unavailable\n },\n clamscan: {\n path: \"/usr/bin/clamscan\", // Standard path\n scan_archives: true,\n active: true,\n },\n });\n },\n catch: (error) =>\n UploadistaError.fromCode(\"CLAMAV_NOT_INSTALLED\", {\n body: `ClamAV initialization failed: ${error instanceof Error ? error.message : String(error)}`,\n details: { error },\n }),\n });\n\n this.clamscan = scanner;\n return scanner;\n }.bind(this),\n ).pipe(\n withOperationSpan(\"virus-scan\", \"init\", {\n \"scan.preference\": this.config.preference ?? \"clamdscan\",\n }),\n );\n }\n\n /**\n * Scans a file for viruses using ClamAV\n *\n * @param input - File contents as Uint8Array\n * @returns Effect with scan results\n */\n scan(input: Uint8Array): Effect.Effect<ScanResult, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n // Initialize scanner (lazy initialization)\n const scanner = yield* this.initScanner();\n\n // Create temporary file path for scanning\n const tmpDir = os.tmpdir();\n const fileName = `uploadista-scan-${randomUUID()}`;\n const tempFilePath = path.join(tmpDir, fileName);\n\n // Write file data to temp file\n yield* Effect.tryPromise({\n try: () => fs.writeFile(tempFilePath, input),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: \"Failed to create temporary file for scanning\",\n details: { error },\n }),\n });\n\n // Scan the file and ensure cleanup\n const result = yield* Effect.tryPromise({\n try: () => scanner.isInfected(tempFilePath),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: `Virus scan failed: ${error instanceof Error ? error.message : String(error)}`,\n details: { error },\n }),\n }).pipe(\n Effect.map((scanResult) => ({\n isClean: !scanResult.isInfected,\n detectedViruses: scanResult.viruses || [],\n })),\n Effect.ensuring(\n // Clean up temporary file (ignore errors)\n Effect.tryPromise({\n try: () => fs.unlink(tempFilePath),\n catch: () => undefined,\n }).pipe(Effect.ignore),\n ),\n );\n\n return result;\n }.bind(this),\n ).pipe(\n withOperationSpan(\"virus-scan\", \"scan\", {\n \"scan.file_size\": input.byteLength,\n }),\n );\n }\n\n /**\n * Gets the ClamAV engine version\n *\n * @returns Effect with version string\n */\n getVersion(): Effect.Effect<string, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n // Initialize scanner (lazy initialization)\n const scanner = yield* this.initScanner();\n\n // Get version from ClamAV\n const versionResult = yield* Effect.tryPromise({\n try: () => scanner.getVersion(),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: \"Failed to get ClamAV version\",\n details: { error },\n }),\n });\n\n return versionResult.version || \"Unknown\";\n }.bind(this),\n ).pipe(withOperationSpan(\"virus-scan\", \"get-version\", {}));\n }\n}\n\n/**\n * Creates a Virus Scan Plugin layer using ClamAV\n *\n * @param config - Optional ClamAV configuration\n * @returns Layer providing VirusScanPlugin\n *\n * @example\n * ```typescript\n * // Use with default configuration\n * const layer = ClamScanPluginLayer();\n *\n * // Use with custom configuration\n * const customLayer = ClamScanPluginLayer({\n * preference: \"clamdscan\",\n * clamdscan_socket: \"/var/run/clamav/clamd.ctl\"\n * });\n * ```\n */\nexport function virusScanPlugin(\n config: VirusScanPluginConfig = {},\n): Layer.Layer<VirusScanPlugin, never, never> {\n return Layer.succeed(VirusScanPlugin, new ClamScanPluginImpl(config));\n}\n"],"mappings":"qXAsEA,IAAM,EAAN,KAAyD,CACvD,SAAoC,KAEpC,YAAY,EAAwC,EAAE,CAAE,CAApC,KAAA,OAAA,EAMpB,aAAgE,CAC9D,OAAO,EAAO,IACZ,WAAqC,CACnC,GAAI,KAAK,SACP,OAAO,KAAK,SAGd,IAAM,EAAU,MAAO,EAAO,WAAW,CACvC,IAAK,SAEI,MAAM,IAAI,GAAU,CAAC,KAAK,CAC/B,WAAY,KAAK,OAAO,YAAc,YACtC,gBAAiB,KAAK,OAAO,iBAAmB,GAChD,WAAY,KAAK,OAAO,YAAc,GACtC,UAAW,CACT,OAAQ,KAAK,OAAO,iBACpB,KAAM,KAAK,OAAO,eAClB,KAAM,KAAK,OAAO,gBAAkB,KACpC,QAAS,IACT,eAAgB,GACjB,CACD,SAAU,CACR,KAAM,oBACN,cAAe,GACf,OAAQ,GACT,CACF,CAAC,CAEJ,MAAQ,GACN,EAAgB,SAAS,uBAAwB,CAC/C,KAAM,iCAAiC,aAAiB,MAAQ,EAAM,QAAU,OAAO,EAAM,GAC7F,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAGF,MADA,MAAK,SAAW,EACT,GACP,KAAK,KAAK,CACb,CAAC,KACA,EAAkB,aAAc,OAAQ,CACtC,kBAAmB,KAAK,OAAO,YAAc,YAC9C,CAAC,CACH,CASH,KAAK,EAA+D,CAClE,OAAO,EAAO,IACZ,WAAqC,CAEnC,IAAM,EAAU,MAAO,KAAK,aAAa,CAGnC,EAAS,EAAG,QAAQ,CACpB,EAAW,mBAAmB,GAAY,GAC1C,EAAe,EAAK,KAAK,EAAQ,EAAS,CAkChD,OA/BA,MAAO,EAAO,WAAW,CACvB,QAAW,EAAG,UAAU,EAAc,EAAM,CAC5C,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,+CACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAGa,MAAO,EAAO,WAAW,CACtC,QAAW,EAAQ,WAAW,EAAa,CAC3C,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,sBAAsB,aAAiB,MAAQ,EAAM,QAAU,OAAO,EAAM,GAClF,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAAC,KACD,EAAO,IAAK,IAAgB,CAC1B,QAAS,CAAC,EAAW,WACrB,gBAAiB,EAAW,SAAW,EAAE,CAC1C,EAAE,CACH,EAAO,SAEL,EAAO,WAAW,CAChB,QAAW,EAAG,OAAO,EAAa,CAClC,UAAa,IAAA,GACd,CAAC,CAAC,KAAK,EAAO,OAAO,CACvB,CACF,EAGD,KAAK,KAAK,CACb,CAAC,KACA,EAAkB,aAAc,OAAQ,CACtC,iBAAkB,EAAM,WACzB,CAAC,CACH,CAQH,YAAqD,CACnD,OAAO,EAAO,IACZ,WAAqC,CAEnC,IAAM,EAAU,MAAO,KAAK,aAAa,CAYzC,OATsB,MAAO,EAAO,WAAW,CAC7C,QAAW,EAAQ,YAAY,CAC/B,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,+BACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,EAEmB,SAAW,WAChC,KAAK,KAAK,CACb,CAAC,KAAK,EAAkB,aAAc,cAAe,EAAE,CAAC,CAAC,GAsB9D,SAAgB,EACd,EAAgC,EAAE,CACU,CAC5C,OAAO,EAAM,QAAQ,EAAiB,IAAI,EAAmB,EAAO,CAAC"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/clamscan-plugin.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport * as fs from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { UploadistaError } from \"@uploadista/core/errors\";\nimport type { ScanResult, VirusScanPluginShape } from \"@uploadista/core/flow\";\nimport { VirusScanPlugin } from \"@uploadista/core/flow\";\nimport { withOperationSpan } from \"@uploadista/observability\";\nimport NodeClam from \"clamscan\";\nimport { Effect, Layer } from \"effect\";\n\n/**\n * Configuration options for the ClamAV Virus Scan plugin\n */\nexport interface VirusScanPluginConfig {\n /**\n * Preference for scanning method\n * - \"clamdscan\": Use clamd daemon (faster, recommended)\n * - \"clamscan\": Use command-line binary\n */\n preference?: \"clamdscan\" | \"clamscan\";\n\n /**\n * Path to clamd socket (for daemon mode)\n * Default: /var/run/clamd.scan/clamd.sock\n */\n clamdscan_socket?: string;\n\n /**\n * TCP host for clamd (alternative to socket)\n */\n clamdscan_host?: string;\n\n /**\n * TCP port for clamd\n * Default: 3310\n */\n clamdscan_port?: number;\n\n /**\n * Whether to remove infected files automatically\n * Default: false (not recommended in flow context)\n */\n remove_infected?: boolean;\n\n /**\n * Debug mode for clamscan library\n * Default: false\n */\n debug_mode?: boolean;\n}\n\n/**\n * ClamAV implementation of the VirusScanPlugin\n *\n * This plugin uses the `clamscan` npm package to scan files for viruses\n * using ClamAV antivirus engine. It supports both clamd daemon mode (fast)\n * and binary mode (slower but more portable).\n *\n * @example\n * ```typescript\n * import { ClamScanPluginLayer } from \"@uploadista/flow-security-clamscan\";\n *\n * const program = Effect.gen(function* () {\n * const virusScan = yield* VirusScanPlugin;\n * const result = yield* virusScan.scan(fileBytes);\n * console.log(result.isClean ? \"Clean\" : \"Infected\");\n * }).pipe(Effect.provide(ClamScanPluginLayer));\n * ```\n */\nclass ClamScanPluginImpl implements VirusScanPluginShape {\n private clamscan: NodeClam | null = null;\n\n constructor(private config: VirusScanPluginConfig = {}) {}\n\n /**\n * Initialize the ClamAV scanner\n * This is called lazily on first use\n */\n private initScanner(): Effect.Effect<NodeClam, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n if (this.clamscan) {\n return this.clamscan;\n }\n\n const scanner = yield* Effect.tryPromise({\n try: async () => {\n // Initialize clamscan with configuration\n return await new NodeClam().init({\n preference: this.config.preference ?? \"clamdscan\",\n remove_infected: this.config.remove_infected ?? false,\n debug_mode: this.config.debug_mode ?? false,\n clamdscan: {\n socket: this.config.clamdscan_socket,\n host: this.config.clamdscan_host,\n port: this.config.clamdscan_port ?? 3310,\n timeout: 60000,\n local_fallback: true, // Fall back to binary if daemon unavailable\n },\n clamscan: {\n path: \"/usr/bin/clamscan\", // Standard path\n scan_archives: true,\n active: true,\n },\n });\n },\n catch: (error) =>\n UploadistaError.fromCode(\"CLAMAV_NOT_INSTALLED\", {\n body: `ClamAV initialization failed: ${error instanceof Error ? error.message : String(error)}`,\n details: { error },\n }),\n });\n\n this.clamscan = scanner;\n return scanner;\n }.bind(this),\n ).pipe(\n withOperationSpan(\"virus-scan\", \"init\", {\n \"scan.preference\": this.config.preference ?? \"clamdscan\",\n }),\n );\n }\n\n /**\n * Scans a file for viruses using ClamAV\n *\n * @param input - File contents as Uint8Array\n * @returns Effect with scan results\n */\n scan(input: Uint8Array): Effect.Effect<ScanResult, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n // Initialize scanner (lazy initialization)\n const scanner = yield* this.initScanner();\n\n // Create temporary file path for scanning\n const tmpDir = os.tmpdir();\n const fileName = `uploadista-scan-${randomUUID()}`;\n const tempFilePath = path.join(tmpDir, fileName);\n\n // Write file data to temp file\n yield* Effect.tryPromise({\n try: () => fs.writeFile(tempFilePath, input),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: \"Failed to create temporary file for scanning\",\n details: { error },\n }),\n });\n\n // Scan the file and ensure cleanup\n const result = yield* Effect.tryPromise({\n try: () => scanner.isInfected(tempFilePath),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: `Virus scan failed: ${error instanceof Error ? error.message : String(error)}`,\n details: { error },\n }),\n }).pipe(\n Effect.map((scanResult) => ({\n isClean: !scanResult.isInfected,\n detectedViruses: scanResult.viruses || [],\n })),\n Effect.ensuring(\n // Clean up temporary file (ignore errors)\n Effect.tryPromise({\n try: () => fs.unlink(tempFilePath),\n catch: () => undefined,\n }).pipe(Effect.ignore),\n ),\n );\n\n return result;\n }.bind(this),\n ).pipe(\n withOperationSpan(\"virus-scan\", \"scan\", {\n \"scan.file_size\": input.byteLength,\n }),\n );\n }\n\n /**\n * Gets the ClamAV engine version\n *\n * @returns Effect with version string\n */\n getVersion(): Effect.Effect<string, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n // Initialize scanner (lazy initialization)\n const scanner = yield* this.initScanner();\n\n // Get version from ClamAV\n const versionResult = yield* Effect.tryPromise({\n try: () => scanner.getVersion(),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: \"Failed to get ClamAV version\",\n details: { error },\n }),\n });\n\n return versionResult.version || \"Unknown\";\n }.bind(this),\n ).pipe(withOperationSpan(\"virus-scan\", \"get-version\", {}));\n }\n}\n\n/**\n * Creates a Virus Scan Plugin layer using ClamAV\n *\n * @param config - Optional ClamAV configuration\n * @returns Layer providing VirusScanPlugin\n *\n * @example\n * ```typescript\n * // Use with default configuration\n * const layer = ClamScanPluginLayer();\n *\n * // Use with custom configuration\n * const customLayer = ClamScanPluginLayer({\n * preference: \"clamdscan\",\n * clamdscan_socket: \"/var/run/clamav/clamd.ctl\"\n * });\n * ```\n */\nexport function virusScanPlugin(\n config: VirusScanPluginConfig = {},\n): Layer.Layer<VirusScanPlugin, never, never> {\n return Layer.succeed(VirusScanPlugin, new ClamScanPluginImpl(config));\n}\n"],"mappings":"qXAsEA,IAAM,EAAN,KAAyD,CACvD,SAAoC,KAEpC,YAAY,EAAwC,EAAE,CAAE,CAApC,KAAA,OAAA,EAMpB,aAAgE,CAC9D,OAAO,EAAO,IACZ,WAAqC,CACnC,GAAI,KAAK,SACP,OAAO,KAAK,SAGd,IAAM,EAAU,MAAO,EAAO,WAAW,CACvC,IAAK,SAEI,MAAM,IAAI,GAAU,CAAC,KAAK,CAC/B,WAAY,KAAK,OAAO,YAAc,YACtC,gBAAiB,KAAK,OAAO,iBAAmB,GAChD,WAAY,KAAK,OAAO,YAAc,GACtC,UAAW,CACT,OAAQ,KAAK,OAAO,iBACpB,KAAM,KAAK,OAAO,eAClB,KAAM,KAAK,OAAO,gBAAkB,KACpC,QAAS,IACT,eAAgB,GACjB,CACD,SAAU,CACR,KAAM,oBACN,cAAe,GACf,OAAQ,GACT,CACF,CAAC,CAEJ,MAAQ,GACN,EAAgB,SAAS,uBAAwB,CAC/C,KAAM,iCAAiC,aAAiB,MAAQ,EAAM,QAAU,OAAO,EAAM,GAC7F,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAGF,MADA,MAAK,SAAW,EACT,GACP,KAAK,KAAK,CACb,CAAC,KACA,EAAkB,aAAc,OAAQ,CACtC,kBAAmB,KAAK,OAAO,YAAc,YAC9C,CAAC,CACH,CASH,KAAK,EAA+D,CAClE,OAAO,EAAO,IACZ,WAAqC,CAEnC,IAAM,EAAU,MAAO,KAAK,aAAa,CAGnC,EAAS,EAAG,QAAQ,CACpB,EAAW,mBAAmB,GAAY,GAC1C,EAAe,EAAK,KAAK,EAAQ,EAAS,CAkChD,OA/BA,MAAO,EAAO,WAAW,CACvB,QAAW,EAAG,UAAU,EAAc,EAAM,CAC5C,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,+CACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAGa,MAAO,EAAO,WAAW,CACtC,QAAW,EAAQ,WAAW,EAAa,CAC3C,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,sBAAsB,aAAiB,MAAQ,EAAM,QAAU,OAAO,EAAM,GAClF,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAAC,KACD,EAAO,IAAK,IAAgB,CAC1B,QAAS,CAAC,EAAW,WACrB,gBAAiB,EAAW,SAAW,EAAE,CAC1C,EAAE,CACH,EAAO,SAEL,EAAO,WAAW,CAChB,QAAW,EAAG,OAAO,EAAa,CAClC,UAAa,IAAA,GACd,CAAC,CAAC,KAAK,EAAO,OAAO,CACvB,CACF,EAGD,KAAK,KAAK,CACb,CAAC,KACA,EAAkB,aAAc,OAAQ,CACtC,iBAAkB,EAAM,WACzB,CAAC,CACH,CAQH,YAAqD,CACnD,OAAO,EAAO,IACZ,WAAqC,CAEnC,IAAM,EAAU,MAAO,KAAK,aAAa,CAYzC,OATsB,MAAO,EAAO,WAAW,CAC7C,QAAW,EAAQ,YAAY,CAC/B,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,+BACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,EAEmB,SAAW,WAChC,KAAK,KAAK,CACb,CAAC,KAAK,EAAkB,aAAc,cAAe,EAAE,CAAC,CAAC,GAsB9D,SAAgB,EACd,EAAgC,EAAE,CACU,CAC5C,OAAO,EAAM,QAAQ,EAAiB,IAAI,EAAmB,EAAO,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@uploadista/flow-security-clamscan",
3
3
  "type": "module",
4
- "version": "0.0.20",
4
+ "version": "0.1.0-beta.5",
5
5
  "description": "ClamAV virus scanning plugin for Uploadista Flow",
6
6
  "license": "MIT",
7
7
  "author": "Uploadista",
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "clamscan": "^2.3.3",
18
- "@uploadista/core": "0.0.20",
19
- "@uploadista/observability": "0.0.20"
18
+ "@uploadista/core": "0.1.0-beta.5",
19
+ "@uploadista/observability": "0.1.0-beta.5"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "effect": "^3.0.0",
@@ -24,19 +24,19 @@
24
24
  },
25
25
  "devDependencies": {
26
26
  "@effect/vitest": "0.27.0",
27
- "@types/node": "24.10.4",
28
- "effect": "3.19.12",
29
- "tsdown": "0.18.0",
30
- "vitest": "4.0.15",
31
- "zod": "4.2.0",
32
- "@uploadista/typescript-config": "0.0.20"
27
+ "@types/node": "24.10.8",
28
+ "effect": "3.19.14",
29
+ "tsdown": "0.19.0",
30
+ "vitest": "4.0.17",
31
+ "zod": "4.3.5",
32
+ "@uploadista/typescript-config": "0.1.0-beta.5"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc --noEmit && tsdown",
36
36
  "format": "biome format --write ./src",
37
37
  "lint": "biome lint --write ./src",
38
38
  "check": "biome check --write ./src",
39
- "test": "vitest",
39
+ "test": "vitest run",
40
40
  "test:run": "vitest run",
41
41
  "test:watch": "vitest watch"
42
42
  }
@@ -0,0 +1,435 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { Effect } from "effect";
3
+ import { VirusScanPlugin } from "@uploadista/core/flow";
4
+ import { virusScanPlugin, type VirusScanPluginConfig } from "../src/clamscan-plugin";
5
+
6
+ // Shared mock state
7
+ let mockIsInfectedResult: { isInfected: boolean; file: string; viruses: string[] | null } = {
8
+ isInfected: false,
9
+ file: "/tmp/test-file",
10
+ viruses: [],
11
+ };
12
+ let mockVersionResult: { version: string | undefined } = { version: "ClamAV 1.0.0" };
13
+ let mockInitShouldFail = false;
14
+ let mockIsInfectedShouldFail = false;
15
+ let mockGetVersionShouldFail = false;
16
+
17
+ // Mock the clamscan module
18
+ vi.mock("clamscan", () => {
19
+ return {
20
+ default: class MockNodeClam {
21
+ async init() {
22
+ if (mockInitShouldFail) {
23
+ throw new Error("ClamAV not found");
24
+ }
25
+ return this;
26
+ }
27
+ async isInfected() {
28
+ if (mockIsInfectedShouldFail) {
29
+ throw new Error("Scan timeout");
30
+ }
31
+ return mockIsInfectedResult;
32
+ }
33
+ async getVersion() {
34
+ if (mockGetVersionShouldFail) {
35
+ throw new Error("Connection refused");
36
+ }
37
+ return mockVersionResult;
38
+ }
39
+ },
40
+ };
41
+ });
42
+
43
+ describe("ClamScan Virus Plugin", () => {
44
+ beforeEach(() => {
45
+ // Reset mock state before each test
46
+ mockIsInfectedResult = {
47
+ isInfected: false,
48
+ file: "/tmp/test-file",
49
+ viruses: [],
50
+ };
51
+ mockVersionResult = { version: "ClamAV 1.0.0" };
52
+ mockInitShouldFail = false;
53
+ mockIsInfectedShouldFail = false;
54
+ mockGetVersionShouldFail = false;
55
+ });
56
+
57
+ describe("virusScanPlugin factory", () => {
58
+ it("should create a plugin layer with default configuration", () => {
59
+ const layer = virusScanPlugin();
60
+ expect(layer).toBeDefined();
61
+ });
62
+
63
+ it("should create a plugin layer with custom configuration", () => {
64
+ const config: VirusScanPluginConfig = {
65
+ preference: "clamscan",
66
+ clamdscan_socket: "/custom/socket/path",
67
+ clamdscan_host: "localhost",
68
+ clamdscan_port: 3311,
69
+ remove_infected: false,
70
+ debug_mode: true,
71
+ };
72
+ const layer = virusScanPlugin(config);
73
+ expect(layer).toBeDefined();
74
+ });
75
+ });
76
+
77
+ describe("scan method", () => {
78
+ it("should return clean result for non-infected file", async () => {
79
+ mockIsInfectedResult = {
80
+ isInfected: false,
81
+ file: "/tmp/test-file",
82
+ viruses: [],
83
+ };
84
+
85
+ const program = Effect.gen(function* () {
86
+ const plugin = yield* VirusScanPlugin;
87
+ const fileData = new TextEncoder().encode("clean file content");
88
+ return yield* plugin.scan(fileData);
89
+ }).pipe(Effect.provide(virusScanPlugin()));
90
+
91
+ const result = await Effect.runPromise(program);
92
+ expect(result.isClean).toBe(true);
93
+ expect(result.detectedViruses).toEqual([]);
94
+ });
95
+
96
+ it("should return infected result with virus names", async () => {
97
+ mockIsInfectedResult = {
98
+ isInfected: true,
99
+ file: "/tmp/test-file",
100
+ viruses: ["Eicar-Test-Signature", "Win.Trojan.Generic"],
101
+ };
102
+
103
+ const program = Effect.gen(function* () {
104
+ const plugin = yield* VirusScanPlugin;
105
+ const fileData = new TextEncoder().encode("infected file content");
106
+ return yield* plugin.scan(fileData);
107
+ }).pipe(Effect.provide(virusScanPlugin()));
108
+
109
+ const result = await Effect.runPromise(program);
110
+ expect(result.isClean).toBe(false);
111
+ expect(result.detectedViruses).toEqual(["Eicar-Test-Signature", "Win.Trojan.Generic"]);
112
+ });
113
+
114
+ it("should handle null virus array for infected files", async () => {
115
+ mockIsInfectedResult = {
116
+ isInfected: true,
117
+ file: "/tmp/test-file",
118
+ viruses: null,
119
+ };
120
+
121
+ const program = Effect.gen(function* () {
122
+ const plugin = yield* VirusScanPlugin;
123
+ const fileData = new TextEncoder().encode("infected file");
124
+ return yield* plugin.scan(fileData);
125
+ }).pipe(Effect.provide(virusScanPlugin()));
126
+
127
+ const result = await Effect.runPromise(program);
128
+ expect(result.isClean).toBe(false);
129
+ expect(result.detectedViruses).toEqual([]);
130
+ });
131
+
132
+ it("should handle binary data correctly", async () => {
133
+ mockIsInfectedResult = {
134
+ isInfected: false,
135
+ file: "/tmp/test-file",
136
+ viruses: [],
137
+ };
138
+
139
+ const program = Effect.gen(function* () {
140
+ const plugin = yield* VirusScanPlugin;
141
+ // Binary data that isn't valid UTF-8
142
+ const binaryData = new Uint8Array([0x00, 0xff, 0xfe, 0x89, 0x50, 0x4e, 0x47]);
143
+ return yield* plugin.scan(binaryData);
144
+ }).pipe(Effect.provide(virusScanPlugin()));
145
+
146
+ const result = await Effect.runPromise(program);
147
+ expect(result.isClean).toBe(true);
148
+ });
149
+
150
+ it("should handle large file data", async () => {
151
+ mockIsInfectedResult = {
152
+ isInfected: false,
153
+ file: "/tmp/test-file",
154
+ viruses: [],
155
+ };
156
+
157
+ const program = Effect.gen(function* () {
158
+ const plugin = yield* VirusScanPlugin;
159
+ // 1MB of data
160
+ const largeData = new Uint8Array(1024 * 1024).fill(0x41);
161
+ return yield* plugin.scan(largeData);
162
+ }).pipe(Effect.provide(virusScanPlugin()));
163
+
164
+ const result = await Effect.runPromise(program);
165
+ expect(result.isClean).toBe(true);
166
+ });
167
+ });
168
+
169
+ describe("getVersion method", () => {
170
+ it("should return ClamAV version string", async () => {
171
+ mockVersionResult = {
172
+ version: "ClamAV 1.0.0/26789/Tue Jan 14 09:00:00 2025",
173
+ };
174
+
175
+ const program = Effect.gen(function* () {
176
+ const plugin = yield* VirusScanPlugin;
177
+ return yield* plugin.getVersion();
178
+ }).pipe(Effect.provide(virusScanPlugin()));
179
+
180
+ const result = await Effect.runPromise(program);
181
+ expect(result).toBe("ClamAV 1.0.0/26789/Tue Jan 14 09:00:00 2025");
182
+ });
183
+
184
+ it("should return 'Unknown' when version is undefined", async () => {
185
+ mockVersionResult = {
186
+ version: undefined,
187
+ };
188
+
189
+ const program = Effect.gen(function* () {
190
+ const plugin = yield* VirusScanPlugin;
191
+ return yield* plugin.getVersion();
192
+ }).pipe(Effect.provide(virusScanPlugin()));
193
+
194
+ const result = await Effect.runPromise(program);
195
+ expect(result).toBe("Unknown");
196
+ });
197
+
198
+ it("should return 'Unknown' when version is empty", async () => {
199
+ mockVersionResult = {
200
+ version: "",
201
+ };
202
+
203
+ const program = Effect.gen(function* () {
204
+ const plugin = yield* VirusScanPlugin;
205
+ return yield* plugin.getVersion();
206
+ }).pipe(Effect.provide(virusScanPlugin()));
207
+
208
+ const result = await Effect.runPromise(program);
209
+ expect(result).toBe("Unknown");
210
+ });
211
+ });
212
+
213
+ describe("error handling", () => {
214
+ it("should fail with CLAMAV_NOT_INSTALLED when initialization fails", async () => {
215
+ mockInitShouldFail = true;
216
+
217
+ const program = Effect.gen(function* () {
218
+ const plugin = yield* VirusScanPlugin;
219
+ const fileData = new TextEncoder().encode("test");
220
+ return yield* plugin.scan(fileData);
221
+ }).pipe(Effect.provide(virusScanPlugin()));
222
+
223
+ const result = await Effect.runPromise(Effect.either(program));
224
+ expect(result._tag).toBe("Left");
225
+ if (result._tag === "Left") {
226
+ expect(result.left.code).toBe("CLAMAV_NOT_INSTALLED");
227
+ }
228
+ });
229
+
230
+ it("should fail with VIRUS_SCAN_FAILED when scan fails", async () => {
231
+ mockIsInfectedShouldFail = true;
232
+
233
+ const program = Effect.gen(function* () {
234
+ const plugin = yield* VirusScanPlugin;
235
+ const fileData = new TextEncoder().encode("test content");
236
+ return yield* plugin.scan(fileData);
237
+ }).pipe(Effect.provide(virusScanPlugin()));
238
+
239
+ const result = await Effect.runPromise(Effect.either(program));
240
+ expect(result._tag).toBe("Left");
241
+ if (result._tag === "Left") {
242
+ expect(result.left.code).toBe("VIRUS_SCAN_FAILED");
243
+ }
244
+ });
245
+
246
+ it("should fail with VIRUS_SCAN_FAILED when getVersion fails", async () => {
247
+ mockGetVersionShouldFail = true;
248
+
249
+ const program = Effect.gen(function* () {
250
+ const plugin = yield* VirusScanPlugin;
251
+ return yield* plugin.getVersion();
252
+ }).pipe(Effect.provide(virusScanPlugin()));
253
+
254
+ const result = await Effect.runPromise(Effect.either(program));
255
+ expect(result._tag).toBe("Left");
256
+ if (result._tag === "Left") {
257
+ expect(result.left.code).toBe("VIRUS_SCAN_FAILED");
258
+ }
259
+ });
260
+ });
261
+
262
+ describe("configuration options", () => {
263
+ it("should accept clamdscan preference", () => {
264
+ const layer = virusScanPlugin({ preference: "clamdscan" });
265
+ expect(layer).toBeDefined();
266
+ });
267
+
268
+ it("should accept clamscan preference", () => {
269
+ const layer = virusScanPlugin({ preference: "clamscan" });
270
+ expect(layer).toBeDefined();
271
+ });
272
+
273
+ it("should accept socket configuration for clamd", () => {
274
+ const layer = virusScanPlugin({
275
+ preference: "clamdscan",
276
+ clamdscan_socket: "/var/run/clamav/clamd.ctl",
277
+ });
278
+ expect(layer).toBeDefined();
279
+ });
280
+
281
+ it("should accept TCP host and port for clamd", () => {
282
+ const layer = virusScanPlugin({
283
+ preference: "clamdscan",
284
+ clamdscan_host: "127.0.0.1",
285
+ clamdscan_port: 3310,
286
+ });
287
+ expect(layer).toBeDefined();
288
+ });
289
+
290
+ it("should accept debug mode configuration", () => {
291
+ const layer = virusScanPlugin({
292
+ debug_mode: true,
293
+ });
294
+ expect(layer).toBeDefined();
295
+ });
296
+
297
+ it("should accept remove_infected configuration", () => {
298
+ const layer = virusScanPlugin({
299
+ remove_infected: false,
300
+ });
301
+ expect(layer).toBeDefined();
302
+ });
303
+ });
304
+
305
+ describe("EICAR test file detection", () => {
306
+ it("should detect EICAR test file as infected", async () => {
307
+ // EICAR is a standard antivirus test signature
308
+ const EICAR_SIGNATURE =
309
+ "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
310
+
311
+ mockIsInfectedResult = {
312
+ isInfected: true,
313
+ file: "/tmp/eicar-test",
314
+ viruses: ["Eicar-Test-Signature"],
315
+ };
316
+
317
+ const program = Effect.gen(function* () {
318
+ const plugin = yield* VirusScanPlugin;
319
+ const eicarData = new TextEncoder().encode(EICAR_SIGNATURE);
320
+ return yield* plugin.scan(eicarData);
321
+ }).pipe(Effect.provide(virusScanPlugin()));
322
+
323
+ const result = await Effect.runPromise(program);
324
+ expect(result.isClean).toBe(false);
325
+ expect(result.detectedViruses).toContain("Eicar-Test-Signature");
326
+ });
327
+ });
328
+
329
+ describe("temporary file handling", () => {
330
+ it("should clean up temporary files after successful scan", async () => {
331
+ mockIsInfectedResult = {
332
+ isInfected: false,
333
+ file: "/tmp/test-file",
334
+ viruses: [],
335
+ };
336
+
337
+ const program = Effect.gen(function* () {
338
+ const plugin = yield* VirusScanPlugin;
339
+ const fileData = new TextEncoder().encode("test content");
340
+ return yield* plugin.scan(fileData);
341
+ }).pipe(Effect.provide(virusScanPlugin()));
342
+
343
+ // The test verifies the scan completes - cleanup is handled internally
344
+ const result = await Effect.runPromise(program);
345
+ expect(result).toBeDefined();
346
+ expect(result.isClean).toBe(true);
347
+ });
348
+
349
+ it("should clean up temporary files even when scan fails", async () => {
350
+ mockIsInfectedShouldFail = true;
351
+
352
+ const program = Effect.gen(function* () {
353
+ const plugin = yield* VirusScanPlugin;
354
+ const fileData = new TextEncoder().encode("test content");
355
+ return yield* plugin.scan(fileData);
356
+ }).pipe(Effect.provide(virusScanPlugin()));
357
+
358
+ // Scan should fail but temp file cleanup should still happen
359
+ const result = await Effect.runPromise(Effect.either(program));
360
+ expect(result._tag).toBe("Left");
361
+ });
362
+ });
363
+
364
+ describe("lazy initialization", () => {
365
+ it("should initialize scanner lazily on first use", async () => {
366
+ mockIsInfectedResult = {
367
+ isInfected: false,
368
+ file: "/tmp/test",
369
+ viruses: [],
370
+ };
371
+
372
+ const layer = virusScanPlugin();
373
+
374
+ // First scan initializes the scanner
375
+ const program1 = Effect.gen(function* () {
376
+ const plugin = yield* VirusScanPlugin;
377
+ return yield* plugin.scan(new TextEncoder().encode("test1"));
378
+ }).pipe(Effect.provide(layer));
379
+
380
+ await Effect.runPromise(program1);
381
+
382
+ // Second scan reuses the initialized scanner (same layer instance)
383
+ const program2 = Effect.gen(function* () {
384
+ const plugin = yield* VirusScanPlugin;
385
+ return yield* plugin.scan(new TextEncoder().encode("test2"));
386
+ }).pipe(Effect.provide(layer));
387
+
388
+ const result = await Effect.runPromise(program2);
389
+ expect(result.isClean).toBe(true);
390
+ });
391
+ });
392
+
393
+ describe("multiple virus detection", () => {
394
+ it("should report multiple viruses when detected", async () => {
395
+ mockIsInfectedResult = {
396
+ isInfected: true,
397
+ file: "/tmp/multi-virus",
398
+ viruses: ["Trojan.Generic", "Worm.Mydoom", "Virus.Sality"],
399
+ };
400
+
401
+ const program = Effect.gen(function* () {
402
+ const plugin = yield* VirusScanPlugin;
403
+ const fileData = new TextEncoder().encode("malicious content");
404
+ return yield* plugin.scan(fileData);
405
+ }).pipe(Effect.provide(virusScanPlugin()));
406
+
407
+ const result = await Effect.runPromise(program);
408
+ expect(result.isClean).toBe(false);
409
+ expect(result.detectedViruses).toHaveLength(3);
410
+ expect(result.detectedViruses).toContain("Trojan.Generic");
411
+ expect(result.detectedViruses).toContain("Worm.Mydoom");
412
+ expect(result.detectedViruses).toContain("Virus.Sality");
413
+ });
414
+ });
415
+
416
+ describe("empty file handling", () => {
417
+ it("should handle empty file input", async () => {
418
+ mockIsInfectedResult = {
419
+ isInfected: false,
420
+ file: "/tmp/empty-file",
421
+ viruses: [],
422
+ };
423
+
424
+ const program = Effect.gen(function* () {
425
+ const plugin = yield* VirusScanPlugin;
426
+ const emptyData = new Uint8Array(0);
427
+ return yield* plugin.scan(emptyData);
428
+ }).pipe(Effect.provide(virusScanPlugin()));
429
+
430
+ const result = await Effect.runPromise(program);
431
+ expect(result.isClean).toBe(true);
432
+ expect(result.detectedViruses).toEqual([]);
433
+ });
434
+ });
435
+ });