@uploadista/flow-security-clamscan 0.0.20 → 0.1.0
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.
- package/.turbo/turbo-build.log +7 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- package/tests/clamscan-plugin.test.ts +435 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
|
|
2
|
-
> @uploadista/flow-security-clamscan@0.0
|
|
2
|
+
> @uploadista/flow-security-clamscan@0.1.0 build /home/runner/work/uploadista-sdk/uploadista-sdk/packages/flow/security/clamscan
|
|
3
3
|
> tsc --noEmit && tsdown
|
|
4
4
|
|
|
5
|
-
[34mℹ[39m tsdown [2mv0.
|
|
6
|
-
[34mℹ[39m config file: [4m/
|
|
5
|
+
[34mℹ[39m tsdown [2mv0.19.0[22m powered by rolldown [2mv1.0.0-beta.59[22m
|
|
6
|
+
[34mℹ[39m config file: [4m/home/runner/work/uploadista-sdk/uploadista-sdk/packages/flow/security/clamscan/tsdown.config.ts[24m
|
|
7
7
|
[34mℹ[39m entry: [34msrc/index.ts[39m
|
|
8
8
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
9
9
|
[34mℹ[39m Build start
|
|
10
|
-
[34mℹ[39m Cleaning 7 files
|
|
11
10
|
[34mℹ[39m [33m[CJS][39m [2mdist/[22m[1mindex.cjs[22m [2m2.89 kB[22m [2m│ gzip: 1.22 kB[22m
|
|
12
11
|
[34mℹ[39m [33m[CJS][39m 1 files, total: 2.89 kB
|
|
13
12
|
[34mℹ[39m [34m[ESM][39m [2mdist/[22m[1mindex.mjs[22m [2m2.27 kB[22m [2m│ gzip: 0.97 kB[22m
|
|
14
|
-
[34mℹ[39m [34m[ESM][39m [2mdist/[22mindex.mjs.map [2m9.
|
|
13
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22mindex.mjs.map [2m9.40 kB[22m [2m│ gzip: 2.93 kB[22m
|
|
15
14
|
[34mℹ[39m [34m[ESM][39m [2mdist/[22mindex.d.mts.map [2m0.25 kB[22m [2m│ gzip: 0.17 kB[22m
|
|
16
15
|
[34mℹ[39m [34m[ESM][39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m1.68 kB[22m [2m│ gzip: 0.73 kB[22m
|
|
17
|
-
[34mℹ[39m [34m[ESM][39m 4 files, total: 13.
|
|
18
|
-
[32m✔[39m Build complete in [
|
|
16
|
+
[34mℹ[39m [34m[ESM][39m 4 files, total: 13.59 kB
|
|
17
|
+
[32m✔[39m Build complete in [32m27670ms[39m
|
|
19
18
|
[34mℹ[39m [33m[CJS][39m [2mdist/[22mindex.d.cts.map [2m0.25 kB[22m [2m│ gzip: 0.17 kB[22m
|
|
20
19
|
[34mℹ[39m [33m[CJS][39m [2mdist/[22m[32m[1mindex.d.cts[22m[39m [2m1.68 kB[22m [2m│ gzip: 0.73 kB[22m
|
|
21
20
|
[34mℹ[39m [33m[CJS][39m 2 files, total: 1.92 kB
|
|
22
|
-
[32m✔[39m Build complete in [
|
|
21
|
+
[32m✔[39m Build complete in [32m27669ms[39m
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[
|
|
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
|
|
4
|
+
"version": "0.1.0",
|
|
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
|
|
19
|
-
"@uploadista/observability": "0.0
|
|
18
|
+
"@uploadista/core": "0.1.0",
|
|
19
|
+
"@uploadista/observability": "0.1.0"
|
|
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.
|
|
28
|
-
"effect": "3.19.
|
|
29
|
-
"tsdown": "0.
|
|
30
|
-
"vitest": "4.0.
|
|
31
|
-
"zod": "4.
|
|
32
|
-
"@uploadista/typescript-config": "0.0
|
|
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"
|
|
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
|
+
});
|