first commit

This commit is contained in:
2025-10-26 23:10:15 +08:00
commit 8f0345b7be
14961 changed files with 2356381 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import type { TSESTree } from '@typescript-eslint/utils';
export declare function isNodeTypeClass(node: TSESTree.ClassDeclaration): boolean;
export declare function isCredentialTypeClass(node: TSESTree.ClassDeclaration): boolean;
export declare function findClassProperty(node: TSESTree.ClassDeclaration, propertyName: string): TSESTree.PropertyDefinition | null;
export declare function findObjectProperty(obj: TSESTree.ObjectExpression, propertyName: string): TSESTree.Property | null;
export declare function getLiteralValue(node: TSESTree.Node | null): string | boolean | number | null;
export declare function getStringLiteralValue(node: TSESTree.Node | null): string | null;
export declare function getModulePath(node: TSESTree.Node | null): string | null;
export declare function getBooleanLiteralValue(node: TSESTree.Node | null): boolean | null;
export declare function findArrayLiteralProperty(obj: TSESTree.ObjectExpression, propertyName: string): TSESTree.ArrayExpression | null;
export declare function hasArrayLiteralValue(node: TSESTree.PropertyDefinition, searchValue: string): boolean;
export declare function getTopLevelObjectInJson(node: TSESTree.ObjectExpression): TSESTree.ObjectExpression | null;
export declare function isFileType(filename: string, extension: string): boolean;
export declare function isDirectRequireCall(node: TSESTree.CallExpression): boolean;
export declare function isRequireMemberCall(node: TSESTree.CallExpression): boolean;
export declare function extractCredentialInfoFromArray(element: TSESTree.ArrayExpression['elements'][number]): {
name: string;
testedBy?: string;
node: TSESTree.Node;
} | null;
export declare function extractCredentialNameFromArray(element: TSESTree.ArrayExpression['elements'][number]): {
name: string;
node: TSESTree.Node;
} | null;
export declare function findSimilarStrings(target: string, candidates: Set<string>, maxDistance?: number, maxResults?: number): string[];
//# sourceMappingURL=ast-utils.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"ast-utils.d.ts","sourceRoot":"","sources":["../../src/utils/ast-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAezD,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,CAAC,gBAAgB,GAAG,OAAO,CAUxE;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,QAAQ,CAAC,gBAAgB,GAAG,OAAO,CAE9E;AAED,wBAAgB,iBAAiB,CAChC,IAAI,EAAE,QAAQ,CAAC,gBAAgB,EAC/B,YAAY,EAAE,MAAM,GAClB,QAAQ,CAAC,kBAAkB,GAAG,IAAI,CAQpC;AAED,wBAAgB,kBAAkB,CACjC,GAAG,EAAE,QAAQ,CAAC,gBAAgB,EAC9B,YAAY,EAAE,MAAM,GAClB,QAAQ,CAAC,QAAQ,GAAG,IAAI,CAQ1B;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,IAAI,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,IAAI,CAK5F;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAG/E;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAevE;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,IAAI,GAAG,OAAO,GAAG,IAAI,CAGjF;AAED,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,QAAQ,CAAC,gBAAgB,EAC9B,YAAY,EAAE,MAAM,GAClB,QAAQ,CAAC,eAAe,GAAG,IAAI,CAMjC;AAED,wBAAgB,oBAAoB,CACnC,IAAI,EAAE,QAAQ,CAAC,kBAAkB,EACjC,WAAW,EAAE,MAAM,GACjB,OAAO,CAST;AAED,wBAAgB,uBAAuB,CACtC,IAAI,EAAE,QAAQ,CAAC,gBAAgB,GAC7B,QAAQ,CAAC,gBAAgB,GAAG,IAAI,CAKlC;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAEvE;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,QAAQ,CAAC,cAAc,GAAG,OAAO,CAM1E;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,QAAQ,CAAC,cAAc,GAAG,OAAO,CAO1E;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE,QAAQ,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,GACnD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAA;CAAE,GAAG,IAAI,CA6BjE;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE,QAAQ,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,GACnD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAA;CAAE,GAAG,IAAI,CAG9C;AAED,wBAAgB,kBAAkB,CACjC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,EACvB,WAAW,GAAE,MAAU,EACvB,UAAU,GAAE,MAAU,GACpB,MAAM,EAAE,CAeV"}

