Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions src/schematics/utils.jasmine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { ConnectorConfig } from './interfaces.js';
import {
connectorConfigObjectLiteral,
isValidPackageSpecifier,
resolveDataConnectProviderConfig,
} from './utils.js';
import 'jasmine';

// eslint-disable-next-line @typescript-eslint/no-implied-eval
const evalObjectLiteral = (literal: string) => new Function(`return (${literal});`)();

describe('connectorConfigObjectLiteral', () => {

it('escapes a quote-bearing value so it round-trips as inert string data', () => {
const injectionAttempt = 'us-central1"; console.log("INJECTED"); const _z="x';
const literal = connectorConfigObjectLiteral({
location: injectionAttempt,
connector: 'my-connector',
service: 'my-service',
} as ConnectorConfig);
const evaluated = evalObjectLiteral(literal);
expect(evaluated.location).toBe(injectionAttempt);
});

it('coerces a non-string value from an unquoted yaml scalar to a string', () => {
const literal = connectorConfigObjectLiteral({
location: 'us-central1',
connector: 'my-connector',
service: 123,
} as unknown as ConnectorConfig);
const evaluated = evalObjectLiteral(literal);
expect(evaluated.service).toBe('123');
expect(typeof evaluated.service).toBe('string');
});

it('coerces a boolean-like yaml scalar to a string', () => {
const literal = connectorConfigObjectLiteral({
location: 'us-central1',
connector: true,
service: 'my-service',
} as unknown as ConnectorConfig);
const evaluated = evalObjectLiteral(literal);
expect(evaluated.connector).toBe('true');
expect(typeof evaluated.connector).toBe('string');
});

});

describe('isValidPackageSpecifier', () => {

it('accepts a normal scoped package specifier', () => {
expect(isValidPackageSpecifier('@my-org/my-connector')).toBeTrue();
});

it('rejects a value containing a double quote', () => {
expect(isValidPackageSpecifier('foo"; console.log("INJECTED"); const _z="x')).toBeFalse();
});

it('rejects a value containing a backslash', () => {
expect(isValidPackageSpecifier('foo\\bar')).toBeFalse();
});

it('rejects a value containing a newline', () => {
expect(isValidPackageSpecifier('foo\nbar')).toBeFalse();
});

});

