Skip to content
Merged
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
42 changes: 42 additions & 0 deletions packages/annotator/src/__tests__/annotator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,48 @@ describe('Annotator', () => {
expect(result.value.path).toBe('annotations/title-99/section-9999.yaml');
});

it('clamps over-cap untrusted strings so the annotation still validates (#232)', async () => {
const client = makeClient([
fakeResult({
caseName: 'A'.repeat(900),
citation: ['C'.repeat(400)],
snippet: 'S'.repeat(900),
}),
]);
const annotator = new Annotator({ client, logger });

const result = await annotator.annotateSection('18 U.S.C. 111');

// Without bounding, schema validation would reject the over-cap fields and
// annotateSection would return err. Bounding clamps them so it succeeds.
expect(result.ok).toBe(true);
if (!result.ok) return;
const c = result.value.annotation.cases[0];
expect(c?.caseName.length).toBeLessThanOrEqual(500);
expect(c?.citation.length).toBeLessThanOrEqual(200);
expect(c?.holdingSummary.length).toBeLessThanOrEqual(500);
expect(c?.caseName.endsWith('...')).toBe(true);
});

it('does not leave a lone high surrogate when truncation splits a pair (#232)', async () => {
// Position 496 is the high surrogate of an emoji; truncation reserves room
// for the ellipsis at 497, so a naive cut would land mid-pair.
const snippet = 'a'.repeat(496) + '\u{1F600}' + 'b'.repeat(20);
const client = makeClient([fakeResult({ snippet })]);
const annotator = new Annotator({ client, logger });

const result = await annotator.annotateSection('18 U.S.C. 111');

expect(result.ok).toBe(true);
if (!result.ok) return;
const summary = result.value.annotation.cases[0]?.holdingSummary ?? '';
expect(summary.length).toBeLessThanOrEqual(500);
// The character just before the ellipsis must not be a lone high surrogate.
const beforeEllipsis = summary.slice(0, summary.length - 3);
const lastCode = beforeEllipsis.charCodeAt(beforeEllipsis.length - 1);
expect(lastCode >= 0xd800 && lastCode <= 0xdbff).toBe(false);
});

it('sorts results by court priority: SCOTUS > Appellate > District', async () => {
const results = [
fakeResult({ court: 'paed', caseName: 'District Case', citation: ['300 F.Supp. 100'] }),
Expand Down
36 changes: 28 additions & 8 deletions packages/annotator/src/annotator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { type PrecedentAnnotation, PrecedentAnnotationSchema, type Result, ok, err } from '@civic-source/types';
import { type Logger, createLogger, TokenBucket } from '@civic-source/shared';
import { type CourtListenerResult, CourtListenerClient } from './client.js';
import { COURTLISTENER_BASE_URL, COURT_PRIORITY, MAX_HOLDING_SUMMARY_LENGTH, RATE_LIMIT_PER_HOUR, getApiToken } from './constants.js';
import {
COURTLISTENER_BASE_URL,
COURT_PRIORITY,
MAX_HOLDING_SUMMARY_LENGTH,
MAX_CASE_NAME_LENGTH,
MAX_CITATION_LENGTH,
RATE_LIMIT_PER_HOUR,
getApiToken,
} from './constants.js';
import { deduplicateCases } from './citation-utils.js';

/** Result of annotating a section, including the output path */
Expand Down Expand Up @@ -32,10 +40,19 @@ function sortByCourtPriority(results: CourtListenerResult[]): CourtListenerResul
});
}