View File

@@ -0,0 +1,135 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { distance } from 'fastest-levenshtein';
function implementsInterface(node, interfaceName) {
return (node.implements?.some((impl) => impl.type === AST_NODE_TYPES.TSClassImplements &&
impl.expression.type === AST_NODE_TYPES.Identifier &&
impl.expression.name === interfaceName) ?? false);
}
export function isNodeTypeClass(node) {
if (implementsInterface(node, 'INodeType')) {
return true;
}
if (node.superClass?.type === AST_NODE_TYPES.Identifier && node.superClass.name === 'Node') {
return true;
}
return false;
}
export function isCredentialTypeClass(node) {
return implementsInterface(node, 'ICredentialType');
}
export function findClassProperty(node, propertyName) {
const property = node.body.body.find((member) => member.type === AST_NODE_TYPES.PropertyDefinition &&
member.key?.type === AST_NODE_TYPES.Identifier &&
member.key.name === propertyName);
return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null;
}
export function findObjectProperty(obj, propertyName) {
const property = obj.properties.find((prop) => prop.type === AST_NODE_TYPES.Property &&
prop.key.type === AST_NODE_TYPES.Identifier &&
prop.key.name === propertyName);
return property?.type === AST_NODE_TYPES.Property ? property : null;
}
export function getLiteralValue(node) {
if (node?.type === AST_NODE_TYPES.Literal) {
return node.value;
}
return null;
}
export function getStringLiteralValue(node) {
const value = getLiteralValue(node);
return typeof value === 'string' ? value : null;
}
export function getModulePath(node) {
const stringValue = getStringLiteralValue(node);
if (stringValue) {
return stringValue;
}
if (node?.type === AST_NODE_TYPES.TemplateLiteral &&
node.expressions.length === 0 &&
node.quasis.length === 1) {
return node.quasis[0]?.value.cooked ?? null;
}
return null;
}
export function getBooleanLiteralValue(node) {
const value = getLiteralValue(node);
return typeof value === 'boolean' ? value : null;
}
export function findArrayLiteralProperty(obj, propertyName) {
const property = findObjectProperty(obj, propertyName);
if (property?.value.type === AST_NODE_TYPES.ArrayExpression) {
return property.value;
}
return null;
}
export function hasArrayLiteralValue(node, searchValue) {
if (node.value?.type !== AST_NODE_TYPES.ArrayExpression)
return false;
return node.value.elements.some((element) => element?.type === AST_NODE_TYPES.Literal &&
typeof element.value === 'string' &&
element.value === searchValue);
}
export function getTopLevelObjectInJson(node) {
if (node.parent?.type === AST_NODE_TYPES.Property) {
return null;
}
return node;
}
export function isFileType(filename, extension) {
return filename.endsWith(extension);
}
export function isDirectRequireCall(node) {
return (node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === 'require' &&
node.arguments.length > 0);
}
export function isRequireMemberCall(node) {
return (node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.object.type === AST_NODE_TYPES.Identifier &&
node.callee.object.name === 'require' &&
node.arguments.length > 0);
}
export function extractCredentialInfoFromArray(element) {
if (!element)
return null;
const stringValue = getStringLiteralValue(element);
if (stringValue) {
return { name: stringValue, node: element };
}
if (element.type === AST_NODE_TYPES.ObjectExpression) {
const nameProperty = findObjectProperty(element, 'name');
const testedByProperty = findObjectProperty(element, 'testedBy');
if (nameProperty) {
const nameValue = getStringLiteralValue(nameProperty.value);
const testedByValue = testedByProperty
? getStringLiteralValue(testedByProperty.value)
: undefined;
if (nameValue) {
return {
name: nameValue,
testedBy: testedByValue ?? undefined,
node: nameProperty.value,
};
}
}
}
return null;
}
export function extractCredentialNameFromArray(element) {
const info = extractCredentialInfoFromArray(element);
return info ? { name: info.name, node: info.node } : null;
}
export function findSimilarStrings(target, candidates, maxDistance = 3, maxResults = 3) {
const matches = [];
for (const candidate of candidates) {
const levenshteinDistance = distance(target.toLowerCase(), candidate.toLowerCase());
if (levenshteinDistance <= maxDistance) {
matches.push({ name: candidate, distance: levenshteinDistance });
}
}
return matches
.sort((a, b) => a.distance - b.distance)
.slice(0, maxResults)
.map((match) => match.name);
}
//# sourceMappingURL=ast-utils.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
/**
* Checks if the given childPath is contained within the parentPath. Resolves
* the paths before comparing them, so that relative paths are also supported.
*/
export declare function isContainedWithin(parentPath: string, childPath: string): boolean;
/**
* Joins the given paths to the parentPath, ensuring that the resulting path
* is still contained within the parentPath. If not, it throws an error to
* prevent path traversal vulnerabilities.
*
* @throws {UnexpectedError} If the resulting path is not contained within the parentPath.
*/
export declare function safeJoinPath(parentPath: string, ...paths: string[]): string;
export declare function findPackageJson(startPath: string): string | null;
export declare function readPackageJsonCredentials(packageJsonPath: string): Set<string>;
export declare function extractCredentialNameFromFile(credentialFilePath: string): string | null;
export declare function validateIconPath(iconPath: string, baseDir: string): {
isValid: boolean;
isFile: boolean;
isSvg: boolean;
exists: boolean;
};
export declare function readPackageJsonNodes(packageJsonPath: string): string[];
export declare function areAllCredentialUsagesTestedByNodes(credentialName: string, packageDir: string): boolean;
export declare function findSimilarSvgFiles(targetPath: string, baseDir: string): string[];
//# sourceMappingURL=file-utils.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"file-utils.d.ts","sourceRoot":"","sources":["../../src/utils/file-utils.ts"],"names":[],"mappings":"AAgBA;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAShF;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAU3E;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAahE;AAyCD,wBAAgB,0BAA0B,CAAC,eAAe,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAkB/E;AAED,wBAAgB,6BAA6B,CAAC,kBAAkB,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4BvF;AAED,wBAAgB,gBAAgB,CAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GACb;IACF,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;CAChB,CAcA;AAED,wBAAgB,oBAAoB,CAAC,eAAe,EAAE,MAAM,GAAG,MAAM,EAAE,CAItE;AAED,wBAAgB,mCAAmC,CAClD,cAAc,EAAE,MAAM,EACtB,UAAU,EAAE,MAAM,GAChB,OAAO,CAoBT;AA+DD,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBjF"}