describe('resolveDataConnectProviderConfig', () => {

it('resolves to the connectorConfig object literal when there is no package', () => {
const resolution = resolveDataConnectProviderConfig({
connectorYaml: { connectorId: 'my-connector' },
connectorConfig: {
location: 'us-central1',
connector: 'my-connector',
service: 'my-service',
},
});
expect(resolution.kind).toBe('literal');
});

it('resolves to an external import when package is a valid specifier', () => {
const resolution = resolveDataConnectProviderConfig({
connectorYaml: { connectorId: 'my-connector' },
connectorConfig: {
location: 'us-central1',
connector: 'my-connector',
service: 'my-service',
},
package: '@my-org/my-connector',
});
expect(resolution).toEqual({ kind: 'external', package: '@my-org/my-connector' });
});

it('falls back to the connectorConfig literal when package is not a valid specifier', () => {
const resolution = resolveDataConnectProviderConfig({
connectorYaml: { connectorId: 'my-connector' },
connectorConfig: {
location: 'us-central1',
connector: 'my-connector',
service: 'my-service',
},
package: 'foo"; console.log("INJECTED")',
});
expect(resolution.kind).toBe('literal');
});

it('does not throw and falls back to an empty literal when there is no javascriptSdk config', () => {
expect(() => resolveDataConnectProviderConfig({
connectorYaml: { connectorId: 'my-connector' },
})).not.toThrow();
expect(resolveDataConnectProviderConfig({
connectorYaml: { connectorId: 'my-connector' },
})).toEqual({ kind: 'literal', literal: '{}' });
});

it('does not throw and falls back to an empty literal when config is null', () => {
expect(() => resolveDataConnectProviderConfig(null)).not.toThrow();
expect(resolveDataConnectProviderConfig(null)).toEqual({ kind: 'literal', literal: '{}' });
});

});
68 changes: 47 additions & 21 deletions src/schematics/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {
Tree,
chain,
} from "@angular-devkit/schematics";
import { NodePackageInstallTask } from "@angular-devkit/schematics/tasks";
import { NodePackageInstallTask } from "@angular-devkit/schematics/tasks/index.js";
import { addRootProvider } from "@schematics/angular/utility";
import { parse } from "yaml";
import { overwriteIfExists, safeReadJSON, stringifyFormatted } from "./common";
import { overwriteIfExists, safeReadJSON, stringifyFormatted } from "./common.js";
import {
ConnectorConfig,
ConnectorYaml,
Expand Down Expand Up @@ -148,6 +148,38 @@ ${addZonePatch ? "import 'zone.js/dist/zone-patch-rxjs';" : ""}`
return host;
}

// config.package and the connectorConfig values below come from the project's own
// dataconnect.yaml/connector.yaml, not from a trusted schema. isValidPackageSpecifier
// rejects the characters that would let a value break out of the double-quoted string
// literal it's interpolated into; it does not otherwise validate that the value is a
// well-formed module specifier.
const PACKAGE_SPECIFIER_PATTERN = /^[^'"\\\n\r]+$/;
export function isValidPackageSpecifier(pkg: string): boolean {
return PACKAGE_SPECIFIER_PATTERN.test(pkg);
}

export function connectorConfigObjectLiteral(connectorConfig: ConnectorConfig): string {
return `{${(Object.keys(connectorConfig) as (keyof ConnectorConfig)[]).map(
(key) => `${key}: ${JSON.stringify(String(connectorConfig[key]))}`
).join(',')}}`;
}

export type DataConnectProviderConfigResolution =
| { kind: "external"; package: string }
| { kind: "literal"; literal: string };

export function resolveDataConnectProviderConfig(
config: DataConnectConnectorConfig | null | undefined
): DataConnectProviderConfigResolution {
if (config?.package && isValidPackageSpecifier(config.package)) {
return { kind: "external", package: config.package };
}
if (config?.connectorConfig) {
return { kind: "literal", literal: connectorConfigObjectLiteral(config.connectorConfig) };
}
return { kind: "literal", literal: "{}" };
}

export function featureToRules(
features: FEATURES[],
projectName: string,
Expand Down Expand Up @@ -216,27 +248,21 @@ export function featureToRules(
case FEATURES.DataConnect:
return addRootProvider(projectName, ({ code, external }) => {
external("getDataConnect", "@angular/fire/data-connect");
let configAsStr = "{}";

const config = dataConnectConfig;
const resolution = resolveDataConnectProviderConfig(dataConnectConfig);
const configAsStr = resolution.kind === "external"
? external("connectorConfig", resolution.package)
: resolution.literal;

let angularConfig: undefined | string;
if (config) {
if (config.package) {
configAsStr = external("connectorConfig", config.package);
} else {
configAsStr = `{${Object.keys(config.connectorConfig as ConnectorConfig).map(
(key) => `${key}: "${(config.connectorConfig as ConnectorConfig)[key]}"`
).join(',')}}`;
}
if (config.angular) {
angularConfig = `, ${external(
"provideTanStackQuery",
"@tanstack/angular-query-experimental"
)}(new ${external(
"QueryClient",
"@tanstack/angular-query-experimental"
)}())`;
}
if (dataConnectConfig?.angular) {
angularConfig = `, ${external(
"provideTanStackQuery",
"@tanstack/angular-query-experimental"
)}(new ${external(
"QueryClient",
"@tanstack/angular-query-experimental"
)}())`;
}
return code`${external(
"provideDataConnect",
Expand Down