diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index a3840e3..71ff097 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -1,10 +1,13 @@ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { loadEarnProfile } from '../../../core/bundle.js' +import { deploymentIntentHash } from '../../../core/profile.js' +import { resolveEarnFactoryRelease } from '../../../core/releases.js' import type { EarnStack } from '../../../core/stack.js' -import { policySiblingStacks, siblingEnvironment } from './cli.js' +import { policySiblingStacks, redactUrls, siblingEnvironment } from './cli.js' const venue = '0x0d136e26b4d9e4836fc5cb202baeb49cb6f5df48' @@ -14,7 +17,7 @@ function repository(neighbours: Record { mkdirSync(join(directory, id), { recursive: true }) writeFileSync(join(directory, id, 'profile.json'), JSON.stringify(profile)) - if (pending) writeFileSync(join(directory, id, 'deployment.pending.json'), '{}') + if (pending) writePending(join(directory, id)) } const profiles = join(root, 'profiles') write(profiles, 'selected-moderato', authored('selected-moderato')) @@ -24,6 +27,29 @@ function repository(neighbours: Record { 'other-moderato': { profile: authored('other-moderato'), pending: true }, }) await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( - /profile other-moderato: it has an unpublished deployment on policy 469/, + /profile other-moderato: it has an unpublished deployment/, ) }) - it('ignores an unpublished deployment on another policy', async () => { + it('ignores a pending deployment bound to another policy', async () => { const { selected: path } = repository({ 'other-moderato': { profile: authored('other-moderato', '470'), pending: true }, }) await expect(policySiblingStacks(path, selected, genesis)).resolves.toEqual([]) }) + it('stops when a pending deployment no longer matches its edited profile', async () => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato', '470'), pending: true }, + }) + writeFileSync( + join(profiles, 'other-moderato', 'profile.json'), + JSON.stringify(authored('other-moderato', '471')), + ) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato: Pending deployment .* does not match this profile/, + ) + }) + it('names an unparseable neighbour instead of failing anonymously', async () => { const { selected: path } = repository({ - 'broken-moderato': { profile: { id: 'broken-moderato' }, pending: true }, + 'broken-moderato': { profile: { id: 'broken-moderato' } }, }) + writeFileSync(join(dirname(path), '..', 'broken-moderato', 'deployment.pending.json'), '{}') await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( /profile broken-moderato:/, ) @@ -75,7 +115,7 @@ describe('policy sibling discovery', () => { join(elsewhere, 'linked-moderato', 'profile.json'), JSON.stringify(authored('linked-moderato')), ) - writeFileSync(join(elsewhere, 'linked-moderato', 'deployment.pending.json'), '{}') + writePending(join(elsewhere, 'linked-moderato')) symlinkSync(join(elsewhere, 'linked-moderato'), join(profiles, 'linked-moderato')) await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( /profile linked-moderato: it has an unpublished deployment/, @@ -125,6 +165,44 @@ describe('policy sibling discovery', () => { ) }) + describe('a recorded neighbour naming another genesis of this chain', () => { + const earnShare = '0x00000000000000000000000000000000000000E5' + const setup = () => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato') }, + }) + writeFileSync( + join(profiles, 'other-moderato', 'deployment.json'), + JSON.stringify({ + target: { chainId: 42431, genesisHash: '0x' + '33'.repeat(32) }, + addresses: { earnShare }, + }), + ) + return path + } + + it('is skipped when its EarnShare has no code here', async () => { + const getCode = vi.fn(async () => undefined) + await expect(policySiblingStacks(setup(), selected, genesis, { getCode })).resolves.toEqual( + [], + ) + expect(getCode).toHaveBeenCalledWith({ address: earnShare }) + }) + + it('stops the removal when its EarnShare has code here', async () => { + const getCode = vi.fn(async () => '0x6080' as const) + await expect(policySiblingStacks(setup(), selected, genesis, { getCode })).rejects.toThrow( + /profile other-moderato: its record names genesis 0x3333.*may be live on this chain/, + ) + }) + + it('stops the removal when liveness cannot be checked', async () => { + await expect(policySiblingStacks(setup(), selected, genesis)).rejects.toThrow( + /may be live on this chain/, + ) + }) + }) + it('strictly loads a recorded neighbour on the same chain and genesis', async () => { const { profiles, selected: path } = repository({ 'other-moderato': { profile: authored('other-moderato', '470') }, @@ -185,3 +263,43 @@ describe('sibling environment', () => { }) }) }) + +describe('neighbour failure messages', () => { + it('redact a whole URL whose path contains parentheses', () => { + expect( + redactUrls('failed: https://rpc.example/(v2)/fake-api-secret?key=fake-query-secret.'), + ).toBe('failed: https://rpc.example/[redacted].') + }) + + it('redact a whole URL whose credentials contain quotes or angle brackets', () => { + for (const secret of ["pa'ss", 'pa"ss', 'pass']) { + const out = redactUrls(`failed: https://user:${secret}@rpc.example/v2 next`) + expect(out).toBe('failed: https://rpc.example/[redacted] next') + } + }) + + it('keep quotes and angle brackets around a URL outside it', () => { + expect(redactUrls('url "https://rpc.example/v2/key" or .')).toBe( + 'url "https://rpc.example/[redacted]" or .', + ) + }) + + it('handle a long punctuation run in linear time', () => { + const run = '.'.repeat(200_000) + const started = performance.now() + expect(redactUrls(`https://rpc.example/${run}x${run}`)).toBe( + `https://rpc.example/[redacted]${run}`, + ) + expect(performance.now() - started).toBeLessThan(1_000) + }) + + it('keep only the origin of each URL', () => { + expect( + redactUrls( + 'HTTP request failed. URL: https://rpc.example/v2/sk_live_123?key=abc Details: x (https://rpc.example)', + ), + ).toBe( + 'HTTP request failed. URL: https://rpc.example/[redacted] Details: x (https://rpc.example)', + ) + }) +}) diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index 4616282..2ae4b48 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs' import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' -import { getAddress } from 'viem' +import { getAddress, isAddress, type Address, type PublicClient } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { createClient } from 'viem/tempo' @@ -11,6 +11,7 @@ import { loadDeploymentEnvironment, loadEarnProfile, loadEarnProfileBundle, + loadEarnProfileForRead, loadReviewedDeploymentIdentity, pendingDeploymentPath, profileDeploymentPath, @@ -87,7 +88,7 @@ async function access(argv: readonly string[], output: Pick`, - * which alone supplies its reviewed identity and, when it sets any, its RPC settings. + * and is named. A neighbour with only an unpublished deployment stops it when that deployment, + * bound to its authored profile through the pending intent hash, targets this chain and policy, or + * when that binding fails: its contracts may already be bound to the policy. A record naming + * another genesis excludes a neighbour only when its EarnShare has no code on this chain. Each + * neighbour runs with its own `.env.`, which alone supplies its reviewed identity and, when it + * names any, its RPC settings. */ export async function policySiblingStacks( profilePath: string, stack: EarnStack, genesisHash: string, + client?: Pick, ) { if (stack.access.kind !== 'tip403-whitelist') return [] const root = profileRepositoryRoot(profilePath) @@ -132,7 +137,7 @@ export async function policySiblingStacks( const recorded = existsSync(profileDeploymentPath(path)) if (!recorded && !existsSync(pendingDeploymentPath(path))) continue const fail = (reason: unknown): never => { - const detail = reason instanceof Error ? reason.message : String(reason) + const detail = redactUrls(reason instanceof Error ? reason.message : String(reason)) throw new Error(`Cannot check removal safety against profile ${name}: ${detail}`, { cause: reason, }) @@ -141,13 +146,24 @@ export async function policySiblingStacks( const authored = loadEarnProfile(path) if (authored.id === stack.id) continue if (!recorded) { - if (authored.chainId === stack.network.chainId && transferPolicyId(authored) === policyId) { + // Bind the authored profile to its pending intent first, so an edited profile cannot hide it. + const pending = loadEarnProfileForRead(path) + if (pending.chainId === stack.network.chainId && transferPolicyId(pending) === policyId) { fail(`it has an unpublished deployment on policy ${policyId}; publish its record first`) } continue } - // Only a record on this chain and genesis can share the policy; others need no strict load. - if (!sameTarget(path, authored.chainId, stack.network.chainId, genesisHash)) continue + // Only a record that may target this chain can share the policy; others need no strict load. + const target = recordTarget(path, authored.chainId, stack.network.chainId, genesisHash) + if (target.kind === 'other-chain') continue + if (target.kind === 'other-genesis') { + // A wrong genesis must not hide a live stack: skip only when its EarnShare is not here. + const code = client && (await client.getCode({ address: target.earnShare })) + if (client && (!code || code === '0x')) continue + fail( + `its record names genesis ${target.genesisHash}, but its EarnShare may be live on this chain`, + ) + } const values = siblingEnvironment(root, authored.id) const identityPath = values.EARN_TRUSTED_DEPLOYMENT_IDENTITY?.trim() const identity = identityPath @@ -155,7 +171,6 @@ export async function policySiblingStacks( : undefined const { profile, record } = loadEarnProfileBundle(path, identity) if (record.target.chainId !== stack.network.chainId) continue - if (record.target.genesisHash.toLowerCase() !== genesisHash.toLowerCase()) continue if (transferPolicyId(profile) !== policyId) continue siblings.push((await loadHostedProfileStack(path, values, identity)).stack) } catch (error) { @@ -169,33 +184,68 @@ export async function policySiblingStacks( } /** - * Whether a generated record may target this chain and genesis, read before the strict load so an - * unrelated neighbour never blocks a removal. Only a well-formed target that agrees with its - * authored chain excludes a neighbour; anything malformed or inconsistent counts as possibly the - * same, so the strict load rejects it. + * Classifies a generated record's target before the strict load, so a neighbour on another chain + * never blocks a removal. Only a well-formed target that agrees with its authored chain is trusted + * to differ; anything malformed or inconsistent counts as possibly the same, so the strict load + * rejects it. */ -function sameTarget( +function recordTarget( profilePath: string, authoredChainId: number, chainId: number, genesisHash: string, -) { +): + | { kind: 'possibly-same' } + | { kind: 'other-chain' } + | { kind: 'other-genesis'; genesisHash: string; earnShare: Address } { try { - const { target } = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as { + const record = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as { target?: { chainId?: unknown; genesisHash?: unknown } + addresses?: { earnShare?: unknown } + } + const target = record.target + if (typeof target?.chainId !== 'number' || target.chainId !== authoredChainId) { + return { kind: 'possibly-same' } } - if (typeof target?.chainId !== 'number' || target.chainId !== authoredChainId) return true if (typeof target.genesisHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(target.genesisHash)) { - return true + return { kind: 'possibly-same' } + } + if (target.chainId !== chainId) return { kind: 'other-chain' } + if (target.genesisHash.toLowerCase() === genesisHash.toLowerCase()) { + return { kind: 'possibly-same' } + } + const earnShare = record.addresses?.earnShare + if (typeof earnShare !== 'string' || !isAddress(earnShare)) return { kind: 'possibly-same' } + return { + kind: 'other-genesis', + genesisHash: target.genesisHash, + earnShare: getAddress(earnShare), } - return ( - target.chainId === chainId && target.genesisHash.toLowerCase() === genesisHash.toLowerCase() - ) } catch { - return true + return { kind: 'possibly-same' } } } +/** Keeps only each URL's origin, so RPC paths and query tokens never reach the terminal. */ +export function redactUrls(message: string) { + // Take the whole non-space token, since quotes, brackets and other punctuation are valid in + // paths and credentials, and keep only trailing punctuation outside the redacted URL. + return message.replace(/https?:\/\/\S+/g, (token) => { + // Scan back from the end: linear, unlike an unanchored trailing-run regex. + let end = token.length + while (end > 0 && `).,;:!?'"<>`.includes(token[end - 1]!)) end-- + const trailing = token.slice(end) + const candidate = token.slice(0, end) + try { + const url = new URL(candidate) + const kept = candidate === url.origin || candidate === `${url.origin}/` + return (kept ? url.origin : `${url.origin}/[redacted]`) + trailing + } catch { + return '[redacted URL]' + trailing + } + }) +} + const rpcKeys = ['TEMPO_RPC_URL', 'TEMPO_RPC_USERNAME', 'TEMPO_RPC_PASSWORD'] as const /** diff --git a/docs/earn/modules/access.md b/docs/earn/modules/access.md index cd1149d..987eedc 100644 --- a/docs/earn/modules/access.md +++ b/docs/earn/modules/access.md @@ -76,12 +76,13 @@ policy identity and does not block unrelated removals; registry failures still s A TIP-403 policy can be shared: several stacks may attach the same policy on one chain, and a removal applies to all of them. `remove` therefore runs these checks for every deployed profile in the same profiles directory (each `/profile.json` beside the selected one, symlinked or not) -whose verified profile and record match the selected chain, genesis, and `tip403PolicyId`. Each -runs with its own `.env.`, which wins over the shell, alone supplies its -`EARN_TRUSTED_DEPLOYMENT_IDENTITY`, and supplies its RPC settings as a group when it names any of them. A -refusal names the stack that needs the target. Any neighbour with a record that fails to load, and -any neighbour with an unpublished deployment on the same policy, stops the command and is named. -Stacks without a record beside a profile (one kept elsewhere, or an earlier version superseded by a +whose verified profile and record match the selected chain and `tip403PolicyId`. A record naming +another genesis of the same chain is skipped only when its EarnShare has no code on the selected +chain. Each runs with its own `.env.`, which wins over the shell, alone supplies its +`EARN_TRUSTED_DEPLOYMENT_IDENTITY`, and supplies its RPC settings as a group when it names any of +them; credentials without a URL there are refused. A refusal names the stack that needs the target. +Any neighbour with a record that fails to load, and any neighbour with only an unpublished deployment that, bound to its profile through its pending intent hash, targets this chain and policy (or cannot be bound), stops the command and is named; failure messages keep only each URL's origin. Stacks +without a record beside a profile (one kept elsewhere, or an earlier version superseded by a redeployment that is still bound to the policy) are not discovered; check them before removing a member they may need.