mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
fix(javascript): recover GraphQL selection prefixes and validate names (#10065)
fix(javascript): align GraphQL operation grammar
This commit is contained in:
1 parent
f7d564f436
commit
d1895841a4
4 files changed
+170
-10
No files matched your search
@@ -456,9 +456,13 @@ RPC client syntax also contributes network endpoint candidates, in both
|
||||
- `graphql:query`, `graphql:mutation`, and `graphql:subscription` record a
|
||||
named operation found at the start of a string or cooked template literal —
|
||||
the name must be followed by variable definitions (`($`), a directive, or a
|
||||
selection set starting with a field name — or in a precompiled
|
||||
`OperationDefinition` document object. A standalone Source object's `body`
|
||||
remains an operation candidate even when it has `locationOffset` metadata.
|
||||
selection set starting with a field, named fragment spread, or inline fragment
|
||||
— or in a precompiled `OperationDefinition` document object. A precompiled
|
||||
operation name must be one complete GraphQL Name token (ASCII letters,
|
||||
digits, and underscores, with no leading digit). Recognition is limited to
|
||||
static operation headers; it does not validate a complete GraphQL document or
|
||||
prove that the application sends the operation. A standalone Source object's
|
||||
`body` remains an operation candidate even when it has `locationOffset` metadata.
|
||||
A graphql-tag `loc.source.body` copy is suppressed only when that source
|
||||
belongs to a recovered document with the same operation and its containing
|
||||
document, location and source have proven private metadata ownership. Writes,
|
||||
|
||||
@@ -110,6 +110,30 @@ const graphqlNameAt = (
|
||||
: { value: name, end: index + name.length };
|
||||
};
|
||||
|
||||
/** GraphQL selections may start with a field, named spread, or inline fragment. */
|
||||
const graphqlSelectionAt = (text: string, start: number): boolean => {
|
||||
let index = skipGraphqlIgnored(text, start);
|
||||
for (;;) {
|
||||
if (graphqlNameAt(text, index) !== undefined) return true;
|
||||
if (!text.startsWith("...", index)) return false;
|
||||
index = skipGraphqlIgnored(text, index + 3);
|
||||
const fragment = graphqlNameAt(text, index);
|
||||
if (fragment !== undefined)
|
||||
return (
|
||||
fragment.value !== "on" ||
|
||||
graphqlNameAt(text, skipGraphqlIgnored(text, fragment.end)) !==
|
||||
undefined
|
||||
);
|
||||
if (text.charAt(index) === "@")
|
||||
return (
|
||||
graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined
|
||||
);
|
||||
if (text.charAt(index) !== "{") return false;
|
||||
// Untyped inline fragments can nest; scan their prefix without recursion.
|
||||
index = skipGraphqlIgnored(text, index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/** Recover a named operation from GraphQL document text. */
|
||||
const graphqlOperationFromText = (
|
||||
text: string,
|
||||
@@ -122,12 +146,13 @@ const graphqlOperationFromText = (
|
||||
const index = skipGraphqlIgnored(text, name.end);
|
||||
const next = text.charAt(index);
|
||||
// Keep the existing bounded recognition scope: a named operation followed
|
||||
// by variable definitions, a directive, or a selection set with a field.
|
||||
// by variable definitions, a directive, or a recognized selection prefix.
|
||||
const hasHeader =
|
||||
next === "("
|
||||
? text.charAt(skipGraphqlIgnored(text, index + 1)) === "$"
|
||||
: (next === "@" || next === "{") &&
|
||||
graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined;
|
||||
: next === "@"
|
||||
? graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined
|
||||
: next === "{" && graphqlSelectionAt(text, index + 1);
|
||||
return hasHeader
|
||||
? { value: name.value, mechanism: `graphql:${type.value}` }
|
||||
: undefined;
|
||||
@@ -149,9 +174,10 @@ const graphqlOperationFromDocumentNode = (
|
||||
)
|
||||
return undefined;
|
||||
const value = semanticFinalObjectStringProperty(name, "value");
|
||||
return value === undefined
|
||||
? undefined
|
||||
: { value, mechanism: `graphql:${type}` };
|
||||
if (value === undefined) return undefined;
|
||||
return graphqlNameAt(value, 0)?.end === value.length
|
||||
? { value, mechanism: `graphql:${type}` }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
interface GraphqlSourceDependency {
|
||||
|
||||
@@ -415,7 +415,10 @@ describe("GraphQL operation recognition", () => {
|
||||
"query GetUser # prose\nparameters",
|
||||
"query Get# split name\nUser { user }",
|
||||
"query GetUser @ # missing directive name",
|
||||
"query GetUser { # fragment\n...UserFields }",
|
||||
"query GetUser { # missing fragment name\n... }",
|
||||
"query GetUser { ... on }",
|
||||
"query GetUser { ... @ }",
|
||||
"query GetUser { ....UserFields }",
|
||||
"query\u00a0GetUser { user }",
|
||||
"query\vGetUser { user }",
|
||||
])("keeps incomplete or unsupported header text unknown: %s", (text) => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { parse as parseGraphql } from "graphql";
|
||||
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const documents = [
|
||||
"query FieldFirst { user }",
|
||||
"query NamedSpread { ...UserFields } fragment UserFields on Query { user }",
|
||||
"query TypedInline { ... on Query { user } }",
|
||||
"query DirectedInline { ... @include(if: true) { user } }",
|
||||
"query UntypedInline { ... { ... { user } } }",
|
||||
];
|
||||
|
||||
it.each(["text", "DocumentNode"] as const)(
|
||||
"retains GraphQL operation grammar and source Evidence through application analysis: %s",
|
||||
async (representation) => {
|
||||
const root = await createTestTempDirectory("rea-graphql-grammar-");
|
||||
const names =
|
||||
representation === "text"
|
||||
? documents.map((text) => {
|
||||
const operation = parseGraphql(text).definitions.find(
|
||||
(definition) => definition.kind === "OperationDefinition",
|
||||
);
|
||||
if (operation?.name === undefined)
|
||||
throw new Error("Expected named operation");
|
||||
return operation.name.value;
|
||||
})
|
||||
: ["_", "_GetUser2"];
|
||||
const definitions =
|
||||
representation === "text"
|
||||
? documents
|
||||
.map(
|
||||
(text, index) =>
|
||||
`const document${index} = ${index % 3 === 0 ? JSON.stringify(text) : index % 3 === 1 ? "gql`" + text + "`" : `{ body: ${JSON.stringify(text)}, locationOffset: { line: 1, column: 1 } }`};`,
|
||||
)
|
||||
.join("\n")
|
||||
: [...names, "", "two words", "123Name", "GetUser\n", "GetUser💫"]
|
||||
.map(
|
||||
(name, index) =>
|
||||
`const document${index} = { kind: "OperationDefinition", operation: "query", name: { kind: "Name", value: ${JSON.stringify(name)} } };`,
|
||||
)
|
||||
.join("\n");
|
||||
const source = `throw new Error("This authored input must never execute");\n${definitions}\nconst malformed = "query Broken { ... }";\nconst prose = "query string parameters";\nfetch(dynamicEndpoint);\n`;
|
||||
await writeFile(join(root, "app.mjs"), source);
|
||||
const analyzed = await analyzeJavaScriptApplication({
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
});
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
expect(analyzed.value.parameters).toMatchObject({
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
});
|
||||
expect(analyzed.value.authority).toBe("shipped-artifact");
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
analyzed.value.normalized_result,
|
||||
);
|
||||
const endpoints = result.graph.nodes.filter(
|
||||
({ kind }) => kind === "endpoint",
|
||||
);
|
||||
const graphql = endpoints
|
||||
.flatMap(({ observations }) => observations)
|
||||
.filter(
|
||||
({ properties }) =>
|
||||
typeof properties.mechanism === "string" &&
|
||||
properties.mechanism.startsWith("graphql:"),
|
||||
);
|
||||
expect(graphql.map(({ properties }) => properties.value).sort()).toEqual(
|
||||
[...names].sort(),
|
||||
);
|
||||
const sha256 = createHash("sha256").update(source).digest("hex");
|
||||
for (const observation of graphql) {
|
||||
expect(observation.properties.mechanism).toBe("graphql:query");
|
||||
expect(observation.evidence).toMatchObject({
|
||||
authority: "ast-static-analysis",
|
||||
state: "observed",
|
||||
confidence: "exact",
|
||||
artifact: { available: true, sha256 },
|
||||
location: {
|
||||
available: true,
|
||||
value: {
|
||||
kind: "source-range",
|
||||
source: "app.mjs",
|
||||
start: { line: expect.any(Number), column: expect.any(Number) },
|
||||
end: { line: expect.any(Number), column: expect.any(Number) },
|
||||
},
|
||||
},
|
||||
});
|
||||
const location = observation.evidence.location;
|
||||
if (!location.available || location.value.kind !== "source-range")
|
||||
throw new Error("Expected the operation's source range");
|
||||
const name = String(observation.properties.value);
|
||||
expect(location.value.start.line).toBe(names.indexOf(name) + 2);
|
||||
expect(location.value.end.line).toBe(location.value.start.line);
|
||||
const selected = source
|
||||
.split("\n")
|
||||
[location.value.start.line - 1]!.slice(
|
||||
location.value.start.column,
|
||||
location.value.end.column,
|
||||
);
|
||||
expect(selected).toContain(name);
|
||||
}
|
||||
const unresolved = endpoints
|
||||
.flatMap(({ observations }) => observations)
|
||||
.find(({ properties }) => properties.value === "dynamicEndpoint");
|
||||
expect(unresolved).toMatchObject({
|
||||
properties: { mechanism: "call:fetch", resolution: "partial" },
|
||||
evidence: {
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("unresolved source expression"),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const calls = result.graph.edges.filter(
|
||||
({ relation }) => relation === "calls",
|
||||
);
|
||||
expect(calls).toHaveLength(names.length + 1);
|
||||
expect(calls.every(({ evidence }) => evidence.state === "inferred")).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user