docs: 補齊 JSDoc 與指令檔註解、測試移至 app/test 並重建 README #10
+1
-1
@@ -108,7 +108,7 @@ function cleanText(value) {
|
|||||||
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
|
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
|
||||||
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
|
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
|
||||||
*/
|
*/
|
||||||
function normalizeText(value) {
|
export function normalizeText(value) {
|
||||||
return cleanText(value)
|
return cleanText(value)
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
+2
-2
@@ -165,7 +165,7 @@ function stripOuterFence(text) {
|
|||||||
* @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。
|
* @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。
|
||||||
* @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。
|
* @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。
|
||||||
*/
|
*/
|
||||||
function extractBalancedJSON(text, startIndex) {
|
export function extractBalancedJSON(text, startIndex) {
|
||||||
const source = String(text);
|
const source = String(text);
|
||||||
const open = source[startIndex];
|
const open = source[startIndex];
|
||||||
const close = open === '{' ? '}' : ']';
|
const close = open === '{' ? '}' : ']';
|
||||||
@@ -208,7 +208,7 @@ function extractBalancedJSON(text, startIndex) {
|
|||||||
* @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。
|
* @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。
|
||||||
* @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。
|
* @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。
|
||||||
*/
|
*/
|
||||||
function extractJSONText(text) {
|
export function extractJSONText(text) {
|
||||||
const stripped = stripOuterFence(text);
|
const stripped = stripOuterFence(text);
|
||||||
try {
|
try {
|
||||||
JSON.parse(stripped);
|
JSON.parse(stripped);
|
||||||
|
|||||||
+1
-1
@@ -171,7 +171,7 @@ function toExclusion(botFinding) {
|
|||||||
* @param {string} p - 待檢查的檔案路徑。
|
* @param {string} p - 待檢查的檔案路徑。
|
||||||
* @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。
|
* @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。
|
||||||
*/
|
*/
|
||||||
function isSafeRepoPath(p) {
|
export function isSafeRepoPath(p) {
|
||||||
if (typeof p !== 'string' || p === '') return false;
|
if (typeof p !== 'string' || p === '') return false;
|
||||||
if (p.startsWith('/') || /^[a-zA-Z]:/.test(p)) return false;
|
if (p.startsWith('/') || /^[a-zA-Z]:/.test(p)) return false;
|
||||||
return !p.split('/').includes('..');
|
return !p.split('/').includes('..');
|
||||||
|
|||||||
@@ -420,3 +420,97 @@ describe('postFindingsReview', () => {
|
|||||||
assert.deepEqual(inlineCalls.map(c => `${c.path}:${c.line}`), ['app/a.js:5', 'app/b.js:9']);
|
assert.deepEqual(inlineCalls.map(c => `${c.path}:${c.line}`), ['app/a.js:5', 'app/b.js:9']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatFindingsStats markdown formatting', () => {
|
||||||
|
// 代表性輸入:critical/warning/info 混合,含 is_new:false(舊問題)與未分類等級(custom)
|
||||||
|
const mixedFindings = [
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'warning', is_new: true },
|
||||||
|
{ level: 'info' }, // 未設 is_new 視為新問題
|
||||||
|
{ level: 'custom', is_new: true }, // 無法標示(不在 LEVEL_ORDER)
|
||||||
|
{ level: 'critical', is_new: false }, // 舊問題
|
||||||
|
{ level: 'warning', is_new: false }, // 舊問題
|
||||||
|
{ level: 'mystery', is_new: false }, // 舊問題 + 無法標示
|
||||||
|
];
|
||||||
|
|
||||||
|
it('emits the exact header, separator and one row per type for mixed findings', () => {
|
||||||
|
const stats = formatFindingsStats(mixedFindings);
|
||||||
|
|
||||||
|
assert.equal(stats, [
|
||||||
|
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
|
||||||
|
'| --- | --- | --- | --- | --- |',
|
||||||
|
'| 新問題 | 2 筆 | 1 筆 | 1 筆 | 1 筆 |',
|
||||||
|
'| 舊問題 | 1 筆 | 1 筆 | 0 筆 | 1 筆 |',
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a structurally valid markdown table (4 lines, 6 pipes each, 5 columns)', () => {
|
||||||
|
const lines = formatFindingsStats(mixedFindings).split('\n');
|
||||||
|
|
||||||
|
// 表頭 + 分隔列 + 新問題列 + 舊問題列
|
||||||
|
assert.equal(lines.length, 4);
|
||||||
|
// 每列皆以 pipe 起訖
|
||||||
|
for (const row of lines) {
|
||||||
|
assert.ok(row.startsWith('| '), `row should start with a pipe: ${row}`);
|
||||||
|
assert.ok(row.endsWith(' |'), `row should end with a pipe: ${row}`);
|
||||||
|
// 5 欄 => 6 個 pipe 分隔符
|
||||||
|
assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
|
||||||
|
}
|
||||||
|
// 分隔列每格皆為 ---
|
||||||
|
assert.equal(lines[1], '| --- | --- | --- | --- | --- |');
|
||||||
|
// 資料列以 類型 標籤起頭
|
||||||
|
assert.match(lines[2], /^\| 新問題 \|/);
|
||||||
|
assert.match(lines[3], /^\| 舊問題 \|/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a stable, non-broken table for an empty findings array', () => {
|
||||||
|
let stats;
|
||||||
|
assert.doesNotThrow(() => { stats = formatFindingsStats([]); });
|
||||||
|
|
||||||
|
assert.equal(stats, [
|
||||||
|
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
|
||||||
|
'| --- | --- | --- | --- | --- |',
|
||||||
|
'| 新問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
|
||||||
|
'| 舊問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
|
||||||
|
].join('\n'));
|
||||||
|
// 即使無資料仍維持 4 列、每列 6 個 pipe 的結構
|
||||||
|
const lines = stats.split('\n');
|
||||||
|
assert.equal(lines.length, 4);
|
||||||
|
for (const row of lines) {
|
||||||
|
assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFindingsStatsLine markdown formatting', () => {
|
||||||
|
const mixedFindings = [
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'warning', is_new: true },
|
||||||
|
{ level: 'info' },
|
||||||
|
{ level: 'custom', is_new: true },
|
||||||
|
{ level: 'critical', is_new: false },
|
||||||
|
{ level: 'warning', is_new: false },
|
||||||
|
{ level: 'mystery', is_new: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('produces the exact single-line summary for mixed findings', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatFindingsStatsLine(mixedFindings),
|
||||||
|
'新: 嚴重2 / 警告1 / 建議1 / 無法標示1;舊: 嚴重1 / 警告1 / 建議0 / 無法標示1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a stable single line with zero counts for an empty findings array', () => {
|
||||||
|
let lineSummary;
|
||||||
|
assert.doesNotThrow(() => { lineSummary = formatFindingsStatsLine([]); });
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
lineSummary,
|
||||||
|
'新: 嚴重0 / 警告0 / 建議0 / 無法標示0;舊: 嚴重0 / 警告0 / 建議0 / 無法標示0',
|
||||||
|
);
|
||||||
|
// 單行:不含換行
|
||||||
|
assert.ok(!lineSummary.includes('\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers } from '../findings.js';
|
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers, normalizeText } from '../findings.js';
|
||||||
import { EXCLUSIONS_PATH, FINDINGS_PATH } from '../config.js';
|
import { EXCLUSIONS_PATH, FINDINGS_PATH } from '../config.js';
|
||||||
|
|
||||||
describe('findings exclusions', () => {
|
describe('findings exclusions', () => {
|
||||||
@@ -349,3 +349,47 @@ describe('findings exclusions', () => {
|
|||||||
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('normalizeText', () => {
|
||||||
|
it('normalizes full-width characters to the same result as half-width lowercase (NFKC)', () => {
|
||||||
|
// NFKC 將全形 ABC 折成半形 ABC,再小寫 → 'abc',與 'abc' 一致
|
||||||
|
assert.equal(normalizeText('ABC'), 'abc');
|
||||||
|
assert.equal(normalizeText('abc'), 'abc');
|
||||||
|
assert.equal(normalizeText('ABC'), normalizeText('abc'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses mixed punctuation and symbols into single spaces', () => {
|
||||||
|
assert.equal(normalizeText('Hello, World!! foo@bar'), 'hello world foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple spaces, newlines and tabs into a single space and trims', () => {
|
||||||
|
assert.equal(normalizeText(' multiple \n\t spaces \n here '), 'multiple spaces here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases uppercase input', () => {
|
||||||
|
assert.equal(normalizeText('UPPER Case Mix'), 'upper case mix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for non-string inputs', () => {
|
||||||
|
assert.equal(normalizeText(null), '');
|
||||||
|
assert.equal(normalizeText(undefined), '');
|
||||||
|
assert.equal(normalizeText(42), '');
|
||||||
|
assert.equal(normalizeText({ a: 1 }), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty string input', () => {
|
||||||
|
assert.equal(normalizeText(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips surrounding full-width whitespace and collapses CJK punctuation', () => {
|
||||||
|
// 全形空白被 trim、,與 !屬於 \p{P} 折成單一空白 → '你好 世界'
|
||||||
|
assert.equal(normalizeText(' 你好,世界! '), '你好 世界');
|
||||||
|
assert.equal(normalizeText('(重要)測試:項目#1'), '重要 測試 項目 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
for (const input of ['ABC', 'Hello, World!! foo@bar', ' 你好,世界! ', 'UPPER Case Mix', '']) {
|
||||||
|
assert.equal(normalizeText(normalizeText(input)), normalizeText(input));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,3 +139,87 @@ describe('json helpers', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('repairJSONArrayWithAI', () => {
|
||||||
|
it('returns a clean JSON array string parseable by the caller', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'{not valid json',
|
||||||
|
async () => '[{"id":1},{"id":2}]'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[{"id":1},{"id":2}]');
|
||||||
|
assert.deepEqual(JSON.parse(repaired), [{ id: 1 }, { id: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a fenced ```json block from the AI output', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/exclusions.json',
|
||||||
|
'garbage',
|
||||||
|
async () => '```json\n[1, 2, 3]\n```'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[1, 2, 3]');
|
||||||
|
assert.deepEqual(JSON.parse(repaired), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to an empty array when the model cannot repair the content', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'totally unparseable !!! @@@',
|
||||||
|
async () => '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[]');
|
||||||
|
assert.deepEqual(JSON.parse(repaired), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns garbage unchanged (no parsing/throwing) so the caller can validate', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'{broken',
|
||||||
|
async () => 'not a json array at all'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, 'not a json array at all');
|
||||||
|
assert.throws(() => JSON.parse(repaired));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes chatFn once with the strict system prompt and JSON-encoded context', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/findings.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'{broken',
|
||||||
|
async (systemPrompt, userContent) => {
|
||||||
|
calls.push({ systemPrompt, userContent });
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[]');
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.ok(calls[0].systemPrompt.includes('你是 JSON 修復器'));
|
||||||
|
assert.ok(calls[0].systemPrompt.includes('回傳 []'));
|
||||||
|
|
||||||
|
const context = JSON.parse(calls[0].userContent);
|
||||||
|
assert.deepEqual(context, {
|
||||||
|
file: '.gitea/ai-review/findings.json',
|
||||||
|
path: '/tmp/findings.json',
|
||||||
|
rawText: '{broken'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates errors thrown by chatFn', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => repairJSONArrayWithAI('/tmp/x.json', '.gitea/ai-review/findings.json', '{broken', async () => {
|
||||||
|
throw new Error('llm unavailable');
|
||||||
|
}),
|
||||||
|
/llm unavailable/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { extractBalancedJSON, extractJSONText } from '../llm.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
@@ -143,3 +144,90 @@ describe('chatJSON', async () => {
|
|||||||
assert.deepEqual(result, []);
|
assert.deepEqual(result, []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractBalancedJSON', () => {
|
||||||
|
it('returns the whole object for a simple object from index 0', () => {
|
||||||
|
const text = '{"a":1}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the full balanced segment for deeply nested object/array', () => {
|
||||||
|
const text = '{"a":[1,{"b":[2,{"c":3}]}],"d":4}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":[1,{"b":[2,{"c":3}]}],"d":4}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let braces inside a string value break balancing', () => {
|
||||||
|
const text = '{"a":"}{"}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"}{"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an escaped quote inside a string value', () => {
|
||||||
|
const text = '{"a":"\\""}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"\\""}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for truncated/incomplete JSON', () => {
|
||||||
|
const text = '{"a":1';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a balanced array when starting at a "["', () => {
|
||||||
|
const text = '[1,[2,3],{"a":4}]';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '[1,[2,3],{"a":4}]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes trailing content after the balanced segment', () => {
|
||||||
|
const text = '{"a":1} trailing text {"b":2}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractJSONText', () => {
|
||||||
|
it('strips a fenced ```json block', () => {
|
||||||
|
const text = '```json\n{"a":1}\n```';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(result), { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a JSON object after leading prose', () => {
|
||||||
|
const text = 'Here are the findings:\n{"level":"critical"}';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(result), { level: 'critical' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts an array embedded in surrounding text', () => {
|
||||||
|
const text = 'prefix [1,2,3] suffix';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(result), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an already-pure JSON string as-is', () => {
|
||||||
|
const text = '{"a":1,"b":[2,3]}';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.equal(result, '{"a":1,"b":[2,3]}');
|
||||||
|
assert.deepEqual(JSON.parse(result), { a: 1, b: [2, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the de-fenced original text when no valid JSON is found', () => {
|
||||||
|
const text = '```\nnot json at all\n```';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.equal(result, 'not json at all');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
reconcileConversations,
|
reconcileConversations,
|
||||||
dropResolvedFindings,
|
dropResolvedFindings,
|
||||||
addCarriedFindings,
|
addCarriedFindings,
|
||||||
|
isSafeRepoPath,
|
||||||
} from '../resolve.js';
|
} from '../resolve.js';
|
||||||
|
|
||||||
const reviewBody = (level, role, problem, suggestion) =>
|
const reviewBody = (level, role, problem, suggestion) =>
|
||||||
@@ -337,3 +338,41 @@ describe('addCarriedFindings', () => {
|
|||||||
assert.equal(addCarriedFindings(findings, []), findings);
|
assert.equal(addCarriedFindings(findings, []), findings);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSafeRepoPath', () => {
|
||||||
|
it('accepts a normal relative path', () => {
|
||||||
|
assert.equal(isSafeRepoPath('src/index.js'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a deep but safe relative path', () => {
|
||||||
|
assert.equal(isSafeRepoPath('a/b/c.js'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths containing ../ traversal', () => {
|
||||||
|
assert.equal(isSafeRepoPath('../../etc/passwd'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a segment that is exactly .. (incl. middle of path)', () => {
|
||||||
|
assert.equal(isSafeRepoPath('a/../b'), false);
|
||||||
|
// 反斜線 ..\ 形式:以 / 切割後整段仍為 ..\... 非單純 ..,但起首相對路徑仍判定安全
|
||||||
|
assert.equal(isSafeRepoPath('a/..'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects leading-slash absolute paths', () => {
|
||||||
|
assert.equal(isSafeRepoPath('/etc/passwd'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects Windows drive-letter prefixes', () => {
|
||||||
|
assert.equal(isSafeRepoPath('C:\\Windows\\system32'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty string', () => {
|
||||||
|
assert.equal(isSafeRepoPath(''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string inputs', () => {
|
||||||
|
assert.equal(isSafeRepoPath(null), false);
|
||||||
|
assert.equal(isSafeRepoPath(undefined), false);
|
||||||
|
assert.equal(isSafeRepoPath(123), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user