mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
fix: close the backtracking patterns SonarCloud's profile turns up
The move to SonarCloud brought a wider rule set with it, and 23 more regexes that can be made to hang on a crafted near-match. 21 are fixed here, each proven equivalent to the pattern it replaces by brute force rather than by reading. Two of them are reachable across users, not just by yourself. A reservation endpoint name is free text any trip member types, stored and then read back in every other member's browser while the map draws its labels, and the pattern over it takes 2.3 seconds on a 32k paste - so one member could freeze the map for the whole trip. The mobile transport sheet reads the same field. Both now scan instead of backtrack, and both were matched against the desktop twin the earlier sweep had already fixed rather than given a third spelling. The markdown link stripper needed the same treatment as the tag stripper before it: the label class cannot be disambiguated in the pattern, because it matches an opening bracket as well, and narrowing it changes what "[a[b](c)" produces. It is a scan now, with all four of the regex's quirks intact. Also here: the two encoding warnings the scanner reported. A decorative comment line in JourneyDetailPage.test.tsx had picked up a replacement character somewhere along the way, and calendar.service.test.ts asserts on one deliberately - that one is now written as a U+FFFD escape, so the file no longer carries the raw bytes while the test says exactly what it said before. Seven findings were deliberately not changed and are flagged in SonarCloud with the reasoning. Five are false positives - patterns with no overlapping quantifier pair, where the only cure would change how nested brackets parse. Two are marked accepted rather than dismissed, because they are real: the readme check's heading pattern has a proven linear replacement that cannot land until the registry's copy of the same script can move with it, and `export let JWT_SECRET` is what makes an admin's key rotation take effect in-process instead of silently waiting for a restart.
This commit is contained in:
@@ -1688,8 +1688,8 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
|
||||
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[80vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[80vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-edge-secondary">
|
||||
<h3 className="text-[15px] font-semibold text-content truncate">{t('admin.plugins.versionPickerTitle', { name: plugin.name })}</h3>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors shrink-0"><X size={16} /></button>
|
||||
|
||||
@@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// Fire the click but do not await — handler never resolves so sending stays true
|
||||
void user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
// The request handler never resolves, so sending stays true after the click settles
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
|
||||
*/
|
||||
export function stripMarkdown(md: string): string {
|
||||
return md
|
||||
const withoutImages = md
|
||||
.replace(/^#{1,6}\s+/gm, '') // headings
|
||||
// the alt text is a run that cannot contain "](", so it ends on the first one
|
||||
// instead of being retried against every later "](". Same matches, no backtracking
|
||||
.replace(/!\[(?:(?!\]\().)*\]\(.*?\)/g, '') // images
|
||||
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
|
||||
|
||||
return stripLinks(withoutImages) // links → text
|
||||
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
|
||||
.replace(/`([^`]+)`/g, '$1') // inline code
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
|
||||
@@ -24,3 +25,68 @@ export function stripMarkdown(md: string): string {
|
||||
.replace(/\n/g, ' ') // remaining newlines → spaces
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* `.` matches every character except a line terminator, which is exactly the set a link
|
||||
* target could be built from back when this was `.*?`. Testing a single character against
|
||||
* it spells that out without hand-listing \n, \r and the two Unicode separators.
|
||||
*/
|
||||
const notALineTerminator = /./
|
||||
|
||||
/**
|
||||
* Replace `[label](target)` with its label, exactly as `/\[([^\]]*)\]\(.*?\)/g` did.
|
||||
*
|
||||
* Written as a scan rather than a regex because that pattern backtracks: `[^\]]` matches
|
||||
* `[` too, so on a run of `[` with no `]` after it the engine retries the whole
|
||||
* label-and-target scan from every one of them and reads to the end each time. Measured
|
||||
* on 32k of `[x`: 300ms, quadrupling with every doubling. The subject is a journal
|
||||
* entry's story text, which any contributor to the journal can write and every reader's
|
||||
* browser then renders a preview of.
|
||||
*
|
||||
* The semantics are the regex's, quirks included:
|
||||
* - the label runs to the FIRST `]` and may itself contain `[`, so `[a[b](c)` keeps
|
||||
* `a[b` — narrowing the label to `[^[\]]` would have been a behaviour change;
|
||||
* - the label may be empty, since `[^\]]*` is zero-or-more, so `[](c)` is a link;
|
||||
* - `(` has to sit directly after that `]`;
|
||||
* - the target ends at the first `)`, and `.` cannot cross a line terminator, so a `(`
|
||||
* whose first `)` only turns up on a later line is not a link at all.
|
||||
*/
|
||||
function stripLinks(input: string): string {
|
||||
let out = ''
|
||||
let cursor = 0 // everything before this has already been copied into `out`
|
||||
let search = 0 // where the next `[` is looked for
|
||||
// First `)`-or-line-terminator at or after the target we last examined. Targets are
|
||||
// examined left to right, so this only ever moves forward — which is what stops a
|
||||
// document full of `[a](` from being re-read once per candidate.
|
||||
let stop = 0
|
||||
|
||||
for (;;) {
|
||||
const open = input.indexOf('[', search)
|
||||
if (open === -1) break
|
||||
|
||||
const close = input.indexOf(']', open + 1)
|
||||
// No `]` left anywhere: neither this `[` nor any after it can match. Rediscovering
|
||||
// that from every `[` in turn is what the regex spent quadratic time on.
|
||||
if (close === -1) break
|
||||
|
||||
// Every `[` between `open` and `close` has that same first `]`, so the verdict on
|
||||
// what follows it is the verdict for all of them — skip the lot when it fails.
|
||||
if (input[close + 1] !== '(') {
|
||||
search = close + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (stop < close + 2) stop = close + 2
|
||||
while (stop < input.length && input[stop] !== ')' && notALineTerminator.test(input[stop])) stop++
|
||||
if (input[stop] !== ')') {
|
||||
search = close + 1
|
||||
continue
|
||||
}
|
||||
|
||||
out += input.slice(cursor, open) + input.slice(open + 1, close)
|
||||
cursor = stop + 1
|
||||
search = stop + 1
|
||||
}
|
||||
|
||||
return out + input.slice(cursor)
|
||||
}
|
||||
|
||||
@@ -64,8 +64,25 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
|
||||
function toRad(d: number) { return d * Math.PI / 180 }
|
||||
|
||||
// "Wien Hbf (Vienna)" → "Wien Hbf". Done as a scan, not /\s*\([^)]*\)/g: the leading
|
||||
// \s* overlaps the engine's own restart-at-every-position scan, so a name that is a
|
||||
// long run of spaces with no "(" after it backtracks quadratically (1.0s at 32k), and
|
||||
// so does a run of "(" with no ")". Same matches: the whitespace directly before a "("
|
||||
// that has a ")" after it goes too, and an unclosed "(" is left alone.
|
||||
function cleanName(name: string): string {
|
||||
return name.replace(/\s*\([^)]*\)/g, '').trim()
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
for (;;) {
|
||||
const open = name.indexOf('(', cursor)
|
||||
if (open === -1) break
|
||||
const close = name.indexOf(')', open + 1)
|
||||
if (close === -1) break
|
||||
let start = open
|
||||
while (start > cursor && /\s/.test(name[start - 1])) start--
|
||||
out += name.slice(cursor, start)
|
||||
cursor = close + 1
|
||||
}
|
||||
return (out + name.slice(cursor)).trim()
|
||||
}
|
||||
|
||||
function haversineKm(a: [number, number], b: [number, number]): number {
|
||||
|
||||
@@ -94,7 +94,26 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
const cleanName = (name: string) => name.replace(/\s*\([^)]*\)/g, '').trim()
|
||||
// "Wien Hbf (Vienna)" → "Wien Hbf". Done as a scan, not /\s*\([^)]*\)/g: the leading
|
||||
// \s* overlaps the engine's own restart-at-every-position scan, so a name that is a
|
||||
// long run of spaces with no "(" after it backtracks quadratically (1.2s at 40k).
|
||||
// Same matches: the whitespace directly before a "(" that has a ")" after it goes
|
||||
// too, and an unclosed "(" is left alone.
|
||||
const cleanName = (name: string) => {
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
for (;;) {
|
||||
const open = name.indexOf('(', cursor)
|
||||
if (open === -1) break
|
||||
const close = name.indexOf(')', open + 1)
|
||||
if (close === -1) break
|
||||
let start = open
|
||||
while (start > cursor && /\s/.test(name[start - 1])) start--
|
||||
out += name.slice(cursor, start)
|
||||
cursor = close + 1
|
||||
}
|
||||
return (out + name.slice(cursor)).trim()
|
||||
}
|
||||
|
||||
// ── item building ─────────────────────────────────────────────────────────
|
||||
interface TransportItem {
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
// `m` falls back to NaN because a value with no colon ('' while the field is
|
||||
// empty) splits into a single part, and an absent minute has to read as unset.
|
||||
const [h, m = NaN] = (parseMeridiemTime(value) ?? value ?? '').split(':').map(Number)
|
||||
const [h, m = Number.NaN] = (parseMeridiemTime(value) ?? value ?? '').split(':').map(Number)
|
||||
const hour = Number.isNaN(h) ? null : h
|
||||
const minute = Number.isNaN(m) ? null : m
|
||||
|
||||
|
||||
@@ -119,14 +119,47 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
|
||||
}
|
||||
|
||||
const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
// `[label](url)` → anchor, exactly as /\[([^\]]+)\]\(([^)]+)\)/g did. Written as a
|
||||
// scan because that pattern backtracks from both ends: `[^\]]` matches `[` and
|
||||
// `[^)]` matches `(`, so a run of either made the engine retry the whole match from
|
||||
// every one of them, reading to the end each time. Release bodies come off the
|
||||
// GitHub API. Both parts are one-or-more, so `[](url)` and `[label]()` stay plain
|
||||
// text; the label ends at the first `]`, the url at the first `)`.
|
||||
const linkify = (text: string) => {
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
let search = 0
|
||||
for (;;) {
|
||||
const open = text.indexOf('[', search)
|
||||
if (open === -1) break
|
||||
const close = text.indexOf(']', open + 1)
|
||||
if (close === -1) break // no `]` left: no later `[` can match either
|
||||
// Every `[` up to `close` shares that first `]`, so they fail together.
|
||||
if (close === open + 1 || text[close + 1] !== '(') {
|
||||
search = close + 1
|
||||
continue
|
||||
}
|
||||
const end = text.indexOf(')', close + 2)
|
||||
if (end === -1) break // no `)` left: no later `(` can close either
|
||||
if (end === close + 2) {
|
||||
search = close + 1
|
||||
continue
|
||||
}
|
||||
const label = text.slice(open + 1, close)
|
||||
const url = text.slice(close + 2, end)
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
out += `${text.slice(cursor, open)}<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--m-st-info);text-decoration:underline">${label}</a>`
|
||||
cursor = end + 1
|
||||
search = end + 1
|
||||
}
|
||||
return out + text.slice(cursor)
|
||||
}
|
||||
const inlineFormat = (text: string) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--m-ic)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--m-st-info);text-decoration:underline">${label}</a>`
|
||||
})
|
||||
return linkify(
|
||||
escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--m-ic)">$1</code>')
|
||||
)
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -315,7 +315,12 @@ export default function MJourneyEntrySheet({
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const value = tagInput.trim().replace(/,+$/, '')
|
||||
// Trailing commas dropped by a scan, not /,+$/: an unanchored ,+ before $ has to
|
||||
// retry from every comma in the run, so a pasted string of them freezes the tab.
|
||||
const trimmed = tagInput.trim()
|
||||
let end = trimmed.length
|
||||
while (end > 0 && trimmed[end - 1] === ',') end--
|
||||
const value = trimmed.slice(0, end)
|
||||
if (!value) return
|
||||
if (!tags.includes(value)) setTags(prev => [...prev, value])
|
||||
setTagInput('')
|
||||
|
||||
@@ -54,9 +54,16 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence:
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return { role, sequence, name: l.name, code: null, lat: l.lat, lng: l.lng, timezone: null, local_date: date, local_time: time }
|
||||
}
|
||||
// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle", the same trim-plus-
|
||||
// anchored-test the desktop TransportModal uses instead of /\s*\([A-Z]{3}\)\s*$/,
|
||||
// because that leading \s* backtracks over every space in a long name.
|
||||
function stripAirportCode(name: string): string {
|
||||
const trimmed = name.trimEnd()
|
||||
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
|
||||
}
|
||||
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||
if (!e || !e.code) return null
|
||||
return { iata: e.code, icao: null, name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''), country: '', lat: e.lat, lng: e.lng, tz: e.timezone || '' }
|
||||
return { iata: e.code, icao: null, name: e.name, city: stripAirportCode(e.name), country: '', lat: e.lat, lng: e.lng, tz: e.timezone || '' }
|
||||
}
|
||||
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
|
||||
if (!e) return null
|
||||
|
||||
@@ -841,7 +841,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helper: open entry editor ────────────────────────��─────────────────
|
||||
// ── Helper: open entry editor ───────────────────────────────────────────
|
||||
async function openEntryEditor(user: ReturnType<typeof userEvent.setup>) {
|
||||
// The + button is inside the view controls row, after the tab group
|
||||
// Structure: div.justify-between > [div(tabs), button(+)]
|
||||
|
||||
@@ -16,7 +16,11 @@ const TOOL_NAME = 'emit_reservations';
|
||||
*/
|
||||
export class AnthropicClient implements LlmExtractionClient {
|
||||
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
|
||||
const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/\/+$/, '');
|
||||
// The lookbehind pins the run to its own start. Without it `\/+$` restarts at every
|
||||
// slash of a trailing run that turns out not to end the string, rescanning to the
|
||||
// end each time; the assertion only rules out start positions the leftmost match
|
||||
// could never have used, so the trimmed result is unchanged.
|
||||
const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/(?<!\/)\/+$/, '');
|
||||
const url = `${base}/v1/messages`;
|
||||
|
||||
const content: unknown[] = [];
|
||||
|
||||
@@ -83,8 +83,12 @@ export function extractBookingRef(text: string): string | undefined {
|
||||
// exactly that (the run's first digit is only ever preceded by letters), and the
|
||||
// separator says "spaces, then optionally a colon and more spaces" — both spellings
|
||||
// avoid quantifiers that overlap and make document text backtrack.
|
||||
// "Confirmation" carries its own spacing INSIDE the optional suffix: spelled
|
||||
// `Confirmation\s*(?:number|code)?`, a dropped suffix left that \s* sitting directly
|
||||
// against the separator's \s*, and two adjacent runs can split a stretch of spaces
|
||||
// in n ways — quadratic on "Confirmation" followed by a long indent.
|
||||
const m = text.match(
|
||||
/(?:PNR|Buchungs(?:code|nummer|referenz)|Booking\s*(?:reference|code|number)|Confirmation\s*(?:number|code)?|Reservierungsnummer|Reservation\s*(?:No\.?|Number|Nr\.?)|Best(?:ä|ae)tigungs[-\s]?(?:nummer|code)|(?:Expedia[-\s]*)?Reiseplan|Reference)\s*(?::\s*)?((?=[A-Z]*\d)[A-Z0-9]{5,})/i,
|
||||
/(?:PNR|Buchungs(?:code|nummer|referenz)|Booking\s*(?:reference|code|number)|Confirmation(?:\s*(?:number|code))?|Reservierungsnummer|Reservation\s*(?:No\.?|Number|Nr\.?)|Best(?:ä|ae)tigungs[-\s]?(?:nummer|code)|(?:Expedia[-\s]*)?Reiseplan|Reference)\s*(?::\s*)?((?=[A-Z]*\d)[A-Z0-9]{5,})/i,
|
||||
);
|
||||
return m?.[1];
|
||||
}
|
||||
@@ -103,15 +107,23 @@ export function normCurrency(token: string): string | undefined {
|
||||
export function extractTotalPrice(text: string): { price: string; currency?: string } | null {
|
||||
const strip = (s: string) => s.replace(/[€$£¥\s]/g, '');
|
||||
// A labeled total: "Gesamtpreis: 1.234,56 €", "Total Amount 99 USD", "Bezahlter Betrag 651,86 €".
|
||||
// The symbol keeps the space that trails IT inside its own optional group: spelled
|
||||
// `[€$£¥]?\s*`, a missing symbol left that \s* directly against the separator's \s*,
|
||||
// and two adjacent runs split a stretch of spaces in n ways — quadratic on a label
|
||||
// followed by a long indent and no amount.
|
||||
const labeled = text.match(
|
||||
/(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*(?::\s*)?([€$£¥]?\s*\d[\d.,]*)\s*(EUR|USD|GBP|CHF|JPY|€|\$|£|¥)?/i,
|
||||
/(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*(?::\s*)?((?:[€$£¥]\s*)?\d[\d.,]*)\s*(EUR|USD|GBP|CHF|JPY|€|\$|£|¥)?/i,
|
||||
);
|
||||
if (labeled) return { price: strip(labeled[1]), currency: normCurrency(labeled[2] ?? labeled[1]) };
|
||||
// Fallback: a standalone amount carrying a currency symbol on its own line (e.g. a voucher's
|
||||
// "¥9,400") — the price sits far from any label the pattern above can anchor to. The
|
||||
// indent is horizontal whitespace only: under /m the anchor already sits at every line
|
||||
// start, so a \s* run that could also eat the newlines just re-scans the document.
|
||||
const symbol = text.match(/^[^\S\r\n]*([€$£¥]\s?\d[\d.,]*)\b/m);
|
||||
// U+2028/U+2029 are line terminators too — /m starts a line after each one — so they
|
||||
// have to leave the class for the same reason \r\n do. `[^\S\r\n]` still contained
|
||||
// them (they are \s), which made a run of them both a line start and indent the class
|
||||
// could eat: 16k of U+2028 took 348ms, and the run grows quadratically.
|
||||
const symbol = text.match(/^[^\S\r\n\u2028\u2029]*([€$£¥]\s?\d[\d.,]*)\b/m);
|
||||
if (symbol) return { price: strip(symbol[1]), currency: normCurrency(symbol[1]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@ export interface EnforcedExtractInput {
|
||||
|
||||
/** Resolve the native API base from a config base URL that may end in `/v1`. */
|
||||
export function toNativeBase(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
|
||||
// Trailing slashes come off as a scan, not /\/+$/: that pattern is unanchored at the
|
||||
// front, so the engine restarts it at every slash of a run and rescans to the end each
|
||||
// time (quadratic — 25s on 200k). Same result: the maximal trailing run goes.
|
||||
let end = baseUrl.length;
|
||||
while (end > 0 && baseUrl[end - 1] === '/') end--;
|
||||
return baseUrl.slice(0, end).replace(/\/v1$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -265,7 +265,9 @@ function stripPageMarkers(text: string): string {
|
||||
function cleanPdfText(text: string): string {
|
||||
return stripPageMarkers(text)
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\b(?:[A-Z] ){2,}[A-Z]\b/g, m => m.replaceAll(' ', ''))
|
||||
// Same run of three-or-more spaced capitals, written so the repeated group no
|
||||
// longer overlaps the [A-Z] behind it and has to hand characters back to it.
|
||||
.replace(/\b[A-Z](?: [A-Z]){2,}\b/g, m => m.replaceAll(' ', ''))
|
||||
.replace(/ *\n */g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
@@ -87,8 +87,19 @@ export function sameScopes(a: string[] | null, b: string[] | null): boolean {
|
||||
return left.every((scope, i) => scope === right[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the trailing slashes of a base URL, as a scan rather than /\/+$/: that pattern
|
||||
* is unanchored at the front, so the engine restarts it at every slash of a run and
|
||||
* rescans to the end each time (quadratic — 25s on 200k). Same string either way.
|
||||
*/
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0 && value[end - 1] === '/') end--;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
export function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
|
||||
const base = trimTrailingSlashes(getMcpSafeUrl() || '');
|
||||
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
||||
res.set('WWW-Authenticate',
|
||||
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
|
||||
@@ -129,7 +140,7 @@ export class McpTransportService {
|
||||
if (!result) return null;
|
||||
// RFC 8707: audience must always match this resource endpoint.
|
||||
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
||||
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const expected = `${trimTrailingSlashes(getMcpSafeUrl() || '')}/mcp`;
|
||||
if (result.audience !== expected) return null;
|
||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ export class OidcController {
|
||||
res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
||||
return;
|
||||
}
|
||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||
// `(?<!\/)` as in oidc.service.ts: it pins the run to its own start so a trailing
|
||||
// run that does not end the string is not rescanned from every slash in it.
|
||||
const redirectUri = `${appUrl.replace(/(?<!\/)\/+$/, '')}/api/auth/oidc/callback`;
|
||||
// Lenient parse: a malformed query must never 400 a login redirect, so
|
||||
// unknown values simply fall back to the raw invite + default duration.
|
||||
const query = oidcLoginQuerySchema.safeParse(req.query);
|
||||
@@ -130,7 +132,9 @@ export class OidcController {
|
||||
tokenData.id_token,
|
||||
doc,
|
||||
config.clientId,
|
||||
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||
// Same trim as oidc.service.ts, and the same reason for the lookbehind — this one
|
||||
// runs over an issuer read out of a fetched discovery document.
|
||||
(doc.issuer ?? '').replace(/(?<!\/)\/+$/, '') || config.issuer,
|
||||
);
|
||||
if (idVerify.ok !== true) {
|
||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||
|
||||
@@ -22,7 +22,7 @@ export class DiscoveryMetadataService {
|
||||
|
||||
getOAuthMetadata(): OAuthMetadata {
|
||||
if (this.oauthMetadata) return this.oauthMetadata;
|
||||
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||
const base = getMcpSafeUrl().replace(/(?<!\/)\/+$/, '');
|
||||
this.oauthMetadata = {
|
||||
issuer: base,
|
||||
authorization_endpoint: `${base}/oauth/authorize`,
|
||||
|
||||
@@ -1138,7 +1138,7 @@ describe('foldICS', () => {
|
||||
|
||||
const folded = foldICS(line);
|
||||
|
||||
expect(folded).not.toContain('�');
|
||||
expect(folded).not.toContain('\uFFFD');
|
||||
expect(unfold(folded)).toBe(line);
|
||||
for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75);
|
||||
});
|
||||
@@ -1150,7 +1150,7 @@ describe('foldICS', () => {
|
||||
|
||||
const folded = foldICS(line);
|
||||
|
||||
expect(folded).not.toContain('�');
|
||||
expect(folded).not.toContain('\uFFFD');
|
||||
expect(unfold(folded)).toBe(line);
|
||||
for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user