@vizzly-testing/cli 0.5.0 → 0.6.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/README.md +15 -9
- package/dist/commands/run.js +57 -18
- package/dist/commands/tdd.js +6 -13
- package/dist/server/handlers/tdd-handler.js +82 -8
- package/dist/services/html-report-generator.js +377 -0
- package/dist/services/report-generator/report.css +355 -0
- package/dist/services/report-generator/viewer.js +100 -0
- package/dist/services/server-manager.js +3 -2
- package/dist/services/tdd-service.js +375 -66
- package/dist/services/test-runner.js +54 -27
- package/dist/types/server/handlers/tdd-handler.d.ts +18 -1
- package/dist/types/services/html-report-generator.d.ts +52 -0
- package/dist/types/services/report-generator/viewer.d.ts +0 -0
- package/dist/types/services/server-manager.d.ts +19 -1
- package/dist/types/services/tdd-service.d.ts +24 -3
- package/dist/types/utils/config-loader.d.ts +3 -0
- package/dist/types/utils/security.d.ts +29 -0
- package/dist/utils/config-loader.js +7 -0
- package/dist/utils/security.js +154 -0
- package/docs/tdd-mode.md +58 -12
- package/package.json +3 -2
|
@@ -39,10 +39,11 @@ export class TestRunner extends BaseService {
|
|
|
39
39
|
screenshotsCaptured: 0
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
let buildUrl = null;
|
|
43
|
+
let screenshotCount = 0;
|
|
44
|
+
let testSuccess = false;
|
|
45
|
+
let testError = null;
|
|
42
46
|
try {
|
|
43
|
-
let buildUrl = null;
|
|
44
|
-
let screenshotCount = 0;
|
|
45
|
-
|
|
46
47
|
// Create build based on mode
|
|
47
48
|
buildId = await this.createBuild(options, tdd);
|
|
48
49
|
if (!tdd && buildId) {
|
|
@@ -62,7 +63,7 @@ export class TestRunner extends BaseService {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// Start server with appropriate handler
|
|
65
|
-
await this.serverManager.start(buildId, tdd);
|
|
66
|
+
await this.serverManager.start(buildId, tdd, options.setBaseline);
|
|
66
67
|
|
|
67
68
|
// Forward server events
|
|
68
69
|
if (this.serverManager.server?.emitter) {
|
|
@@ -78,28 +79,46 @@ export class TestRunner extends BaseService {
|
|
|
78
79
|
VIZZLY_ENABLED: 'true',
|
|
79
80
|
VIZZLY_SET_BASELINE: options.setBaseline || options['set-baseline'] ? 'true' : 'false'
|
|
80
81
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
url: buildUrl,
|
|
89
|
-
testsPassed: 1,
|
|
90
|
-
testsFailed: 0,
|
|
91
|
-
screenshotsCaptured: screenshotCount
|
|
92
|
-
};
|
|
82
|
+
try {
|
|
83
|
+
await this.executeTestCommand(testCommand, env);
|
|
84
|
+
testSuccess = true;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
testError = error;
|
|
87
|
+
testSuccess = false;
|
|
88
|
+
}
|
|
93
89
|
} catch (error) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const executionTime = Date.now() - startTime;
|
|
98
|
-
await this.finalizeBuild(buildId, tdd, false, executionTime);
|
|
99
|
-
throw error;
|
|
90
|
+
// Error in setup phase
|
|
91
|
+
testError = error;
|
|
92
|
+
testSuccess = false;
|
|
100
93
|
} finally {
|
|
101
|
-
|
|
94
|
+
// Always finalize the build and stop the server
|
|
95
|
+
const executionTime = Date.now() - startTime;
|
|
96
|
+
if (buildId) {
|
|
97
|
+
try {
|
|
98
|
+
await this.finalizeBuild(buildId, tdd, testSuccess, executionTime);
|
|
99
|
+
} catch (finalizeError) {
|
|
100
|
+
this.logger.error('Failed to finalize build:', finalizeError);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await this.serverManager.stop();
|
|
105
|
+
} catch (stopError) {
|
|
106
|
+
this.logger.error('Failed to stop server:', stopError);
|
|
107
|
+
}
|
|
102
108
|
}
|
|
109
|
+
|
|
110
|
+
// If there was a test error, throw it now (after cleanup)
|
|
111
|
+
if (testError) {
|
|
112
|
+
this.logger.error('Test run failed:', testError);
|
|
113
|
+
throw testError;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
buildId: buildId,
|
|
117
|
+
url: buildUrl,
|
|
118
|
+
testsPassed: testSuccess ? 1 : 0,
|
|
119
|
+
testsFailed: testSuccess ? 0 : 1,
|
|
120
|
+
screenshotsCaptured: screenshotCount
|
|
121
|
+
};
|
|
103
122
|
}
|
|
104
123
|
async createBuild(options, tdd) {
|
|
105
124
|
if (tdd) {
|
|
@@ -190,8 +209,11 @@ export class TestRunner extends BaseService {
|
|
|
190
209
|
this.testProcess.on('error', error => {
|
|
191
210
|
reject(new VizzlyError(`Failed to run test command: ${error.message}`), 'TEST_COMMAND_FAILED');
|
|
192
211
|
});
|
|
193
|
-
this.testProcess.on('exit', code => {
|
|
194
|
-
|
|
212
|
+
this.testProcess.on('exit', (code, signal) => {
|
|
213
|
+
// If process was killed by SIGINT, treat as interruption
|
|
214
|
+
if (signal === 'SIGINT') {
|
|
215
|
+
reject(new VizzlyError('Test command was interrupted', 'TEST_COMMAND_INTERRUPTED'));
|
|
216
|
+
} else if (code !== 0) {
|
|
195
217
|
reject(new VizzlyError(`Test command exited with code ${code}`, 'TEST_COMMAND_FAILED'));
|
|
196
218
|
} else {
|
|
197
219
|
resolve();
|
|
@@ -200,8 +222,13 @@ export class TestRunner extends BaseService {
|
|
|
200
222
|
});
|
|
201
223
|
}
|
|
202
224
|
async cancel() {
|
|
203
|
-
if (this.testProcess) {
|
|
204
|
-
this.testProcess.kill('
|
|
225
|
+
if (this.testProcess && !this.testProcess.killed) {
|
|
226
|
+
this.testProcess.kill('SIGKILL');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Stop server manager if running
|
|
230
|
+
if (this.serverManager) {
|
|
231
|
+
await this.serverManager.stop();
|
|
205
232
|
}
|
|
206
233
|
}
|
|
207
234
|
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
export function createTddHandler(config: any, workingDir: any, baselineBuild: any, baselineComparison: any): {
|
|
1
|
+
export function createTddHandler(config: any, workingDir: any, baselineBuild: any, baselineComparison: any, setBaseline?: boolean): {
|
|
2
2
|
initialize: () => Promise<void>;
|
|
3
3
|
registerBuild: (buildId: any) => void;
|
|
4
4
|
handleScreenshot: (buildId: any, name: any, image: any, properties?: {}) => Promise<{
|
|
5
|
+
statusCode: number;
|
|
6
|
+
body: {
|
|
7
|
+
error: string;
|
|
8
|
+
details: any;
|
|
9
|
+
tddMode: boolean;
|
|
10
|
+
comparison?: undefined;
|
|
11
|
+
status?: undefined;
|
|
12
|
+
message?: undefined;
|
|
13
|
+
success?: undefined;
|
|
14
|
+
};
|
|
15
|
+
} | {
|
|
5
16
|
statusCode: number;
|
|
6
17
|
body: {
|
|
7
18
|
error: string;
|
|
@@ -12,6 +23,8 @@ export function createTddHandler(config: any, workingDir: any, baselineBuild: an
|
|
|
12
23
|
baseline: any;
|
|
13
24
|
current: any;
|
|
14
25
|
diff: any;
|
|
26
|
+
diffPercentage: any;
|
|
27
|
+
threshold: any;
|
|
15
28
|
};
|
|
16
29
|
tddMode: boolean;
|
|
17
30
|
status?: undefined;
|
|
@@ -29,6 +42,8 @@ export function createTddHandler(config: any, workingDir: any, baselineBuild: an
|
|
|
29
42
|
baseline: any;
|
|
30
43
|
current: any;
|
|
31
44
|
diff?: undefined;
|
|
45
|
+
diffPercentage?: undefined;
|
|
46
|
+
threshold?: undefined;
|
|
32
47
|
};
|
|
33
48
|
tddMode: boolean;
|
|
34
49
|
error?: undefined;
|
|
@@ -56,6 +71,8 @@ export function createTddHandler(config: any, workingDir: any, baselineBuild: an
|
|
|
56
71
|
baseline?: undefined;
|
|
57
72
|
current?: undefined;
|
|
58
73
|
diff?: undefined;
|
|
74
|
+
diffPercentage?: undefined;
|
|
75
|
+
threshold?: undefined;
|
|
59
76
|
};
|
|
60
77
|
tddMode: boolean;
|
|
61
78
|
error?: undefined;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export class HtmlReportGenerator {
|
|
2
|
+
constructor(workingDir: any, config: any);
|
|
3
|
+
workingDir: any;
|
|
4
|
+
config: any;
|
|
5
|
+
reportDir: string;
|
|
6
|
+
reportPath: string;
|
|
7
|
+
cssPath: string;
|
|
8
|
+
/**
|
|
9
|
+
* Sanitize HTML content to prevent XSS attacks
|
|
10
|
+
* @param {string} text - Text to sanitize
|
|
11
|
+
* @returns {string} Sanitized text
|
|
12
|
+
*/
|
|
13
|
+
sanitizeHtml(text: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Sanitize build info object
|
|
16
|
+
* @param {Object} buildInfo - Build information to sanitize
|
|
17
|
+
* @returns {Object} Sanitized build info
|
|
18
|
+
*/
|
|
19
|
+
sanitizeBuildInfo(buildInfo?: any): any;
|
|
20
|
+
/**
|
|
21
|
+
* Generate HTML report from TDD results
|
|
22
|
+
* @param {Object} results - TDD comparison results
|
|
23
|
+
* @param {Object} buildInfo - Build information
|
|
24
|
+
* @returns {string} Path to generated report
|
|
25
|
+
*/
|
|
26
|
+
generateReport(results: any, buildInfo?: any): string;
|
|
27
|
+
/**
|
|
28
|
+
* Process comparison data for HTML report
|
|
29
|
+
* @param {Object} comparison - Comparison object
|
|
30
|
+
* @returns {Object} Processed comparison data
|
|
31
|
+
*/
|
|
32
|
+
processComparison(comparison: any): any;
|
|
33
|
+
/**
|
|
34
|
+
* Get relative path from report directory to image file
|
|
35
|
+
* @param {string} imagePath - Absolute path to image
|
|
36
|
+
* @param {string} reportDir - Report directory path
|
|
37
|
+
* @returns {string|null} Relative path or null if invalid
|
|
38
|
+
*/
|
|
39
|
+
getRelativePath(imagePath: string, reportDir: string): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Generate the complete HTML template
|
|
42
|
+
* @param {Object} data - Report data
|
|
43
|
+
* @returns {string} HTML content
|
|
44
|
+
*/
|
|
45
|
+
generateHtmlTemplate(data: any): string;
|
|
46
|
+
/**
|
|
47
|
+
* Generate HTML for a single comparison
|
|
48
|
+
* @param {Object} comparison - Comparison data
|
|
49
|
+
* @returns {string} HTML content
|
|
50
|
+
*/
|
|
51
|
+
generateComparisonHtml(comparison: any): string;
|
|
52
|
+
}
|
|
File without changes
|
|
@@ -9,6 +9,17 @@ export class ServerManager extends BaseService {
|
|
|
9
9
|
initialize: () => Promise<void>;
|
|
10
10
|
registerBuild: (buildId: any) => void;
|
|
11
11
|
handleScreenshot: (buildId: any, name: any, image: any, properties?: {}) => Promise<{
|
|
12
|
+
statusCode: number;
|
|
13
|
+
body: {
|
|
14
|
+
error: string;
|
|
15
|
+
details: any;
|
|
16
|
+
tddMode: boolean;
|
|
17
|
+
comparison?: undefined;
|
|
18
|
+
status?: undefined;
|
|
19
|
+
message?: undefined;
|
|
20
|
+
success?: undefined;
|
|
21
|
+
};
|
|
22
|
+
} | {
|
|
12
23
|
statusCode: number;
|
|
13
24
|
body: {
|
|
14
25
|
error: string;
|
|
@@ -19,6 +30,8 @@ export class ServerManager extends BaseService {
|
|
|
19
30
|
baseline: any;
|
|
20
31
|
current: any;
|
|
21
32
|
diff: any;
|
|
33
|
+
diffPercentage: any;
|
|
34
|
+
threshold: any;
|
|
22
35
|
};
|
|
23
36
|
tddMode: boolean;
|
|
24
37
|
status?: undefined;
|
|
@@ -36,6 +49,8 @@ export class ServerManager extends BaseService {
|
|
|
36
49
|
baseline: any;
|
|
37
50
|
current: any;
|
|
38
51
|
diff?: undefined;
|
|
52
|
+
diffPercentage?: undefined;
|
|
53
|
+
threshold?: undefined;
|
|
39
54
|
};
|
|
40
55
|
tddMode: boolean;
|
|
41
56
|
error?: undefined;
|
|
@@ -63,6 +78,8 @@ export class ServerManager extends BaseService {
|
|
|
63
78
|
baseline?: undefined;
|
|
64
79
|
current?: undefined;
|
|
65
80
|
diff?: undefined;
|
|
81
|
+
diffPercentage?: undefined;
|
|
82
|
+
threshold?: undefined;
|
|
66
83
|
};
|
|
67
84
|
tddMode: boolean;
|
|
68
85
|
error?: undefined;
|
|
@@ -139,9 +156,10 @@ export class ServerManager extends BaseService {
|
|
|
139
156
|
cleanup: () => void;
|
|
140
157
|
};
|
|
141
158
|
emitter: EventEmitter<[never]>;
|
|
142
|
-
start(buildId?: any, tddMode?: boolean): Promise<void>;
|
|
159
|
+
start(buildId?: any, tddMode?: boolean, setBaseline?: boolean): Promise<void>;
|
|
143
160
|
buildId: any;
|
|
144
161
|
tddMode: boolean;
|
|
162
|
+
setBaseline: boolean;
|
|
145
163
|
createApiService(): Promise<import("./api-service.js").ApiService>;
|
|
146
164
|
get server(): {
|
|
147
165
|
emitter: EventEmitter<[never]>;
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export function createTDDService(config: any, options?: {}): TddService;
|
|
5
5
|
export class TddService {
|
|
6
|
-
constructor(config: any, workingDir?: string);
|
|
6
|
+
constructor(config: any, workingDir?: string, setBaseline?: boolean);
|
|
7
7
|
config: any;
|
|
8
|
+
setBaseline: boolean;
|
|
8
9
|
api: ApiService;
|
|
9
10
|
workingDir: string;
|
|
10
11
|
baselinePath: string;
|
|
@@ -14,6 +15,11 @@ export class TddService {
|
|
|
14
15
|
comparisons: any[];
|
|
15
16
|
threshold: any;
|
|
16
17
|
downloadBaselines(environment?: string, branch?: any, buildId?: any, comparisonId?: any): Promise<any>;
|
|
18
|
+
/**
|
|
19
|
+
* Handle local baseline logic (either load existing or prepare for new baselines)
|
|
20
|
+
* @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
|
|
21
|
+
*/
|
|
22
|
+
handleLocalBaselines(): Promise<any | null>;
|
|
17
23
|
loadBaseline(): Promise<any>;
|
|
18
24
|
compareScreenshot(name: any, imageBuffer: any, properties?: {}): Promise<{
|
|
19
25
|
name: any;
|
|
@@ -32,7 +38,7 @@ export class TddService {
|
|
|
32
38
|
comparisons: any[];
|
|
33
39
|
baseline: any;
|
|
34
40
|
};
|
|
35
|
-
printResults(): {
|
|
41
|
+
printResults(): Promise<{
|
|
36
42
|
total: number;
|
|
37
43
|
passed: number;
|
|
38
44
|
failed: number;
|
|
@@ -40,12 +46,27 @@ export class TddService {
|
|
|
40
46
|
errors: number;
|
|
41
47
|
comparisons: any[];
|
|
42
48
|
baseline: any;
|
|
43
|
-
}
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* Generate HTML report for TDD results
|
|
52
|
+
* @param {Object} results - TDD comparison results
|
|
53
|
+
*/
|
|
54
|
+
generateHtmlReport(results: any): Promise<string>;
|
|
55
|
+
/**
|
|
56
|
+
* Open HTML report in default browser
|
|
57
|
+
* @param {string} reportPath - Path to HTML report
|
|
58
|
+
*/
|
|
59
|
+
openReport(reportPath: string): Promise<void>;
|
|
44
60
|
/**
|
|
45
61
|
* Update baselines with current screenshots (accept changes)
|
|
46
62
|
* @returns {number} Number of baselines updated
|
|
47
63
|
*/
|
|
48
64
|
updateBaselines(): number;
|
|
65
|
+
/**
|
|
66
|
+
* Create a new baseline (used during --set-baseline mode)
|
|
67
|
+
* @private
|
|
68
|
+
*/
|
|
69
|
+
private createNewBaseline;
|
|
49
70
|
/**
|
|
50
71
|
* Update a single baseline with current screenshot
|
|
51
72
|
* @private
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes a screenshot name to prevent path traversal and ensure safe file naming
|
|
3
|
+
* @param {string} name - Original screenshot name
|
|
4
|
+
* @param {number} maxLength - Maximum allowed length (default: 255)
|
|
5
|
+
* @returns {string} Sanitized screenshot name
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizeScreenshotName(name: string, maxLength?: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* Validates that a path stays within the allowed working directory bounds
|
|
10
|
+
* @param {string} targetPath - Path to validate
|
|
11
|
+
* @param {string} workingDir - Working directory that serves as the security boundary
|
|
12
|
+
* @returns {string} Resolved and normalized path if valid
|
|
13
|
+
* @throws {Error} If path is invalid or outside bounds
|
|
14
|
+
*/
|
|
15
|
+
export function validatePathSecurity(targetPath: string, workingDir: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Safely constructs a path within the working directory
|
|
18
|
+
* @param {string} workingDir - Base working directory
|
|
19
|
+
* @param {...string} pathSegments - Path segments to join
|
|
20
|
+
* @returns {string} Safely constructed path
|
|
21
|
+
* @throws {Error} If resulting path would be outside working directory
|
|
22
|
+
*/
|
|
23
|
+
export function safePath(workingDir: string, ...pathSegments: string[]): string;
|
|
24
|
+
/**
|
|
25
|
+
* Validates screenshot properties object for safe values
|
|
26
|
+
* @param {Object} properties - Properties to validate
|
|
27
|
+
* @returns {Object} Validated properties object
|
|
28
|
+
*/
|
|
29
|
+
export function validateScreenshotProperties(properties?: any): any;
|
|
@@ -25,6 +25,10 @@ const DEFAULT_CONFIG = {
|
|
|
25
25
|
// Comparison Configuration
|
|
26
26
|
comparison: {
|
|
27
27
|
threshold: 0.1
|
|
28
|
+
},
|
|
29
|
+
// TDD Configuration
|
|
30
|
+
tdd: {
|
|
31
|
+
openReport: false // Whether to auto-open HTML report in browser
|
|
28
32
|
}
|
|
29
33
|
};
|
|
30
34
|
export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
@@ -42,6 +46,9 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
|
42
46
|
},
|
|
43
47
|
comparison: {
|
|
44
48
|
...DEFAULT_CONFIG.comparison
|
|
49
|
+
},
|
|
50
|
+
tdd: {
|
|
51
|
+
...DEFAULT_CONFIG.tdd
|
|
45
52
|
}
|
|
46
53
|
};
|
|
47
54
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security utilities for path sanitization and validation
|
|
3
|
+
* Protects against path traversal attacks and ensures safe file operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolve, normalize, isAbsolute, join } from 'path';
|
|
7
|
+
import { createServiceLogger } from './logger-factory.js';
|
|
8
|
+
const logger = createServiceLogger('SECURITY');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sanitizes a screenshot name to prevent path traversal and ensure safe file naming
|
|
12
|
+
* @param {string} name - Original screenshot name
|
|
13
|
+
* @param {number} maxLength - Maximum allowed length (default: 255)
|
|
14
|
+
* @returns {string} Sanitized screenshot name
|
|
15
|
+
*/
|
|
16
|
+
export function sanitizeScreenshotName(name, maxLength = 255) {
|
|
17
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
18
|
+
throw new Error('Screenshot name must be a non-empty string');
|
|
19
|
+
}
|
|
20
|
+
if (name.length > maxLength) {
|
|
21
|
+
throw new Error(`Screenshot name exceeds maximum length of ${maxLength} characters`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Block directory traversal patterns
|
|
25
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
26
|
+
throw new Error('Screenshot name contains invalid path characters');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Block absolute paths
|
|
30
|
+
if (isAbsolute(name)) {
|
|
31
|
+
throw new Error('Screenshot name cannot be an absolute path');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Allow only safe characters: alphanumeric, hyphens, underscores, and dots
|
|
35
|
+
// Replace other characters with underscores
|
|
36
|
+
let sanitized = name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
37
|
+
|
|
38
|
+
// Prevent names that start with dots (hidden files)
|
|
39
|
+
if (sanitized.startsWith('.')) {
|
|
40
|
+
sanitized = 'file_' + sanitized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Ensure we have a valid filename
|
|
44
|
+
if (sanitized.length === 0 || sanitized === '.' || sanitized === '..') {
|
|
45
|
+
sanitized = 'unnamed_screenshot';
|
|
46
|
+
}
|
|
47
|
+
return sanitized;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates that a path stays within the allowed working directory bounds
|
|
52
|
+
* @param {string} targetPath - Path to validate
|
|
53
|
+
* @param {string} workingDir - Working directory that serves as the security boundary
|
|
54
|
+
* @returns {string} Resolved and normalized path if valid
|
|
55
|
+
* @throws {Error} If path is invalid or outside bounds
|
|
56
|
+
*/
|
|
57
|
+
export function validatePathSecurity(targetPath, workingDir) {
|
|
58
|
+
if (typeof targetPath !== 'string' || targetPath.length === 0) {
|
|
59
|
+
throw new Error('Path must be a non-empty string');
|
|
60
|
+
}
|
|
61
|
+
if (typeof workingDir !== 'string' || workingDir.length === 0) {
|
|
62
|
+
throw new Error('Working directory must be a non-empty string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Normalize and resolve both paths
|
|
66
|
+
let resolvedWorkingDir = resolve(normalize(workingDir));
|
|
67
|
+
let resolvedTargetPath = resolve(normalize(targetPath));
|
|
68
|
+
|
|
69
|
+
// Ensure the target path starts with the working directory
|
|
70
|
+
if (!resolvedTargetPath.startsWith(resolvedWorkingDir)) {
|
|
71
|
+
logger.warn(`Path traversal attempt blocked: ${targetPath} (resolved: ${resolvedTargetPath}) is outside working directory: ${resolvedWorkingDir}`);
|
|
72
|
+
throw new Error('Path is outside the allowed working directory');
|
|
73
|
+
}
|
|
74
|
+
return resolvedTargetPath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Safely constructs a path within the working directory
|
|
79
|
+
* @param {string} workingDir - Base working directory
|
|
80
|
+
* @param {...string} pathSegments - Path segments to join
|
|
81
|
+
* @returns {string} Safely constructed path
|
|
82
|
+
* @throws {Error} If resulting path would be outside working directory
|
|
83
|
+
*/
|
|
84
|
+
export function safePath(workingDir, ...pathSegments) {
|
|
85
|
+
if (pathSegments.length === 0) {
|
|
86
|
+
return validatePathSecurity(workingDir, workingDir);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sanitize each path segment
|
|
90
|
+
let sanitizedSegments = pathSegments.map(segment => {
|
|
91
|
+
if (typeof segment !== 'string') {
|
|
92
|
+
throw new Error('Path segment must be a string');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Block directory traversal in segments
|
|
96
|
+
if (segment.includes('..')) {
|
|
97
|
+
throw new Error('Path segment contains directory traversal sequence');
|
|
98
|
+
}
|
|
99
|
+
return segment;
|
|
100
|
+
});
|
|
101
|
+
let targetPath = join(workingDir, ...sanitizedSegments);
|
|
102
|
+
return validatePathSecurity(targetPath, workingDir);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validates screenshot properties object for safe values
|
|
107
|
+
* @param {Object} properties - Properties to validate
|
|
108
|
+
* @returns {Object} Validated properties object
|
|
109
|
+
*/
|
|
110
|
+
export function validateScreenshotProperties(properties = {}) {
|
|
111
|
+
if (properties === null || typeof properties !== 'object') {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
let validated = {};
|
|
115
|
+
|
|
116
|
+
// Validate common properties with safe constraints
|
|
117
|
+
if (properties.browser && typeof properties.browser === 'string') {
|
|
118
|
+
try {
|
|
119
|
+
validated.browser = sanitizeScreenshotName(properties.browser, 50);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Skip invalid browser names, don't include them
|
|
122
|
+
logger.warn(`Invalid browser name '${properties.browser}': ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (properties.viewport && typeof properties.viewport === 'object') {
|
|
126
|
+
let viewport = {};
|
|
127
|
+
if (typeof properties.viewport.width === 'number' && properties.viewport.width > 0 && properties.viewport.width <= 10000) {
|
|
128
|
+
viewport.width = Math.floor(properties.viewport.width);
|
|
129
|
+
}
|
|
130
|
+
if (typeof properties.viewport.height === 'number' && properties.viewport.height > 0 && properties.viewport.height <= 10000) {
|
|
131
|
+
viewport.height = Math.floor(properties.viewport.height);
|
|
132
|
+
}
|
|
133
|
+
if (Object.keys(viewport).length > 0) {
|
|
134
|
+
validated.viewport = viewport;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Allow other safe string properties but sanitize them
|
|
139
|
+
for (let [key, value] of Object.entries(properties)) {
|
|
140
|
+
if (key === 'browser' || key === 'viewport') continue; // Already handled
|
|
141
|
+
|
|
142
|
+
if (typeof key === 'string' && key.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(key)) {
|
|
143
|
+
if (typeof value === 'string' && value.length <= 200) {
|
|
144
|
+
// Store sanitized version of string values
|
|
145
|
+
validated[key] = value.replace(/[<>&"']/g, ''); // Basic HTML entity prevention
|
|
146
|
+
} else if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
|
|
147
|
+
validated[key] = value;
|
|
148
|
+
} else if (typeof value === 'boolean') {
|
|
149
|
+
validated[key] = value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return validated;
|
|
154
|
+
}
|