diff --git a/README.md b/README.md index 854985f5..501b9626 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,19 @@ Everytime a new release is merged into `master` there will be a new github relea # Deploying the subgraph The deployment manifest IPFS handler uses the native YAML API from `graph-ts` 0.38.2 -and requires **Graph Node 0.37.0 or newer**. Malformed manifests retain their raw -content; derived fields that cannot be safely extracted remain unset and produce -warnings. Schema links support CIDv0 and CIDv1 in base32 or base58btc, optionally -with an IPFS file path. Unsupported links are skipped without creating a file data source. +and requires **Graph Node 0.40.2 or newer** to resolve schema references directly, +including IPFS URIs and gateway/RPC URLs. Malformed manifests retain their raw +content. `startBlock` defaults to `0` and `poweredBySubstreams` to `false` when they +cannot be determined; missing or invalidly typed network/schema fields remain unset. +Extracted network and schema-reference strings are trimmed, bounded in length, and +checked for embedded control characters. Schema references are otherwise treated as +opaque metadata: Graph Node resolves the original reference and logs/skips invalid +IPFS sources. Common IPFS prefixes and simple gateway paths are normalized only for +metadata and entity IDs; URL query/fragment syntax is retained. A schema reference +can be present even if the schema file cannot be resolved. Schema entity IDs over +2048 UTF-8 bytes are hashed to stay within database index limits; reference metadata +is retained. Consumers must escape +metadata appropriately when displaying text or constructing links. The npm scripts are set up to deploy the subgraphs in one command. Mainnet is connected to a hook where it will be deployed automatically when the `master` branch is updated. Therefore, we never diff --git a/src/mappings/helpers/manifest.ts b/src/mappings/helpers/manifest.ts index 0b355170..f82e3c52 100644 --- a/src/mappings/helpers/manifest.ts +++ b/src/mappings/helpers/manifest.ts @@ -32,97 +32,40 @@ export function manifestStartBlock(value: YAMLValue): BigInt | null { return BigInt.fromString(text) } -// Decode the CID encodings normally used in published manifests. Unsupported -// encodings are skipped rather than passed to a host function that can abort. -function decodeCid(text: string): Uint8Array | null { - if (text.length == 0 || text.length > 128) return null - let base58 = text.startsWith('Qm') || text.startsWith('z') - if (base58) { - let encoded = text.startsWith('z') ? text.slice(1) : text - let alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' - let bytes = new Array() - for (let i = 0; i < encoded.length; i++) { - let carry = alphabet.indexOf(encoded.charAt(i)) - if (carry < 0) return null - for (let j = 0; j < bytes.length; j++) { - carry += i32(bytes[j]) * 58 - bytes[j] = u8(carry & 255) - carry >>= 8 - } - while (carry > 0) { - bytes.push(u8(carry & 255)) - carry >>= 8 - } - } - for (let i = 0; i < encoded.length && encoded.charAt(i) == '1'; i++) bytes.push(0) - let result = new Uint8Array(bytes.length) - for (let i = 0; i < bytes.length; i++) result[i] = bytes[bytes.length - i - 1] - return result - } - if (!text.startsWith('b') && !text.startsWith('B')) return null - let alphabet = text.startsWith('b') ? 'abcdefghijklmnopqrstuvwxyz234567' : 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' - let result = new Uint8Array((text.length - 1) * 5 / 8) - let bits = 0 - let buffer = 0 - let offset = 0 - for (let i = 1; i < text.length; i++) { - let digit = alphabet.indexOf(text.charAt(i)) - if (digit < 0) return null - buffer = (buffer << 5) | digit - bits += 5 - if (bits >= 8) { - bits -= 8 - result[offset++] = u8(buffer >> bits) - buffer &= (1 << bits) - 1 - } - } - return bits < 5 && buffer == 0 ? result : null -} - -function validCid(text: string): bool { - let bytes = decodeCid(text) - if (bytes === null) return false - if (text.startsWith('Qm')) { - return text.length == 46 && bytes.length == 34 && bytes[0] == 0x12 && bytes[1] == 0x20 - } - // CIDv1 contains four unsigned varints: version, codec, hash code, digest - // length; followed by the digest. Graph Node supports digests up to 64 bytes. - let offset = 0 - for (let field = 0; field < 4; field++) { - let value: u64 = 0 - let terminated = false - for (let i = 0; i < 10 && offset < bytes.length; i++) { - let byte = bytes[offset++] - if (i == 9 && byte > 1) return false - value |= u64(byte & 127) << (i * 7) - if ((byte & 128) == 0) { - if (i > 0 && byte == 0) return false - terminated = true - break - } - } - if (!terminated) return false - if (field == 0 && value != 1) return false - if (field == 3) return value <= 64 && value == u64(bytes.length - offset) +// Metadata is opaque text, not HTML or an executable URL. Keep basic bounds +// and reject embedded control characters without imposing format whitelists. +export function manifestMetadataString(value: YAMLValue | null, maxLength: i32): string | null { + let text = yamlString(value) + if (text === null || text.length > maxLength) return null + for (let i = 0; i < text.length; i++) { + let code = text.charCodeAt(i) + if (code < 32 || (code >= 127 && code <= 159)) return null } - return false + return text } -export function manifestSchemaPath(value: YAMLValue | null): string | null { +export function manifestSchemaReference(value: YAMLValue | null): string | null { // Published manifests use { '/': '/ipfs/CID' }; also accept string links. if (value !== null && value.isObject()) value = yamlField(value, '/') - let path = yamlString(value) - if (path === null || path.length > 2048) return null + return manifestMetadataString(value, 2048) +} + +// Normalize familiar references for stable metadata and entity IDs. This is +// not validation: Graph Node receives the original reference for resolution. +export function manifestSchemaPath(reference: string): string { + let path = reference + if (path.startsWith('http://') || path.startsWith('https://')) { + // Keep URL-specific syntax intact instead of reimplementing a URL parser. + if (path.includes('?') || path.includes('#')) return reference + let authorityStart = path.startsWith('https://') ? 8 : 7 + let pathStart = path.indexOf('/', authorityStart) + if (pathStart <= authorityStart) return reference + let ipfsStart = path.indexOf('/ipfs/', pathStart) + if (ipfsStart < 0) return reference + path = path.slice(ipfsStart + 6) + } if (path.startsWith('/ipfs/')) path = path.slice(6) else if (path.startsWith('ipfs://')) path = path.slice(7) - let segments = path.split('/') - if (!validCid(segments[0])) return null - for (let i = 1; i < segments.length; i++) { - if (segments[i].length == 0 || segments[i] == '.' || segments[i] == '..') return null - } - for (let i = 0; i < path.length; i++) { - let code = path.charCodeAt(i) - if (code <= 32 || code == 127 || path.charAt(i) == '?' || path.charAt(i) == '#' || path.charAt(i) == '%' || path.charAt(i) == '\\') return null - } - return path + while (path.endsWith('/')) path = path.slice(0, path.length - 1) + return path.length > 0 ? path : reference } diff --git a/src/mappings/ipfs.ts b/src/mappings/ipfs.ts index 30b5db86..8280af77 100644 --- a/src/mappings/ipfs.ts +++ b/src/mappings/ipfs.ts @@ -1,4 +1,4 @@ -import { json, Bytes, dataSource, JSONValueKind, log, DataSourceContext, BigInt, yaml, YAMLValue } from '@graphprotocol/graph-ts' +import { json, Bytes, dataSource, JSONValueKind, log, DataSourceContext, BigInt, yaml, YAMLValue, crypto } from '@graphprotocol/graph-ts' import { SubgraphMeta, SubgraphVersionMeta, @@ -10,7 +10,7 @@ import { SubgraphDeploymentSchema as SubgraphDeploymentSchemaTemplate } from '../types/templates' import { jsonToString } from './utils' -import { yamlField, yamlString, manifestStartBlock, manifestSchemaPath } from './helpers/manifest' +import { yamlField, yamlString, manifestStartBlock, manifestMetadataString, manifestSchemaReference, manifestSchemaPath } from './helpers/manifest' export function handleSubgraphMetadata(content: Bytes): void { let id = dataSource.context().getString("id") @@ -86,17 +86,23 @@ function manifestWarning(id: string, field: string): void { } function readManifestSchema(manifest: SubgraphDeploymentManifest, root: YAMLValue): void { - let path = manifestSchemaPath(yamlField(yamlField(root, 'schema'), 'file')) - if (path === null) { + let reference = manifestSchemaReference(yamlField(yamlField(root, 'schema'), 'file')) + if (reference === null) { manifestWarning(manifest.id, 'schema.file') return } + let path = manifestSchemaPath(reference) let schemaId = manifest.id.concat('-').concat(path) + // Immutable entity IDs are indexed. Preserve ordinary IDs while bounding + // their UTF-8 size even when references contain multibyte characters. + let schemaIdBytes = Bytes.fromUTF8(schemaId) + if (schemaIdBytes.length > 2048) schemaId = crypto.keccak256(schemaIdBytes).toHexString() manifest.schema = schemaId manifest.schemaIpfsHash = path let context = new DataSourceContext() context.setString('id', schemaId) - SubgraphDeploymentSchemaTemplate.createWithContext(path, context) + // Graph Node validates the original reference and skips invalid IPFS sources. + SubgraphDeploymentSchemaTemplate.createWithContext(reference, context) } function readManifestNetwork(manifest: SubgraphDeploymentManifest, root: YAMLValue): void { @@ -107,8 +113,8 @@ function readManifestNetwork(manifest: SubgraphDeploymentManifest, root: YAMLVal if (entries === null || !entries.isArray()) continue let sources = entries.toArray() for (let i = 0; i < sources.length; i++) { - let network = yamlString(yamlField(sources[i], 'network')) - if (network !== null && validManifestNetwork(network)) { + let network = manifestMetadataString(yamlField(sources[i], 'network'), 256) + if (network !== null) { manifest.network = network return } @@ -117,15 +123,6 @@ function readManifestNetwork(manifest: SubgraphDeploymentManifest, root: YAMLVal manifestWarning(manifest.id, 'network') } -function validManifestNetwork(network: string): bool { - if (network.length > 256) return false - for (let i = 0; i < network.length; i++) { - let code = network.charCodeAt(i) - if (code <= 32 || code == 127) return false - } - return true -} - function readManifestDataSources(manifest: SubgraphDeploymentManifest, root: YAMLValue): void { let sources = yamlField(root, 'dataSources') if (sources === null || !sources.isArray() || sources.toArray().length == 0) { @@ -152,8 +149,8 @@ function readManifestDataSources(manifest: SubgraphDeploymentManifest, root: YAM continue } let startBlockValue = yamlField(source, 'startBlock') - // A missing startBlock defaults to zero. An explicit null or malformed - // value is unknown, so we cannot reliably report a minimum. + // A missing startBlock defaults to zero. If any source is invalid, keep + // the manifest's zero default rather than reporting a partial minimum. let startBlock = startBlockValue === null ? BigInt.fromI32(0) : manifestStartBlock(startBlockValue) if (startBlock === null) { validStartBlocks = false @@ -168,24 +165,44 @@ function readManifestDataSources(manifest: SubgraphDeploymentManifest, root: YAM else manifestWarning(manifest.id, 'dataSources.source.startBlock') } +// Keep field extraction independent of YAML decoding so defaults also apply +// to undecodable documents. +export function parseSubgraphDeploymentManifest( + id: string, + content: string, + root: YAMLValue | null, +): SubgraphDeploymentManifest { + let manifest = new SubgraphDeploymentManifest(id) + manifest.manifest = content + // Preserve the defaults exposed by the original parser, including when + // malformed or incomplete manifests prevent further field extraction. + manifest.startBlock = BigInt.fromI32(0) + manifest.poweredBySubstreams = false + if (root !== null && root.isObject()) { + readManifestSchema(manifest, root) + readManifestNetwork(manifest, root) + readManifestDataSources(manifest, root) + } + return manifest +} + export function handleSubgraphDeploymentManifest(content: Bytes): void { - let manifest = new SubgraphDeploymentManifest(dataSource.stringParam()) - manifest.manifest = content.toString() - // Match the native parser's input limit; retain the raw manifest on failure. + let id = dataSource.stringParam() + let root: YAMLValue | null = null + // Match the native parser's input limit; retain the raw document and defaults + // when it cannot be decoded. if (content.length > 10000000) { - manifestWarning(manifest.id, 'manifest size') - manifest.save() - return - } - let parsed = yaml.try_fromBytes(content) - if (!parsed.isOk) { - manifestWarning(manifest.id, 'YAML') - } else if (!parsed.value.isObject()) { - manifestWarning(manifest.id, 'manifest root') + manifestWarning(id, 'manifest size') } else { - readManifestSchema(manifest, parsed.value) - readManifestNetwork(manifest, parsed.value) - readManifestDataSources(manifest, parsed.value) + let parsed = yaml.try_fromBytes(content) + if (!parsed.isOk) { + manifestWarning(id, 'YAML') + } else if (!parsed.value.isObject()) { + manifestWarning(id, 'manifest root') + } else { + root = parsed.value + } } + let manifest = parseSubgraphDeploymentManifest(id, content.toString(), root) manifest.save() } diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 00000000..5ce07013 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,380 @@ +import { Bytes, json, JSONValue, JSONValueKind, TypedMap, YAMLValue } from '@graphprotocol/graph-ts' +import { afterEach, assert, clearStore, test } from 'matchstick-as/assembly/index' +import { parseSubgraphDeploymentManifest } from '../src/mappings/ipfs' +import { manifestSchemaReference } from '../src/mappings/helpers/manifest' + +const manifestID = 'manifest' +const schemaCID = 'QmNVavEGcPsZ651fcvnRJNuLim3HFJxx1vmE9iALRPywBm' + +// Matchstick does not implement yaml.try_fromBytes yet. Build the YAML values +// a decoder would return, then exercise the production field extraction and store. +function yamlValue(value: JSONValue): YAMLValue { + if (value.kind == JSONValueKind.NULL) return YAMLValue.newNull() + if (value.kind == JSONValueKind.BOOL) return YAMLValue.newBool(value.toBool()) + if (value.kind == JSONValueKind.NUMBER) return YAMLValue.newI64(value.toI64()) + if (value.kind == JSONValueKind.STRING) return YAMLValue.newString(value.toString()) + if (value.kind == JSONValueKind.ARRAY) { + let values = value.toArray() + let result = new Array() + for (let i = 0; i < values.length; i++) result.push(yamlValue(values[i])) + return YAMLValue.newArray(result) + } + let entries = value.toObject().entries + let object = new TypedMap() + for (let i = 0; i < entries.length; i++) { + object.set(YAMLValue.newString(entries[i].key), yamlValue(entries[i].value)) + } + return YAMLValue.newObject(object) +} + +function parse(root: string, content: string = ''): void { + parseSubgraphDeploymentManifest(manifestID, content, yamlValue(json.fromString(root))).save() +} + +function assertDefaults(): void { + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'startBlock', '0') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'poweredBySubstreams', 'false') +} + +afterEach(() => { + clearStore() +}) + +test('missing, empty and invalid dataSources preserve both legacy defaults', () => { + let roots = [ + '{}', + '{"dataSources":[]}', + '{"dataSources":null}', + '{"dataSources":{}}', + '{"dataSources":"invalid"}', + ] + for (let i = 0; i < roots.length; i++) { + clearStore() + parse(roots[i]) + assertDefaults() + } +}) + +test('undecodable documents retain raw content and legacy defaults', () => { + let content = 'dataSources: [\n' + parseSubgraphDeploymentManifest(manifestID, content, null).save() + assertDefaults() + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'manifest', content) +}) + +test('non-object YAML roots retain legacy defaults', () => { + let roots = ['null', '[]', '"text"', '123', 'false'] + for (let i = 0; i < roots.length; i++) { + clearStore() + parse(roots[i]) + assertDefaults() + } +}) + +test('missing or invalid source prevents a misleading partial minimum', () => { + let invalidSources = ['{}', '{"source":null}', '{"source":[]}', '{"source":"bad"}', 'null'] + for (let i = 0; i < invalidSources.length; i++) { + clearStore() + parse( + '{"dataSources":[{"kind":"ethereum/contract","source":{"startBlock":100}},' + + invalidSources[i] + + ']}', + ) + assertDefaults() + } +}) + +test('omitting startBlock from a valid source defaults to zero', () => { + parse('{"dataSources":[{"kind":"ethereum/contract","source":{}}]}') + assertDefaults() +}) + +test('null and invalid start blocks use zero without aborting', () => { + let invalidBlocks = [ + 'null', + 'true', + '[]', + '{}', + '-1', + '"1.5"', + '"bad"', + '""', + '"18446744073709551616"', + ] + for (let i = 0; i < invalidBlocks.length; i++) { + clearStore() + parse( + '{"dataSources":[{"kind":"ethereum/contract","source":{"startBlock":' + + invalidBlocks[i] + + '}}]}', + ) + assertDefaults() + } +}) + +test('minimum preserves zero even when later sources have positive start blocks', () => { + parse( + '{"dataSources":[{"kind":"ethereum/contract","source":{"startBlock":0}},{"kind":"ethereum/contract","source":{"startBlock":100}}]}', + ) + assertDefaults() +}) + +test('valid numeric and quoted blocks still produce the actual minimum', () => { + parse( + '{"dataSources":[{"kind":"ethereum/contract","source":{"startBlock":100}},{"kind":"ethereum/contract","source":{"startBlock":"42"}}]}', + ) + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'startBlock', '42') +}) + +test('maximum unsigned block number remains supported without rounding', () => { + parse( + '{"dataSources":[{"kind":"ethereum/contract","source":{"startBlock":"18446744073709551615"}}]}', + ) + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'startBlock', '18446744073709551615') +}) + +test('missing kind defaults to false without discarding a valid start block', () => { + parse('{"dataSources":[{"source":{"startBlock":42}}]}') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'startBlock', '42') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'poweredBySubstreams', 'false') +}) + +test('a Substreams source is detected even alongside missing kinds and invalid sources', () => { + parse('{"dataSources":[{}, {"kind":"substreams"}]}') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'startBlock', '0') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'poweredBySubstreams', 'true') +}) + +test('templates, context and comments do not change source kind or minimum', () => { + parse( + '{"dataSources":[{"kind":"ethereum/contract","source":{"startBlock":100},"context":{"startBlock":1}}],"templates":[{"kind":"substreams","source":{"startBlock":0}}]}', + '# - kind: substreams\n# startBlock: 0\n', + ) + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'startBlock', '100') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'poweredBySubstreams', 'false') +}) + +test('decoded network takes precedence over text in comments and is trimmed', () => { + parse('{"dataSources":[{"network":" mainnet "}]}', '# network: wrong\n') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'network', 'mainnet') +}) + +test('template network still takes precedence over text in comments', () => { + parse('{"templates":[{"network":"arbitrum-one"}]}', '# network: wrong\n') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'network', 'arbitrum-one') +}) + +test('decoded schema reference takes precedence over text in comments', () => { + parse('{"schema":{"file":{"/":"/ipfs/' + schemaCID + '"}}}', '# schema:\n# file: /ipfs/wrong\n') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'schemaIpfsHash', schemaCID) + assert.fieldEquals( + 'SubgraphDeploymentManifest', + manifestID, + 'schema', + manifestID + '-' + schemaCID, + ) + assert.dataSourceExists('SubgraphDeploymentSchema', '/ipfs/' + schemaCID) +}) + +test('string schema links and IPFS URI normalization remain supported', () => { + parse('{"schema":{"file":"ipfs://' + schemaCID + '/schema.graphql"}}') + assert.fieldEquals( + 'SubgraphDeploymentManifest', + manifestID, + 'schemaIpfsHash', + schemaCID + '/schema.graphql', + ) + assert.dataSourceExists('SubgraphDeploymentSchema', 'ipfs://' + schemaCID + '/schema.graphql') +}) + +test('comments and misplaced fields cannot supply network or schema metadata', () => { + let root = yamlValue( + json.fromString( + '{"context":{"network":"mainnet","schema":{"file":"/ipfs/' + schemaCID + '"}}}', + ), + ) + let content = '# network: mainnet\n# schema:\n# file: /ipfs/' + schemaCID + '\n' + let manifest = parseSubgraphDeploymentManifest(manifestID, content, root) + assert.assertNull(manifest.network) + assert.assertNull(manifest.schemaIpfsHash) + assert.assertNull(manifest.schema) +}) + +test('unrecognized schema references are retained for Graph Node to validate', () => { + // Matchstick records the request; Graph Node logs/skips invalid references. + assertSchemaPath('/ipfs/not-a-cid', 'not-a-cid') +}) + +test('absent network and schema are not replaced with invented strings', () => { + let manifest = parseSubgraphDeploymentManifest(manifestID, '{}', yamlValue(json.fromString('{}'))) + assert.assertNull(manifest.network) + assert.assertNull(manifest.schemaIpfsHash) + assert.assertNull(manifest.schema) +}) + +// Generated from schemaCID using multiformats; all identify the same content. +const schemaCIDBase16 = 'f017012200249f0d875bbdcd83ca3e477cfc8363c7d9f1dadec3aafbf6f662b7c211a9968' +const schemaCIDBase36 = 'k2jmtxrf5wu8a5vjq1gbchq8qpcfe9s1z4h3xsc3mi50yzb67ugc4go8' + +function assertSchemaPath(input: string, expected: string): void { + clearStore() + parse('{"schema":{"file":{"/":"' + input + '"}}}') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'schemaIpfsHash', expected) + assert.fieldEquals( + 'SubgraphDeploymentManifest', + manifestID, + 'schema', + manifestID + '-' + expected, + ) + assert.dataSourceExists('SubgraphDeploymentSchema', input) +} + +test('CIDv1 base16 and base36 schemas are ingested in both letter cases', () => { + let cids = [ + schemaCIDBase16, + schemaCIDBase16.toUpperCase(), + schemaCIDBase36, + schemaCIDBase36.toUpperCase(), + ] + for (let i = 0; i < cids.length; i++) assertSchemaPath('/ipfs/' + cids[i], cids[i]) +}) + +test('CIDv1 base32 and base58 schemas remain supported', () => { + let cids = [ + 'bafybeiacjhynq5n33tmdzi7eo7h4qnr4pwpr3lpmhkx3633gfn6ccguzna', + 'BAFYBEIACJHYNQ5N33TMDZI7EO7H4QNR4PWPR3LPMHKX3633GFN6CCGUZNA', + 'zdj7WVaqHfLTsM9xnKbMfk3AkjuLyEixrP5tFAkpWZ1EqYqMh', + ] + for (let i = 0; i < cids.length; i++) assertSchemaPath('/ipfs/' + cids[i], cids[i]) +}) + +test('base16 accepts a valid 64-byte digest longer than the old text limit', () => { + let cid = + 'f0170134001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101' + assertSchemaPath('/ipfs/' + cid, cid) +}) + +test('gateway schema URLs normalize to CID and optional file path', () => { + let prefixes = [ + 'https://ipfs.io/ipfs/', + 'http://localhost:8080/ipfs/', + 'https://gateway.example/prefix/ipfs/', + ] + for (let i = 0; i < prefixes.length; i++) { + assertSchemaPath(prefixes[i] + schemaCID, schemaCID) + assertSchemaPath( + prefixes[i] + schemaCIDBase36 + '/schema.graphql', + schemaCIDBase36 + '/schema.graphql', + ) + } +}) + +test('trailing slashes normalize for plain CIDs, IPFS links and gateway URLs', () => { + let prefixes = ['', '/ipfs/', 'ipfs://', 'https://ipfs.io/ipfs/'] + for (let i = 0; i < prefixes.length; i++) { + assertSchemaPath(prefixes[i] + schemaCID + '/', schemaCID) + assertSchemaPath(prefixes[i] + schemaCID + '/schema.graphql///', schemaCID + '/schema.graphql') + } +}) + +test('all CID encodings are passed through without an encoding whitelist', () => { + let cids = [ + 'uAXASIAJJ8Nh1u9zYPKPkd8_INjx9nx2t7Dqvv29mK3whGplo', + 'v05o1480297odgtdrrjc3p8v4ev7sgdhsfmfhrbfc7anrurr65du226kpd0', + 'cafybeiacjhynq5n33tmdzi7eo7h4qnr4pwpr3lpmhkx3633gfn6ccguzna======', + ] + for (let i = 0; i < cids.length; i++) assertSchemaPath('/ipfs/' + cids[i], cids[i]) +}) + +test('literal IPFS filenames retain spaces and URL-like characters', () => { + let names = [ + 'my schema.graphql', + 'schema%20name.graphql', + 'schema#v1.graphql', + 'schema+name.graphql', + ] + for (let i = 0; i < names.length; i++) { + assertSchemaPath('/ipfs/' + schemaCID + '/' + names[i], schemaCID + '/' + names[i]) + } +}) + +test('RPC URLs and query or fragment syntax are delegated intact to Graph Node', () => { + let references = [ + 'https://ipfs.example/api/v0/cat?arg=/ipfs/' + schemaCID, + 'https://ipfs.example/api/v0/cat?arg=%2Fipfs%2F' + schemaCID, + 'https://gateway.example/ipfs/' + schemaCID + '/schema.graphql?download=true', + 'https://gateway.example/ipfs/' + schemaCID + '#fragment', + 'https://gateway.example/?next=/ipfs/' + schemaCID, + 'https://gateway.example/#/ipfs/' + schemaCID, + ] + for (let i = 0; i < references.length; i++) assertSchemaPath(references[i], references[i]) +}) + +test('gateway URL encoding and path resolution use the original reference', () => { + // Normalized metadata must never replace the reference Graph Node resolves. + let input = 'https://gateway.example/ipfs/' + schemaCID + '/folder/../schema%20name.graphql' + assertSchemaPath(input, schemaCID + '/folder/../schema%20name.graphql') +}) + +test('empty normalized paths do not erase nonempty metadata', () => { + let references = ['/ipfs/', 'ipfs://', '/'] + for (let i = 0; i < references.length; i++) assertSchemaPath(references[i], references[i]) +}) + +test('multibyte references retain metadata without oversized schema entity IDs', () => { + let reference = 'https://gateway.example/ipfs/' + schemaCID + '#' + new Array(1900).fill('界').join('') + let root = yamlValue(json.fromString('{"schema":{"file":"' + reference + '"}}')) + let manifest = parseSubgraphDeploymentManifest(manifestID, '', root) + assert.stringEquals(manifest.schemaIpfsHash!, reference) + assert.assertTrue(Bytes.fromUTF8(manifest.schema!).length <= 2048) + assert.dataSourceExists('SubgraphDeploymentSchema', reference) + let repeated = parseSubgraphDeploymentManifest(manifestID, '', root) + let other = parseSubgraphDeploymentManifest('other-manifest', '', root) + assert.stringEquals(manifest.schema!, repeated.schema!) + assert.assertTrue(manifest.schema! != other.schema!) +}) + +test('schema IDs preserve the UTF-8 byte boundary and hash only oversized IDs', () => { + let reference = new Array(2048 - manifestID.length - 1).fill('a').join('') + assertSchemaPath(reference, reference) + let root = yamlValue(json.fromString('{"schema":{"file":"' + reference + 'a"}}')) + let manifest = parseSubgraphDeploymentManifest(manifestID, '', root) + assert.stringEquals(manifest.schemaIpfsHash!, reference + 'a') + assert.assertTrue(Bytes.fromUTF8(manifest.schema!).length <= 2048) + assert.dataSourceExists('SubgraphDeploymentSchema', reference + 'a') +}) + +test('schema references require a nonempty string of bounded size', () => { + let invalid = ['null', 'false', '12', '[]', '{}', '{"/":42}', '""', '" "'] + for (let i = 0; i < invalid.length; i++) { + assert.assertNull(manifestSchemaReference(yamlValue(json.fromString(invalid[i])))) + } + let boundary = new Array(2048).fill('a').join('') + assert.stringEquals(manifestSchemaReference(YAMLValue.newString(boundary))!, boundary) + assert.assertNull(manifestSchemaReference(YAMLValue.newString(boundary + 'a'))) +}) + +test('embedded control characters are rejected while ordinary whitespace is retained', () => { + let controls = ['\u0000', '\t', '\n', '\r', '\u001f', '\u007f', '\u0085', '\u009f'] + for (let i = 0; i < controls.length; i++) { + assert.assertNull( + manifestSchemaReference(YAMLValue.newString('/ipfs/' + schemaCID + '/a' + controls[i] + 'b')), + ) + } + assert.stringEquals( + manifestSchemaReference(YAMLValue.newString(' /ipfs/' + schemaCID + '/my schema.graphql '))!, + '/ipfs/' + schemaCID + '/my schema.graphql', + ) +}) + +test('network metadata uses basic string checks without a format whitelist', () => { + parse('{"dataSources":[{"network":" Example network "}]}') + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'network', 'Example network') +}) + +test('unusable network fields still fall back to the first usable template network', () => { + parse( + '{"dataSources":[{"network":123},{"network":"bad\\u0000network"}],"templates":[{"network":"mainnet"}]}', + ) + assert.fieldEquals('SubgraphDeploymentManifest', manifestID, 'network', 'mainnet') +})