View File

@@ -0,0 +1,221 @@
import { parse, simpleTraverse, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import * as path from 'node:path';
import { dirname, parse as parsePath } from 'node:path';
import { isCredentialTypeClass, isNodeTypeClass, findClassProperty, getStringLiteralValue, findArrayLiteralProperty, extractCredentialInfoFromArray, findSimilarStrings, } from './ast-utils.js';
/**
* Checks if the given childPath is contained within the parentPath. Resolves
* the paths before comparing them, so that relative paths are also supported.
*/
export function isContainedWithin(parentPath, childPath) {
parentPath = path.resolve(parentPath);
childPath = path.resolve(childPath);
if (parentPath === childPath) {
return true;
}
return childPath.startsWith(parentPath + path.sep);
}
/**
* Joins the given paths to the parentPath, ensuring that the resulting path
* is still contained within the parentPath. If not, it throws an error to
* prevent path traversal vulnerabilities.
*
* @throws {UnexpectedError} If the resulting path is not contained within the parentPath.
*/
export function safeJoinPath(parentPath, ...paths) {
const candidate = path.join(parentPath, ...paths);
if (!isContainedWithin(parentPath, candidate)) {
throw new Error(`Path traversal detected, refusing to join paths: ${parentPath} and ${JSON.stringify(paths)}`);
}
return candidate;
}
export function findPackageJson(startPath) {
let currentDir = path.dirname(startPath);
while (parsePath(currentDir).dir !== parsePath(currentDir).root) {
const testPath = safeJoinPath(currentDir, 'package.json');
if (fileExistsWithCaseSync(testPath)) {
return testPath;
}
currentDir = dirname(currentDir);
}
return null;
}
function isValidPackageJson(obj) {
return typeof obj === 'object' && obj !== null;
}
function readPackageJsonN8n(packageJsonPath) {
try {
const content = readFileSync(packageJsonPath, 'utf8');
const parsed = JSON.parse(content);
if (isValidPackageJson(parsed)) {
return parsed.n8n ?? {};
}
return {};
}
catch {
return {};
}
}
function resolveN8nFilePaths(packageJsonPath, filePaths) {
const packageDir = dirname(packageJsonPath);
const resolvedFiles = [];
for (const filePath of filePaths) {
const sourcePath = filePath.replace(/^dist\//, '').replace(/\.js$/, '.ts');
const fullSourcePath = safeJoinPath(packageDir, sourcePath);
if (existsSync(fullSourcePath)) {
resolvedFiles.push(fullSourcePath);
}
}
return resolvedFiles;
}
export function readPackageJsonCredentials(packageJsonPath) {
const n8nConfig = readPackageJsonN8n(packageJsonPath);
const credentialPaths = n8nConfig.credentials ?? [];
const credentialFiles = resolveN8nFilePaths(packageJsonPath, credentialPaths);
const credentialNames = [];
for (const credentialFile of credentialFiles) {
try {
const credentialName = extractCredentialNameFromFile(credentialFile);
if (credentialName) {
credentialNames.push(credentialName);
}
}
catch {
// Silently continue if file can't be parsed
}
}
return new Set(credentialNames);
}
export function extractCredentialNameFromFile(credentialFilePath) {
try {
const sourceCode = readFileSync(credentialFilePath, 'utf8');
const ast = parse(sourceCode, {
jsx: false,
range: true,
});
let credentialName = null;
simpleTraverse(ast, {
enter(node) {
if (node.type === AST_NODE_TYPES.ClassDeclaration && isCredentialTypeClass(node)) {
const nameProperty = findClassProperty(node, 'name');
if (nameProperty) {
const nameValue = getStringLiteralValue(nameProperty.value);
if (nameValue) {
credentialName = nameValue;
}
}
}
},
});
return credentialName;
}
catch {
return null;
}
}
export function validateIconPath(iconPath, baseDir) {
const isFile = iconPath.startsWith('file:');
const relativePath = iconPath.replace(/^file:/, '');
const isSvg = relativePath.endsWith('.svg');
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
const fullPath = path.join(baseDir, relativePath);
const exists = fileExistsWithCaseSync(fullPath);
return {
isValid: isFile && isSvg && exists,
isFile,
isSvg,
exists,
};
}
export function readPackageJsonNodes(packageJsonPath) {
const n8nConfig = readPackageJsonN8n(packageJsonPath);
const nodePaths = n8nConfig.nodes ?? [];
return resolveN8nFilePaths(packageJsonPath, nodePaths);
}
export function areAllCredentialUsagesTestedByNodes(credentialName, packageDir) {
const packageJsonPath = safeJoinPath(packageDir, 'package.json');
if (!existsSync(packageJsonPath)) {
return false;
}
const nodeFiles = readPackageJsonNodes(packageJsonPath);
let hasAnyCredentialUsage = false;
for (const nodeFile of nodeFiles) {
const result = checkCredentialUsageInFile(nodeFile, credentialName);
if (result.hasUsage) {
hasAnyCredentialUsage = true;
if (!result.allTestedBy) {
return false; // Found usage without testedBy
}
}
}
return hasAnyCredentialUsage;
}
function checkCredentialUsageInFile(nodeFile, credentialName) {
try {
const sourceCode = readFileSync(nodeFile, 'utf8');
const ast = parse(sourceCode, { jsx: false, range: true });
let hasUsage = false;
let allTestedBy = true;
simpleTraverse(ast, {
enter(node) {
if (node.type === AST_NODE_TYPES.ClassDeclaration && isNodeTypeClass(node)) {
const descriptionProperty = findClassProperty(node, 'description');
if (!descriptionProperty?.value ||
descriptionProperty.value.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
const credentialsArray = findArrayLiteralProperty(descriptionProperty.value, 'credentials');
if (!credentialsArray) {
return;
}
for (const element of credentialsArray.elements) {
const credentialInfo = extractCredentialInfoFromArray(element);
if (credentialInfo?.name === credentialName) {
hasUsage = true;
if (!credentialInfo.testedBy) {
allTestedBy = false;
}
}
}
}
},
});
return { hasUsage, allTestedBy };
}
catch {
return { hasUsage: false, allTestedBy: true };
}
}
function fileExistsWithCaseSync(filePath) {
try {
const dir = path.dirname(filePath);
const file = path.basename(filePath);
const files = new Set(readdirSync(dir));
return files.has(file);
}
catch {
return false;
}
}
export function findSimilarSvgFiles(targetPath, baseDir) {
try {
const targetFileName = path.basename(targetPath, path.extname(targetPath));
const targetDir = path.dirname(targetPath);
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
const searchDir = path.join(baseDir, targetDir);
if (!existsSync(searchDir)) {
return [];
}
const files = readdirSync(searchDir);
const svgFileNames = files
.filter((file) => file.endsWith('.svg'))
.map((file) => path.basename(file, '.svg'));
const candidateNames = new Set(svgFileNames);
const similarNames = findSimilarStrings(targetFileName, candidateNames);
return similarNames.map((name) => path.join(targetDir, `${name}.svg`));
}
catch {
return [];
}
}
//# sourceMappingURL=file-utils.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
export * from './ast-utils.js';
export * from './file-utils.js';
export * from './rule-creator.js';
//# sourceMappingURL=index.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC"}

View File

@@ -0,0 +1,4 @@
export * from './ast-utils.js';
export * from './file-utils.js';
export * from './rule-creator.js';
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC"}

View File

@@ -0,0 +1,3 @@
import { ESLintUtils } from '@typescript-eslint/utils';
export declare const createRule: <Options extends readonly unknown[], MessageIds extends string>({ meta, name, ...rule }: Readonly<ESLintUtils.RuleWithMetaAndName<Options, MessageIds, unknown>>) => ESLintUtils.RuleModule<MessageIds, Options, unknown, ESLintUtils.RuleListener>;
//# sourceMappingURL=rule-creator.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"rule-creator.d.ts","sourceRoot":"","sources":["../../src/utils/rule-creator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAKvD,eAAO,MAAM,UAAU,qPAA2E,CAAC"}

View File

@@ -0,0 +1,5 @@
import { ESLintUtils } from '@typescript-eslint/utils';
const REPO_URL = 'https://github.com/n8n-io/n8n';
const DOCS_PATH = 'blob/master/packages/@n8n/eslint-plugin-community-nodes/docs/rules';
export const createRule = ESLintUtils.RuleCreator((name) => `${REPO_URL}/${DOCS_PATH}/${name}.md`);
//# sourceMappingURL=rule-creator.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"rule-creator.js","sourceRoot":"","sources":["../../src/utils/rule-creator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAEvD,MAAM,QAAQ,GAAG,+BAA+B,CAAC;AACjD,MAAM,SAAS,GAAG,oEAAoE,CAAC;AAEvF,MAAM,CAAC,MAAM,UAAU,GAAG,WAAW,CAAC,WAAW,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,QAAQ,IAAI,SAAS,IAAI,IAAI,KAAK,CAAC,CAAC"}