/** Truncate snippet to a maximum length for holding summary */
function truncateSnippet(snippet: string): string {
if (snippet.length <= MAX_HOLDING_SUMMARY_LENGTH) return snippet;
return snippet.slice(0, MAX_HOLDING_SUMMARY_LENGTH - 3) + '...';
/**
* Truncate an untrusted string to at most `max` UTF-16 code units, bounding the
* values that flow into the hand-rolled YAML sidecar (#232). `max` is measured
* in code units (matching the schema's `.max()` checks). When truncation splits
* a surrogate pair, the trailing lone high surrogate is dropped so the result
* never ends in a broken character.
*/
function truncate(value: string, max: number): string {
if (value.length <= max) return value;
let end = max - 3; // reserve room for the ellipsis
const lastKept = value.charCodeAt(end - 1);
if (lastKept >= 0xd800 && lastKept <= 0xdbff) end -= 1; // don't split a surrogate pair
return value.slice(0, end) + '...';
}

/** Origin of the CourtListener site — a sourceUrl must resolve to this host. */
Expand Down Expand Up @@ -174,11 +191,14 @@ export class Annotator {
const isoNow = new Date().toISOString();

const rawCases = sorted.map((result) => ({
caseName: result.caseName,
citation: result.citation[0] ?? '',
// Bound untrusted CourtListener strings to their schema caps so a long
// value is clamped (not dropped by schema validation) and never bloats
// the YAML sidecar (#232).
caseName: truncate(result.caseName, MAX_CASE_NAME_LENGTH),
citation: truncate(result.citation[0] ?? '', MAX_CITATION_LENGTH),
court: mapCourt(result.court),
date: result.dateFiled,
holdingSummary: truncateSnippet(result.snippet),
holdingSummary: truncate(result.snippet, MAX_HOLDING_SUMMARY_LENGTH),
sourceUrl: courtListenerSourceUrl(result.absolute_url),
impact: 'interpretation' as const,
}));
Expand Down
6 changes: 6 additions & 0 deletions packages/annotator/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export const DEFAULT_PAGE_SIZE = 20;
/** Maximum characters for holding summary (truncated from snippet) */
export const MAX_HOLDING_SUMMARY_LENGTH = 500;

/** Maximum characters for a case name (matches CaseAnnotationSchema.caseName) */
export const MAX_CASE_NAME_LENGTH = 500;

/** Maximum characters for a citation (matches CaseAnnotationSchema.citation) */
export const MAX_CITATION_LENGTH = 200;

/**
* Default court priority for result ordering.
* SCOTUS opinions are most authoritative, followed by Appellate, then District.
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/__tests__/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ describe('CaseAnnotationSchema', () => {
const atLimit = { ...valid, holdingSummary: 'x'.repeat(500) };
expect(CaseAnnotationSchema.parse(atLimit).holdingSummary).toHaveLength(500);
});

// Length caps on untrusted strings that flow into the hand-rolled YAML
// sidecar — fail-closed so nothing over-cap is ever serialized (#232).
it('rejects caseName over 500 chars but accepts it at the cap', () => {
expect(() => CaseAnnotationSchema.parse({ ...valid, caseName: 'x'.repeat(501) })).toThrow();
expect(CaseAnnotationSchema.parse({ ...valid, caseName: 'x'.repeat(500) }).caseName).toHaveLength(500);
});

it('rejects citation over 200 chars but accepts it at the cap', () => {
expect(() => CaseAnnotationSchema.parse({ ...valid, citation: 'x'.repeat(201) })).toThrow();
expect(CaseAnnotationSchema.parse({ ...valid, citation: 'x'.repeat(200) }).citation).toHaveLength(200);
});

it('rejects statuteVersionRef over 100 chars', () => {
expect(() => CaseAnnotationSchema.parse({ ...valid, statuteVersionRef: 'x'.repeat(101) })).toThrow();
});

it('rejects statuteVersionNote over 500 chars', () => {
expect(() => CaseAnnotationSchema.parse({ ...valid, statuteVersionNote: 'x'.repeat(501) })).toThrow();
});
});

describe('PrecedentAnnotationSchema', () => {
Expand Down
11 changes: 7 additions & 4 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,20 @@ export const PrecedentImpactSchema = z.enum([
export type PrecedentImpact = z.infer<typeof PrecedentImpactSchema>;

export const CaseAnnotationSchema = z.object({
caseName: z.string(),
citation: z.string(),
// Length caps bound untrusted CourtListener-derived strings that flow into
// the hand-rolled YAML sidecar serialization, so a hostile or accidentally
// huge value cannot bloat output or widen the injection surface (#232).
caseName: z.string().max(500),
citation: z.string().max(200),
court: z.enum(["SCOTUS", "Appellate", "District"]),
date: z.string(),
holdingSummary: z.string().max(500),
sourceUrl: HttpUrlSchema,
impact: PrecedentImpactSchema,
/** Public Law the statute was current through when this case was decided */
statuteVersionRef: z.string().optional(),
statuteVersionRef: z.string().max(100).optional(),
/** Human-readable note about version alignment */
statuteVersionNote: z.string().optional(),
statuteVersionNote: z.string().max(500).optional(),
});

export const PrecedentAnnotationSchema = z.object({
Expand Down
Loading