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
38 changes: 38 additions & 0 deletions src/assessment/gateway-assessor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
6 changes: 4 additions & 2 deletions src/assessment/gateway-assessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -200,8 +201,9 @@ export class GatewayAssessor {
host: string;
names: string[];
}): Promise<ArnsNameAssessments> {
const assessableNames = names.filter(isAssessableArnsName);
return pMap(
names,
assessableNames,
async (name) => {
try {
return await this.assessArnsName({ host, arnsName: name });
Expand All @@ -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);
});
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/lib/arns-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/names/solana-names-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 5 additions & 1 deletion src/names/solana-names-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
11 changes: 8 additions & 3 deletions src/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -448,8 +449,9 @@ export class Observer {
names: string[];
entropy: Buffer;
}): Promise<ArnsNameAssessments> {
const assessableNames = names.filter(isAssessableArnsName);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return pMap(
names,
assessableNames,
async (name) => {
try {
return await this.assessArnsName({
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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,
Expand Down