diff --git a/src/assessment/gateway-assessor.test.ts b/src/assessment/gateway-assessor.test.ts new file mode 100644 index 0000000..4686556 --- /dev/null +++ b/src/assessment/gateway-assessor.test.ts @@ -0,0 +1,38 @@ +/** + * AR.IO Observer + * Copyright (C) 2022-2026 Permanent Data Solutions, Inc. All Rights Reserved. + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as winston from 'winston'; + +import { GatewayAssessor } from './gateway-assessor.js'; + +describe('GatewayAssessor', () => { + describe('assessArnsNames', () => { + it('leaves out names that are not valid hostname labels', async () => { + const referenceGateway = { + getArnsResolution: sinon.stub(), + checkChunkAvailability: sinon.stub(), + getChunkMetadata: sinon.stub(), + }; + const assessor = new GatewayAssessor({ + referenceGateway, + nodeReleaseVersion: 'test-version', + nameAssessmentConcurrency: 1, + log: winston.createLogger({ silent: true }), + }); + + // punycode for U+25B6 U+FE0F, rejected by the URL parser + const assessments = await assessor.assessArnsNames({ + host: 'gateway.example.com', + names: ['xn--g1h4420f'], + }); + + expect(assessments).to.deep.equal({}); + expect(referenceGateway.getArnsResolution.called).to.equal(false); + }); + }); +}); diff --git a/src/assessment/gateway-assessor.ts b/src/assessment/gateway-assessor.ts index 90e4ef8..e2bd9c6 100644 --- a/src/assessment/gateway-assessor.ts +++ b/src/assessment/gateway-assessor.ts @@ -20,6 +20,7 @@ import { Got } from 'got'; import pMap from 'p-map'; import { Logger } from 'winston'; +import { isAssessableArnsName } from '../lib/arns-validation.js'; import { createGatewayHttpClient } from '../lib/http-client.js'; import * as metrics from '../metrics.js'; import { assessOwnership, getArnsResolution } from '../observer.js'; @@ -200,8 +201,9 @@ export class GatewayAssessor { host: string; names: string[]; }): Promise { + const assessableNames = names.filter(isAssessableArnsName); return pMap( - names, + assessableNames, async (name) => { try { return await this.assessArnsName({ host, arnsName: name }); @@ -227,7 +229,7 @@ export class GatewayAssessor { { concurrency: this.nameAssessmentConcurrency }, ).then((results) => { return results.reduce((assessments, assessment, index) => { - assessments[names[index]] = assessment; + assessments[assessableNames[index]] = assessment; return assessments; }, {} as ArnsNameAssessments); }); diff --git a/src/config.ts b/src/config.ts index 09277f9..147b3ca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ import { fileURLToPath } from 'node:url'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { isAssessableArnsName } from './lib/arns-validation.js'; import * as env from './lib/env.js'; export const args = await yargs(hideBin(process.argv)) @@ -115,6 +116,15 @@ export const ARNS_NAMES = parseCommaSeparatedList( env.varOrDefault('ARNS_NAMES', args.arnsNames ?? ''), ); +const invalidArnsNames = ARNS_NAMES.filter( + (name) => !isAssessableArnsName(name), +); +if (invalidArnsNames.length > 0) { + throw new Error( + `Invalid configuration: ARNS_NAMES contains names that are not valid hostname labels: ${invalidArnsNames.join(', ')}.`, + ); +} + // Whitepaper v3.0.0 §10.3: "eight (8) 'chosen names' selected // independently by each observer". The default lived at `1` since the // constant was introduced in AO days, predating the v3 spec — never diff --git a/src/lib/arns-validation.ts b/src/lib/arns-validation.ts index 1bc31e3..faed3a2 100644 --- a/src/lib/arns-validation.ts +++ b/src/lib/arns-validation.ts @@ -18,6 +18,13 @@ import { ArnsResolution } from '../types.js'; +// Registered names can still be invalid hostname labels (xn--g1h4420f is +// punycode for U+25B6 U+FE0F, which IDNA rejects). No gateway can be reached +// under such a name, so it can't be used for assessment. +export function isAssessableArnsName(name: string): boolean { + return URL.canParse(`https://${name}.invalid/`); +} + export function validateArnsResolutionHeaders( resolution: ArnsResolution, source: string, diff --git a/src/names/solana-names-source.test.ts b/src/names/solana-names-source.test.ts index 5dba663..c509abc 100644 --- a/src/names/solana-names-source.test.ts +++ b/src/names/solana-names-source.test.ts @@ -158,6 +158,20 @@ describe('SolanaNamesSource', () => { const names = await src.getAllNames(0); expect(names).to.deep.equal(['also-real', 'real']); }); + + it('skips names that are not valid hostname labels', async () => { + const stub = sinon.stub().resolves({ + // xn--g1h4420f is punycode for U+25B6 U+FE0F, rejected by URL + items: [{ name: 'real' }, { name: 'xn--g1h4420f' }], + nextCursor: undefined, + }); + // only getArNSRecords is used by getAllNames + const readable = { + getArNSRecords: stub, + } as unknown as SolanaARIOReadable; + const src = new SolanaNamesSource({ readable, log: makeLog() }); + expect(await src.getAllNames(0)).to.deep.equal(['real']); + }); }); describe('convenience methods', () => { diff --git a/src/names/solana-names-source.ts b/src/names/solana-names-source.ts index ab1968f..6b1823b 100644 --- a/src/names/solana-names-source.ts +++ b/src/names/solana-names-source.ts @@ -22,6 +22,7 @@ import type winston from 'winston'; import type { SolanaARIOReadable } from '@ar.io/sdk'; +import { isAssessableArnsName } from '../lib/arns-validation.js'; import type { ArnsNameList, ArnsNamesSource } from '../types.js'; export interface SolanaNamesSourceConfig { @@ -98,7 +99,10 @@ export class SolanaNamesSource implements ArnsNamesSource, ArnsNameList { limit: this.pageSize, }); for (const record of page.items) { - if (record.name) seen.add(record.name); + // Unusable names stay out so the chosen-name sampler picks another. + if (record.name && isAssessableArnsName(record.name)) { + seen.add(record.name); + } } cursor = page.nextCursor; pages += 1; diff --git a/src/observer.test.ts b/src/observer.test.ts index 56b454c..a9d5033 100644 --- a/src/observer.test.ts +++ b/src/observer.test.ts @@ -492,6 +492,20 @@ describe('Observer', function () { nock.cleanAll(); }); + describe('assessArnsNames', function () { + it('leaves out names that are not valid hostname labels', async function () { + // punycode for U+25B6 U+FE0F, rejected by the URL parser + const assessments = await observer.assessArnsNames({ + host: 'gateway.example.com', + names: ['xn--g1h4420f'], + entropy: Buffer.alloc(32), + }); + + expect(assessments).to.deep.equal({}); + expect(referenceGatewayStub.getArnsResolution.called).to.equal(false); + }); + }); + describe('calculateFailureRate', function () { it('should return 0 for empty report', function () { const report: ObserverReport = createReport({ diff --git a/src/observer.ts b/src/observer.ts index 004061c..f45c13d 100644 --- a/src/observer.ts +++ b/src/observer.ts @@ -27,6 +27,7 @@ import { selectGatewaysForOffsetAssessment, } from './assessment/offset-assessor.js'; import * as config from './config.js'; +import { isAssessableArnsName } from './lib/arns-validation.js'; import { customHashPRNG } from './lib/prng.js'; import log from './log.js'; import * as metrics from './metrics.js'; @@ -448,8 +449,9 @@ export class Observer { names: string[]; entropy: Buffer; }): Promise { + const assessableNames = names.filter(isAssessableArnsName); return pMap( - names, + assessableNames, async (name) => { try { return await this.assessArnsName({ @@ -479,7 +481,7 @@ export class Observer { { concurrency: this.nameAssessmentConcurrency }, ).then((results) => { return results.reduce((assessments, assessment, index) => { - assessments[names[index]] = assessment; + assessments[assessableNames[index]] = assessment; return assessments; }, {} as ArnsNameAssessments); }); @@ -626,7 +628,10 @@ export class Observer { enforcementEnabled: config.OFFSET_OBSERVATION_ENFORCEMENT_ENABLED, }); - const nameCount = new Set([...prescribedNames, ...chosenNames]).size; + const nameCount = Object.keys({ + ...prescribedAssessments, + ...chosenAssessments, + }).length; const namePassCount = Object.values({ ...prescribedAssessments, ...chosenAssessments,