From f993e7097560cce9ba6ab0546fcc915e68317e1c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 09:21:33 +0800 Subject: [PATCH 1/4] fix(ios): stop reading the pasteboard in text input canPerformAction iOS 27 validates UICommands from UIIntelligenceSupport without any user action, which calls canPerformAction: on the focused RCTUITextField. The OneKeyPaste swizzle read UIPasteboard.generalPasteboard.hasImages there, a synchronous XPC call to pasteboardd, and a slow reply blocked the main thread until the watchdog killed the app (Sentry REACT-NATIVE-4Y1, "Fatal App Hang Fully Blocked", iOS 27.0). canPerformAction: now reads a cached flag. A background serial queue refreshes it on UIPasteboardChangedNotification, app activation and text input begin-editing, coalescing concurrent refresh requests. Co-Authored-By: Claude Opus 5.5 --- .../ios/OneKeyTextInputPasteObserver.mm | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm b/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm index 89896bf1f..db483a733 100644 --- a/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm +++ b/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm @@ -7,11 +7,57 @@ #import #import +#include + static NSString *const OneKeyTextInputPasteEvent = @"OneKeyTextInputPaste"; static __weak OneKeyTextInputPasteObserver *OneKeyPasteObserver = nil; static BOOL OneKeyPasteObserverHasListeners = NO; static const void *OneKeyPasteInFlightKey = &OneKeyPasteInFlightKey; +// canPerformAction: runs on the main thread, and iOS 27 also calls it from +// system command validation without any user action. Reading UIPasteboard +// there is a synchronous XPC call that can block until the watchdog kills the +// app, so the image check reads this cache and refreshes it off the main thread. +static std::atomic OneKeyPasteboardHasImages{false}; +static std::atomic OneKeyPasteboardRefreshPending{false}; + +static void OneKeyRefreshPasteboardImageState(void) +{ + if (OneKeyPasteboardRefreshPending.exchange(true)) { + return; + } + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("so.onekey.textinput.pasteboard", DISPATCH_QUEUE_SERIAL); + }); + dispatch_async(queue, ^{ + OneKeyPasteboardRefreshPending.store(false); + OneKeyPasteboardHasImages.store(UIPasteboard.generalPasteboard.hasImages); + }); +} + +static void OneKeyStartObservingPasteboard(void) +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + for (NSNotificationName name in @[ + UIPasteboardChangedNotification, + UIApplicationDidBecomeActiveNotification, + UITextFieldTextDidBeginEditingNotification, + UITextViewTextDidBeginEditingNotification, + ]) { + [center addObserverForName:name + object:nil + queue:nil + usingBlock:^(__unused NSNotification *note) { + OneKeyRefreshPasteboardImageState(); + }]; + } + }); +} + @implementation OneKeyTextInputPasteObserver RCT_EXPORT_MODULE(OneKeyTextInputPasteObserver) @@ -145,6 +191,7 @@ + (void)load dispatch_once(&onceToken, ^{ OneKeySwizzle(self, @selector(paste:), @selector(onekey_paste:)); OneKeySwizzle(self, @selector(canPerformAction:withSender:), @selector(onekey_canPerformAction:withSender:)); + OneKeyStartObservingPasteboard(); }); } @@ -162,7 +209,7 @@ - (void)onekey_paste:(id)sender - (BOOL)onekey_canPerformAction:(SEL)action withSender:(id)sender { - if (action == @selector(paste:) && UIPasteboard.generalPasteboard.hasImages) { + if (action == @selector(paste:) && OneKeyPasteboardHasImages.load()) { return YES; } return [self onekey_canPerformAction:action withSender:sender]; @@ -178,6 +225,7 @@ + (void)load dispatch_once(&onceToken, ^{ OneKeySwizzle(self, @selector(paste:), @selector(onekey_paste:)); OneKeySwizzle(self, @selector(canPerformAction:withSender:), @selector(onekey_canPerformAction:withSender:)); + OneKeyStartObservingPasteboard(); }); } @@ -195,7 +243,7 @@ - (void)onekey_paste:(id)sender - (BOOL)onekey_canPerformAction:(SEL)action withSender:(id)sender { - if (action == @selector(paste:) && UIPasteboard.generalPasteboard.hasImages) { + if (action == @selector(paste:) && OneKeyPasteboardHasImages.load()) { return YES; } return [self onekey_canPerformAction:action withSender:sender]; From d9215fbedafdefe0e33a4de48e65e196fdf9145b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 16:49:21 +0800 Subject: [PATCH 2/4] fix(ios): keep image paste available during clipboard refresh --- .../OneKeyTextInput.podspec | 5 + .../react-native-text-input/docs/SPEC.md | 59 ++++++++++++ .../ios/OneKeyTextInputPasteObserver.mm | 43 +++++++-- .../OneKeyTextInputPasteObserverTests.mm | 95 +++++++++++++++++++ 4 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 native-views/react-native-text-input/docs/SPEC.md create mode 100644 native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm diff --git a/native-views/react-native-text-input/OneKeyTextInput.podspec b/native-views/react-native-text-input/OneKeyTextInput.podspec index 41ee3049b..d20eb1fbc 100644 --- a/native-views/react-native-text-input/OneKeyTextInput.podspec +++ b/native-views/react-native-text-input/OneKeyTextInput.podspec @@ -12,7 +12,12 @@ Pod::Spec.new do |s| s.platforms = { :ios => "15.5" } s.source = { :git => "https://github.com/OneKeyHQ/app-modules.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm}" + s.exclude_files = "ios/tests/**/*" s.frameworks = "UIKit", "UniformTypeIdentifiers" s.dependency "React-Core" + + s.test_spec "Tests" do |test_spec| + test_spec.source_files = "ios/tests/**/*.{m,mm}" + end end diff --git a/native-views/react-native-text-input/docs/SPEC.md b/native-views/react-native-text-input/docs/SPEC.md new file mode 100644 index 000000000..b03ef4550 --- /dev/null +++ b/native-views/react-native-text-input/docs/SPEC.md @@ -0,0 +1,59 @@ +# React Native Text Input behavioral contract + +Status: Implemented in source; iOS device interaction remains to be verified. + +## Purpose, scope, and ownership + +This package wraps React Native text inputs and emits paste events for text and +images. The native input owns the platform paste action; callers own handling the +emitted event and any temporary image file URL. The package does not inspect +application-specific clipboard data. + +## Public API and defaults + +`TextInput` accepts React Native `TextInputProps` and an optional `onPaste` +callback. The callback receives `nativeEvent.items`; each item may contain +`type` and `data`. An image item has a MIME type and temporary file URL, and a +text item has type `text/plain`. With no `onPaste` callback, there is no +JavaScript paste subscription. This cache does not add a public prop or change +the callback payload. + +## Lifecycle, concurrency, and cache + +The iOS observer starts with the image cache uninitialized, registers for +pasteboard changes, app activation, and text-input begin-editing, and requests +an initial refresh. It uses one serial background queue for pasteboard image +availability reads and coalesces concurrent notifications. A notification +during a read requires another read before the cache is current. The cache is +process-local and is not persisted; its value is only a hint for command +availability. The paste handler checks the actual clipboard content when used. + +## Platform behavior + +- iOS `RCTUITextField` and `RCTUITextView` expose Paste for an image-only + clipboard. When an image is pasted, the native observer loads its data and + emits `OneKeyTextInputPaste` with a MIME type and temporary file URL. Text + paste events use `text/plain`. If image loading fails, the native paste + fallback remains available. +- iOS command validation must not read `UIPasteboard` on the main thread. + While the cache is uninitialized or a refresh is outstanding, Paste remains + available. Once a refresh completes with no image, normal React Native + Paste gating applies. +- Android keeps its existing paste watcher behavior. This iOS cache does not + change Android or Web paste behavior. + +## Failure, fallback, and resource budget + +The cache may briefly allow Paste when the clipboard contains no image. In +that case, the native paste action falls back to the text input's normal +behavior. If loading an image fails, the native paste fallback remains +available. Command validation performs atomic reads only; pasteboard XPC work +stays on the serial background queue. No image data is retained in the cache. + +## Conformance and acceptance + +The iOS implementation is in `ios/OneKeyTextInputPasteObserver.mm`. Focused +native tests cover image-only Paste while a refresh is blocked after first +focus and foreground activation. Simulator interaction should also confirm +that Paste appears and emits the image event in both cases; that interaction +has not yet been verified. diff --git a/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm b/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm index db483a733..aad2673b8 100644 --- a/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm +++ b/native-views/react-native-text-input/ios/OneKeyTextInputPasteObserver.mm @@ -18,13 +18,25 @@ // system command validation without any user action. Reading UIPasteboard // there is a synchronous XPC call that can block until the watchdog kills the // app, so the image check reads this cache and refreshes it off the main thread. -static std::atomic OneKeyPasteboardHasImages{false}; -static std::atomic OneKeyPasteboardRefreshPending{false}; +enum class OneKeyPasteboardImageState { Unknown, NoImages, HasImages }; +enum class OneKeyPasteboardRefreshState { Idle, Scheduled, Dirty }; +static std::atomic OneKeyPasteboardImageCache{OneKeyPasteboardImageState::Unknown}; +static std::atomic OneKeyPasteboardRefresh{OneKeyPasteboardRefreshState::Idle}; static void OneKeyRefreshPasteboardImageState(void) { - if (OneKeyPasteboardRefreshPending.exchange(true)) { - return; + for (;;) { + OneKeyPasteboardRefreshState state = OneKeyPasteboardRefresh.load(); + if (state == OneKeyPasteboardRefreshState::Dirty) { + return; + } + if (state == OneKeyPasteboardRefreshState::Scheduled) { + if (OneKeyPasteboardRefresh.compare_exchange_weak(state, OneKeyPasteboardRefreshState::Dirty)) { + return; + } + } else if (OneKeyPasteboardRefresh.compare_exchange_weak(state, OneKeyPasteboardRefreshState::Scheduled)) { + break; + } } static dispatch_queue_t queue; static dispatch_once_t onceToken; @@ -32,8 +44,18 @@ static void OneKeyRefreshPasteboardImageState(void) queue = dispatch_queue_create("so.onekey.textinput.pasteboard", DISPATCH_QUEUE_SERIAL); }); dispatch_async(queue, ^{ - OneKeyPasteboardRefreshPending.store(false); - OneKeyPasteboardHasImages.store(UIPasteboard.generalPasteboard.hasImages); + for (;;) { + BOOL hasImages = UIPasteboard.generalPasteboard.hasImages; + OneKeyPasteboardImageCache.store(hasImages ? OneKeyPasteboardImageState::HasImages + : OneKeyPasteboardImageState::NoImages); + OneKeyPasteboardRefreshState expected = OneKeyPasteboardRefreshState::Scheduled; + if (OneKeyPasteboardRefresh.compare_exchange_strong(expected, OneKeyPasteboardRefreshState::Idle)) { + return; + } + // A notification arrived during the read. Keep Paste available until + // another read observes the latest clipboard state. + OneKeyPasteboardRefresh.store(OneKeyPasteboardRefreshState::Scheduled); + } }); } @@ -55,6 +77,7 @@ static void OneKeyStartObservingPasteboard(void) OneKeyRefreshPasteboardImageState(); }]; } + OneKeyRefreshPasteboardImageState(); }); } @@ -209,7 +232,9 @@ - (void)onekey_paste:(id)sender - (BOOL)onekey_canPerformAction:(SEL)action withSender:(id)sender { - if (action == @selector(paste:) && OneKeyPasteboardHasImages.load()) { + if (action == @selector(paste:) && + (OneKeyPasteboardRefresh.load() != OneKeyPasteboardRefreshState::Idle || + OneKeyPasteboardImageCache.load() != OneKeyPasteboardImageState::NoImages)) { return YES; } return [self onekey_canPerformAction:action withSender:sender]; @@ -243,7 +268,9 @@ - (void)onekey_paste:(id)sender - (BOOL)onekey_canPerformAction:(SEL)action withSender:(id)sender { - if (action == @selector(paste:) && OneKeyPasteboardHasImages.load()) { + if (action == @selector(paste:) && + (OneKeyPasteboardRefresh.load() != OneKeyPasteboardRefreshState::Idle || + OneKeyPasteboardImageCache.load() != OneKeyPasteboardImageState::NoImages)) { return YES; } return [self onekey_canPerformAction:action withSender:sender]; diff --git a/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm b/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm new file mode 100644 index 000000000..96c880f90 --- /dev/null +++ b/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm @@ -0,0 +1,95 @@ +#import +#import +#import + +static dispatch_semaphore_t OneKeyTestReadStarted; +static dispatch_semaphore_t OneKeyTestReleaseRead; + +@interface UIPasteboard (OneKeyPasteCacheTests) +- (BOOL)onekey_test_hasImages; +@end + +@implementation UIPasteboard (OneKeyPasteCacheTests) + +- (BOOL)onekey_test_hasImages +{ + dispatch_semaphore_t releaseRead; + dispatch_semaphore_t readStarted; + @synchronized (UIPasteboard.class) { + releaseRead = OneKeyTestReleaseRead; + readStarted = OneKeyTestReadStarted; + } + if (!NSThread.isMainThread && releaseRead != nil) { + dispatch_semaphore_signal(readStarted); + dispatch_semaphore_wait(releaseRead, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + } + return [self onekey_test_hasImages]; +} + +@end + +@interface OneKeyTextInputPasteObserverTests : XCTestCase +@end + +@implementation OneKeyTextInputPasteObserverTests + +- (void)assertImagePasteAvailableWhileRefreshingAfterNotification:(NSNotificationName)name +{ + UIPasteboard *pasteboard = UIPasteboard.generalPasteboard; + NSArray *> *previousItems = pasteboard.items; + RCTUITextField *field = [[RCTUITextField alloc] initWithFrame:CGRectZero]; + SEL paste = @selector(paste:); + + @try { + pasteboard.items = @[]; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5]; + while ([field canPerformAction:paste withSender:nil] && [deadline timeIntervalSinceNow] > 0) { + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + } + XCTAssertFalse([field canPerformAction:paste withSender:nil]); + + @synchronized (UIPasteboard.class) { + OneKeyTestReadStarted = dispatch_semaphore_create(0); + OneKeyTestReleaseRead = dispatch_semaphore_create(0); + } + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Method original = class_getInstanceMethod(UIPasteboard.class, @selector(hasImages)); + Method replacement = class_getInstanceMethod(UIPasteboard.class, @selector(onekey_test_hasImages)); + method_exchangeImplementations(original, replacement); + }); + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(1, 1)]; + pasteboard.image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { + [UIColor.blackColor setFill]; + UIRectFill(CGRectMake(0, 0, 1, 1)); + }]; + XCTAssertEqual(dispatch_semaphore_wait(OneKeyTestReadStarted, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), 0L); + + [NSNotificationCenter.defaultCenter postNotificationName:name object:field]; + XCTAssertTrue([field canPerformAction:paste withSender:nil]); + } @finally { + dispatch_semaphore_t releaseRead; + @synchronized (UIPasteboard.class) { + releaseRead = OneKeyTestReleaseRead; + OneKeyTestReleaseRead = nil; + OneKeyTestReadStarted = nil; + } + if (releaseRead != nil) { + dispatch_semaphore_signal(releaseRead); + } + pasteboard.items = previousItems; + } +} + +- (void)testFirstFocusKeepsImagePasteAvailableDuringRefresh +{ + [self assertImagePasteAvailableWhileRefreshingAfterNotification:UITextFieldTextDidBeginEditingNotification]; +} + +- (void)testForegroundResumeKeepsImagePasteAvailableDuringRefresh +{ + [self assertImagePasteAvailableWhileRefreshingAfterNotification:UIApplicationDidBecomeActiveNotification]; +} + +@end From e434d8cd053b0d367f55061a79cc7ec9ca864b3a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 16:55:20 +0800 Subject: [PATCH 3/4] test: gate source text assertions across app-modules --- .github/workflows/test-integrity.yml | 26 + .../lint/test-integrity.allowlist.json | 3 + development/lint/test-integrity.js | 3156 +++++++++++++++++ development/lint/test-integrity.node-test.js | 2734 ++++++++++++++ package.json | 3 + yarn.lock | 6 +- 6 files changed, 5926 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test-integrity.yml create mode 100644 development/lint/test-integrity.allowlist.json create mode 100644 development/lint/test-integrity.js create mode 100644 development/lint/test-integrity.node-test.js diff --git a/.github/workflows/test-integrity.yml b/.github/workflows/test-integrity.yml new file mode 100644 index 000000000..3723804e4 --- /dev/null +++ b/.github/workflows/test-integrity.yml @@ -0,0 +1,26 @@ +name: test-integrity + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test-integrity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + - name: Install dependencies + run: | + corepack enable + yarn install --immutable + - name: Test integrity checker + run: node --test development/lint/test-integrity.node-test.js + - name: Check repository tests + run: yarn lint:test-integrity diff --git a/development/lint/test-integrity.allowlist.json b/development/lint/test-integrity.allowlist.json new file mode 100644 index 000000000..046955d4b --- /dev/null +++ b/development/lint/test-integrity.allowlist.json @@ -0,0 +1,3 @@ +{ + "entries": [] +} diff --git a/development/lint/test-integrity.js b/development/lint/test-integrity.js new file mode 100644 index 000000000..705c0a239 --- /dev/null +++ b/development/lint/test-integrity.js @@ -0,0 +1,3156 @@ +#!/usr/bin/env node +/* cspell:words quasis pbxproj combinators overrider overriders */ +/** + * Test integrity lint. + * + * Rejects tests that assert on the *text* of first-party source instead of + * executing it. Such a test passes the moment it is written, fails on any + * unrelated refactor, and can never fail for a real defect. + * + * Rules: + * source-text-assertion read a first-party source file, assert on its text + * source-slice-eval slice a first-party source file, eval the fragment + * missing-subject-import test loads no first-party module at all + * + * Usage: + * node development/lint/test-integrity.js human report, exit 1 on violations + * node development/lint/test-integrity.js --json machine readable report + * node development/lint/test-integrity.js --list whole-file violations, one path per line + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { parse } = require('@babel/parser'); +const { NodePath } = require('@babel/traverse'); + +const REPO_ROOT = path.resolve(__dirname, '../..'); +const ALLOWLIST_PATH = path.join(__dirname, 'test-integrity.allowlist.json'); + +const TEST_FILE_RE = /\.(?:test|spec|node-test)\.(?:ts|tsx|js|jsx|mjs|cjs)$/u; +const SKIP_DIRECTORIES = new Set([ + '.cxx', + '.expo', + '.git', + '.yarn', + 'Pods', + 'build', + 'coverage', + 'dist', + 'node_modules', + // Generated mobile bundle output, gitignored. + 'out-dir-bundle', +]); +// `ios` and `android` are skipped only when they are native project roots, +// which is where the cost is. A JavaScript directory that happens to use one +// of those names is scanned like any other. +const PLATFORM_DIRECTORY_NAMES = new Set(['ios', 'android']); +const NATIVE_PROJECT_MARKERS = new Set([ + 'Podfile', + 'build.gradle', + 'build.gradle.kts', + 'settings.gradle', + 'settings.gradle.kts', + 'gradlew', +]); + +function isNativeProjectRoot(directory) { + let names; + try { + names = fs.readdirSync(directory); + } catch { + return false; + } + return names.some( + (name) => + NATIVE_PROJECT_MARKERS.has(name) || + name.endsWith('.xcodeproj') || + name.endsWith('.xcworkspace'), + ); +} + +// A path literal naming first-party source. Build output and vendored code are +// legitimate read targets (supply-chain gates audit artifacts, not source). +const SCRIPT_PATH_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/u; +// A JS test cannot execute these, so a text assertion is the only tool Jest has. +// Reported so the check can move to the native toolchain, but never gated. +const NATIVE_PATH_RE = /\.(?:kt|kts|swift|java|mm?|gradle|podspec|sh)$/u; +// Any dotted final segment, so `.text-js`, `.pbxproj` and `.gitignore` are +// treated as data rather than falling through as unextended source. +const ANY_EXTENSION_RE = /\.[A-Za-z0-9_-]{1,12}$/u; +// Matches a trailing segment too, so a directory that merely ends in +// `node_modules` counts as an artifact root. +const ARTIFACT_PATH_RE = + /(?:^|\/)(?:node_modules|dist|build|out|\.next)(?:\/|$)/u; +// A regex used to pick source files out of a directory listing. +const SOURCE_FILTER_RE = /\.\((?:\?:)?[a-z|]*(?:tsx?|jsx?)[a-z|]*\)/u; + +// Cheap pre-filter so the parser only runs on files that could violate. +const SCANNABLE_SOURCE_RE = /readFileSync|readFile\s*\(|import |require\(/u; + +const READ_FUNCTIONS = new Set(['readFileSync', 'readFile']); +const TEXT_MATCHERS = new Set([ + 'toContain', + 'toContainEqual', + 'toMatch', + 'toEqual', + 'toBe', + 'toStrictEqual', + 'toHaveLength', + // Object-shaped assertions are still assertions about the values inside. + 'toMatchObject', + 'toHaveProperty', + // `expect(source.indexOf(a)).toBeLessThan(source.indexOf(b))` and + // `expect(source.match(re)).toBeTruthy()` are the same assertion in disguise. + 'toBeTruthy', + 'toBeFalsy', + 'toBeDefined', + 'toBeUndefined', + 'toBeNull', + 'toBeGreaterThan', + 'toBeGreaterThanOrEqual', + 'toBeLessThan', + 'toBeLessThanOrEqual', +]); +// Where assertion functions are imported from, however they are renamed. +const ASSERT_MODULE_RE = /^(?:node:)?assert(?:\/strict)?$/u; +const EXPECT_MODULE_RE = /^(?:@jest\/globals|expect|vitest)$/u; +// node:assert, used by the *.node-test.js files this check also scans. +const ASSERT_TEXT_METHODS = new Set([ + 'match', + 'doesNotMatch', + 'ok', + 'equal', + 'notEqual', + 'strictEqual', + 'notStrictEqual', + 'deepEqual', + 'notDeepEqual', + 'deepStrictEqual', + 'notDeepStrictEqual', +]); +// Methods that take the source text as an argument rather than a receiver. +// Every other method propagates from its receiver, whatever it is named. +const ARGUMENT_PROPAGATORS = new Set(['test', 'exec', 'replace', 'replaceAll']); +// `Promise.all([read(a), read(b)])` settles with the texts it was handed. +const PROMISE_COMBINATORS = new Set(['all', 'allSettled', 'any', 'race']); +// Iteration methods whose result is decided by what the callback saw. A read +// inside the callback is still a claim about that text, wherever the assertion +// finally lands. Each maps to the callback parameters that receive the +// receiver's text: the element and the array, and a reducer's accumulator. +const CALLBACK_PROPAGATORS = new Map([ + ['filter', [0, 2]], + ['map', [0, 2]], + ['flatMap', [0, 2]], + ['some', [0, 2]], + ['every', [0, 2]], + ['find', [0, 2]], + ['findIndex', [0, 2]], + ['findLast', [0, 2]], + ['findLastIndex', [0, 2]], + ['reduce', [0, 1, 3]], + ['reduceRight', [0, 1, 3]], + ['sort', [0, 1]], + ['forEach', [0, 2]], +]); +// These cannot cut a fragment out, so they leave a whole read whole. `replace` +// is deliberately absent: a regex replace is one of the ways to cut. +const WHOLE_PRESERVING_METHODS = new Set([ + 'toString', + 'valueOf', + 'trim', + 'trimStart', + 'trimEnd', + 'normalize', +]); +// Cutting a fragment out of a file is what makes an eval a reconstruction of a +// unit that could not be imported. Evaluating a whole file is a different +// thing: a text artifact shipped to another runtime, checked by running it. +const EVAL_FUNCTIONS = new Set([ + 'runInNewContext', + 'runInThisContext', + 'runInContext', + 'transformSync', + 'transformFileSync', + 'transform', + // The rest of the vm surface: `new vm.Script(code)` and + // `vm.compileFunction(code)` run text just as `runInNewContext` does. + 'Script', + 'compileFunction', + // Direct evaluation, with or without a vm. `new Function(...)` reaches here + // as a NewExpression, which the sink handles alongside calls. + 'eval', + 'Function', +]); + +const TEST_BLOCK_NAMES = new Set(['it', 'test', 'fit', 'xit', 'xtest']); +// Reported for visibility but never fails the gate: script-level tests that +// drive a real subprocess legitimately load nothing first-party. +const ADVISORY_RULES = new Set([ + 'missing-subject-import', + 'native-source-text-assertion', +]); +// Only a gated rule can be exempted; an advisory one never fails anything. +const GATED_RULES = new Set(['source-text-assertion', 'source-slice-eval']); +const NODE_BUILTIN_RE = + /^(?:node:)?(?:assert|buffer|child_process|crypto|events|fs|http|https|net|os|path|process|readline|stream|timers|url|util|vm|worker_threads|zlib)(?:\/|$)/u; +const TEST_TOOLING_RE = + /^(?:@babel\/|@jest\/|@swc\/|@testing-library\/|detox$|expect$|fast-glob$|glob$|jest|js-yaml$|supertest$|ts-morph$|typescript$|vitest|yaml$)/u; +const FIRST_PARTY_MODULE_RE = /^(?:\.{1,2}\/|@onekeyhq\/|@onekeyfe\/)/u; +// Every way a test can pull a module in, jest helpers included. +const MODULE_LOADERS = new Set([ + 'require', + 'import', + 'requireActual', + 'mock', + 'requireMock', + 'resolve', + 'unmock', +]); + +function loadAllowlist() { + if (!fs.existsSync(ALLOWLIST_PATH)) { + return []; + } + const parsed = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8')); + const entries = Array.isArray(parsed.entries) ? parsed.entries : []; + entries.forEach((entry, index) => { + const label = `entries[${index}]`; + if (typeof entry.file !== 'string' || !entry.file) { + throw new Error(`${label} needs a "file".`); + } + if (!GATED_RULES.has(entry.rule)) { + throw new Error( + `${label} rule must be one of: ${[...GATED_RULES].join(', ')}`, + ); + } + if (entry.block !== null && typeof entry.block !== 'string') { + throw new Error( + `${label} needs a "block": the exact it()/test() title, or null for a violation in shared setup.`, + ); + } + if (typeof entry.reason !== 'string' || entry.reason.trim().length < 40) { + throw new Error( + `${label} needs a "reason" saying why no runtime assertion can replace it.`, + ); + } + if (!Number.isInteger(entry.count) || entry.count < 1) { + throw new Error( + `${label} needs a "count": how many violations were reviewed, so a new one added to the same block is still reported.`, + ); + } + }); + return entries; +} + +function collectTestFiles(directory, collected) { + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return collected; + } + for (const entry of entries.filter((item) => !item.name.startsWith('.'))) { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + const skipped = + SKIP_DIRECTORIES.has(entry.name) || + (PLATFORM_DIRECTORY_NAMES.has(entry.name) && + isNativeProjectRoot(absolutePath)); + if (!skipped) { + collectTestFiles(absolutePath, collected); + } + } else if (TEST_FILE_RE.test(entry.name)) { + collected.push(absolutePath); + } + } + return collected; +} + +function parseSource(source) { + return parse(source, { + sourceType: 'unambiguous', + allowReturnOutsideFunction: true, + errorRecovery: true, + plugins: ['typescript', 'jsx', 'classProperties', 'decorators-legacy'], + }); +} + +// Babel reports a redeclared name through the hub it is given, and without one +// it fails with a TypeError about the missing hub instead. A redeclaration +// makes the file invalid, so it is reported the way any parse error is. +const SYNTAX_ERROR_HUB = { + getCode() {}, + getScope() {}, + addHelper() { + throw new Error('test-integrity reads code; it never adds helpers'); + }, + buildError: (_node, message) => new SyntaxError(message), +}; + +// Every identifier that names a variable, mapped to the binding it resolves +// to. Taint, path anchoring and helpers are all keyed by binding, so two +// variables that happen to share a name never share a verdict. Keyed by node, +// so nothing needs resetting between files. +const bindingByIdentifier = new WeakMap(); + +function bindingOf(node) { + return node?.type === 'Identifier' + ? bindingByIdentifier.get(node) + : undefined; +} + +function resolveBindings(ast) { + const program = NodePath.get({ + hub: SYNTAX_ERROR_HUB, + parentPath: null, + parent: ast, + container: ast, + key: 'program', + }).setContext(); + // A name nothing declares is one global, however many places use it. + const globals = new Map(); + program.traverse({ + Identifier(identifierPath) { + if ( + !identifierPath.isReferencedIdentifier() && + !identifierPath.isBindingIdentifier() + ) { + return; + } + const { node, parent } = identifierPath; + // A function declaration's name belongs to the scope around it. Looked + // up from the function itself, a parameter of the same name would win. + const scope = + parent.type === 'FunctionDeclaration' && parent.id === node + ? identifierPath.parentPath.scope.parent + : identifierPath.scope; + let binding = scope.getBinding(node.name); + if (!binding) { + if (!globals.has(node.name)) { + globals.set(node.name, { global: node.name }); + } + binding = globals.get(node.name); + } + bindingByIdentifier.set(node, binding); + }, + }); +} + +// Some values hold text the way a variable does without being one: what a +// constructed promise settles with, what a named function returns, and what is +// stored under a property of a variable. Each gets a stable key of its own, so +// definitions taint it exactly as they taint a binding. +const SETTLED_VALUES = new WeakMap(); +const RETURNED_VALUES = new WeakMap(); +const STORED_VALUES = new WeakMap(); + +function derivedKey(keys, owner, name) { + if (!owner || name === undefined) { + return undefined; + } + if (!keys.has(owner)) { + keys.set(owner, new Map()); + } + const byName = keys.get(owner); + if (!byName.has(name)) { + byName.set(name, { derivedFrom: owner, name }); + } + return byName.get(name); +} + +/** The key for what `new Promise(...)` settles with. */ +function settledKey(node) { + return derivedKey(SETTLED_VALUES, node, 'settled'); +} + +/** The key for what a call to the function bound as `binding` returns. */ +function returnedKey(binding) { + return derivedKey(RETURNED_VALUES, binding, 'returned'); +} + +/** The key for what the variable bound as `owner` stores under `name`. */ +function propertyKey(owner, name) { + return derivedKey(STORED_VALUES, owner, name); +} + +/** + * `ctx.source`, `ctx['source']`, `files[0]` and `load().source`: the key for + * what that property of that owner holds. + */ +function storedKey(node) { + if ( + node?.type !== 'MemberExpression' && + node?.type !== 'OptionalMemberExpression' + ) { + return undefined; + } + return propertyKey(ownerKey(node.object), staticName(node.property, node)); +} + +/** + * What properties can be recorded against: a variable, a property of one + * (`results[1].value`), or what a call to a named function returns, awaited + * or not. + */ +function ownerKey(node) { + switch (node?.type) { + case 'Identifier': + return bindingOf(node); + case 'MemberExpression': + case 'OptionalMemberExpression': + return storedKey(node); + case 'AwaitExpression': + return ownerKey(node.argument); + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + return ownerKey(node.expression); + case 'CallExpression': + case 'OptionalCallExpression': + return node.callee.type === 'Identifier' + ? returnedKey(bindingOf(node.callee)) + : undefined; + default: + return undefined; + } +} + +/** The name a property key or member access spells out, if it is static. */ +function staticName(key, container) { + if (key.type === 'StringLiteral') { + return key.value; + } + if (key.type === 'NumericLiteral') { + return String(key.value); + } + return key.type === 'Identifier' && !container.computed + ? key.name + : undefined; +} + +function holdsWholeFile(key, tainted, fragments) { + return tainted.has(key) && !fragments.has(key); +} + +const NON_CHILD_KEYS = new Set(['loc', 'leadingComments', 'trailingComments']); + +function childKeys(node) { + return Object.keys(node).filter((key) => !NON_CHILD_KEYS.has(key)); +} + +function walk(node, visit, parent) { + if (!node || typeof node.type !== 'string') { + return; + } + visit(node, parent); + for (const key of childKeys(node)) { + const value = node[key]; + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child.type === 'string') { + walk(child, visit, node); + } + } + } else if (value && typeof value.type === 'string') { + walk(value, visit, node); + } + } +} + +function collectStringLiterals(node, collected = []) { + walk(node, (current) => { + if (current.type === 'StringLiteral') { + collected.push(current.value); + } else if (current.type === 'TemplateElement') { + collected.push(current.value.cooked ?? current.value.raw ?? ''); + } + }); + return collected; +} + +function calleeName(callee) { + if (!callee) { + return undefined; + } + if (callee.type === 'Identifier') { + return callee.name; + } + // `(0, eval)(code)`, the standard way to reach indirect eval. + if (callee.type === 'SequenceExpression') { + return calleeName(callee.expressions.at(-1)); + } + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' + ) { + return callee.property.name; + } + return undefined; +} + +/** + * Classify a read target: 'script' for first-party JS/TS the test could have + * imported instead, 'native' for sources no JS runtime can execute, undefined + * for build artifacts, data files and temp directories. + */ +/** + * Classify a path expression on its own, wherever it was written. `callSite` + * carries what a wrapper's caller contributes: the literals of the argument it + * passed, and whether that argument names the file outright. + */ +function classifyPath(pathArgument, repoAnchored, anchoredHelpers, callSite) { + if (!pathArgument) { + return undefined; + } + // A checked-in file is always reached from `__dirname`. A path built from a + // temp directory or a fixture root is something the test itself produced, so + // reading it is not a source-text assertion however the file is named. + // When a wrapper's caller supplies the head of the path, only the caller can + // say where that path starts; the parameter name in the template says nothing. + const anchored = callSite?.headIsParameter + ? callSite.anchored + : isRepoAnchored(pathArgument, repoAnchored, anchoredHelpers); + if (!anchored) { + return undefined; + } + const literals = [ + ...(callSite?.literals ?? []), + ...pathLiterals(pathArgument, repoAnchored), + ]; + if (literals.some((literal) => ARTIFACT_PATH_RE.test(literal))) { + return undefined; + } + if (literals.some((literal) => SCRIPT_PATH_RE.test(literal))) { + return 'script'; + } + if (literals.some((literal) => NATIVE_PATH_RE.test(literal))) { + return 'native'; + } + // A literal extension we do not police (.css, .yml, .json, .md) is data, not + // source; asserting on it is a config contract, not a fake unit test. + if (literals.some((literal) => ANY_EXTENSION_RE.test(literal))) { + return undefined; + } + // No extension to go on. Only two shapes still say "source": a sibling of the + // test file named by a variable, and a module specifier. Anything else + // anchored but unextended is left alone. + // A wrapper's tail is whatever its caller passed, not the parameter name. + if (callSite && !callSite.endsInVariable) { + return undefined; + } + return namesUnextendedSource(pathArgument, literals, repoAnchored) + ? 'script' + : undefined; +} + +/** + * 'script' | 'native' | undefined for the source text a node carries. + * `tainted` maps a binding to the kind of source it can hold; `calls` says how + * a call relates to source text (see analyzeFile). + */ +function taintKind(node, tainted, calls) { + if (!node) { + return undefined; + } + switch (node.type) { + case 'Identifier': + return tainted.get(bindingOf(node)); + case 'AwaitExpression': + return taintKind(node.argument, tainted, calls); + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + return taintKind(node.expression, tainted, calls); + case 'MemberExpression': + case 'OptionalMemberExpression': { + const stored = storedKey(node); + // A property an object or array literal spells out holds what it was + // given, and what any spread that can replace it holds under that + // name, and nothing else: `ctx.count` is not source because + // `ctx.source` is. A property only ever assigned may still hold + // whatever the rest of its owner does. + if (stored?.fromLiteral) { + return tainted.get(stored); + } + return ( + (stored?.defined ? tainted.get(stored) : undefined) ?? + taintKind(node.object, tainted, calls) + ); + } + case 'TemplateLiteral': + return firstTaint(node.expressions, tainted, calls); + case 'BinaryExpression': + return node.operator === '+' + ? firstTaint([node.left, node.right], tainted, calls) + : undefined; + case 'UnaryExpression': + // `expect(!source.includes(x))` is still a claim about source. + return node.operator === '!' + ? taintKind(node.argument, tainted, calls) + : undefined; + case 'LogicalExpression': + return firstTaint([node.left, node.right], tainted, calls); + case 'ConditionalExpression': + return firstTaint([node.consequent, node.alternate], tainted, calls); + case 'ArrayExpression': + return firstTaint(node.elements, tainted, calls); + case 'ObjectExpression': + // `{ text: read(...) }` and `{ source }` hold source text as a value. + return firstTaint( + node.properties.map((property) => + property.type === 'SpreadElement' + ? property.argument + : property.value, + ), + tainted, + calls, + ); + case 'SpreadElement': + return taintKind(node.argument, tainted, calls); + case 'SequenceExpression': + return taintKind(node.expressions.at(-1), tainted, calls); + case 'NewExpression': + // `new Promise(...)` holds whatever its executor resolves it with. + return tainted.get(settledKey(node)); + case 'CallExpression': + case 'OptionalCallExpression': { + const fromRead = calls.readKind(node); + if (fromRead) { + return fromRead; + } + // `normalize(source)` hands its argument's text back, reworked. + const transformed = calls.transform(node); + const handedBack = + transformed && taintKind(transformed.argument, tainted, calls); + if (handedBack) { + return handedBack; + } + // `loadBody()` hands back whatever its own returns carry. + const returned = tainted.get(returnedKey(bindingOf(node.callee))); + if (returned) { + return returned; + } + const name = calleeName(node.callee); + if ( + node.callee.type === 'MemberExpression' || + node.callee.type === 'OptionalMemberExpression' + ) { + // Any method called on source text keeps the claim about that text + // alive, whatever it is named: .slice, .indexOf, .split().filter(). + const fromReceiver = taintKind(node.callee.object, tainted, calls); + if (fromReceiver) { + return fromReceiver; + } + // `/re/.test(source)` and `x.replace(source, y)` carry the text in an + // argument instead, as do the Promise combinators; no other method is + // assumed to. + if ( + name && + (ARGUMENT_PROPAGATORS.has(name) || + (PROMISE_COMBINATORS.has(name) && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Promise')) + ) { + return firstTaint(node.arguments, tainted, calls); + } + // `files.filter((f) => readFileSync(f).includes(x))` decides its result + // from source text even though nothing tainted was passed in. + if (name && CALLBACK_PROPAGATORS.has(name)) { + return node.arguments + .map((argument) => callbackBodyTaint(argument, tainted, calls)) + .find(Boolean); + } + return undefined; + } + if (node.callee.type === 'Identifier' && name === 'String') { + return taintKind(node.arguments[0], tainted, calls); + } + return undefined; + } + default: + return undefined; + } +} + +/** + * Is this the unmodified contents of one file? Defined this way round on + * purpose: every other shape - a slice, a regex replace, a join of matches - + * is a fragment, and enumerating the ways to cut a string up is a losing game. + * `fragments` holds the bindings that can hold less than a whole file. + */ +function isWholeFileRead(node, tainted, fragments, calls) { + if (!node) { + return false; + } + const partsAreWhole = (parts) => + parts.every( + (part) => + !taintKind(part, tainted, calls) || + isWholeFileRead(part, tainted, fragments, calls), + ); + switch (node.type) { + case 'Identifier': + return holdsWholeFile(bindingOf(node), tainted, fragments); + case 'NewExpression': + return holdsWholeFile(settledKey(node), tainted, fragments); + case 'MemberExpression': + case 'OptionalMemberExpression': { + const stored = storedKey(node); + return ( + Boolean(stored?.defined) && holdsWholeFile(stored, tainted, fragments) + ); + } + case 'AwaitExpression': + return isWholeFileRead(node.argument, tainted, fragments, calls); + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + return isWholeFileRead(node.expression, tainted, fragments, calls); + case 'TemplateLiteral': + return partsAreWhole(node.expressions); + case 'BinaryExpression': + return node.operator === '+' && partsAreWhole([node.left, node.right]); + case 'CallExpression': + case 'OptionalCallExpression': { + const name = calleeName(node.callee); + if (calls.readKind(node)) { + return true; + } + // A transform helper hands a whole file back whole only if its own body + // does: `(text) => text.trim()` does, `(text) => text.slice(1)` cuts. + const transformed = calls.transform(node); + if (transformed && taintKind(transformed.argument, tainted, calls)) { + return ( + transformed.preservesWhole && + isWholeFileRead(transformed.argument, tainted, fragments, calls) + ); + } + const returned = returnedKey(bindingOf(node.callee)); + if (tainted.has(returned)) { + return holdsWholeFile(returned, tainted, fragments); + } + if ( + name && + WHOLE_PRESERVING_METHODS.has(name) && + (node.callee.type === 'MemberExpression' || + node.callee.type === 'OptionalMemberExpression') + ) { + return isWholeFileRead(node.callee.object, tainted, fragments, calls); + } + if (node.callee.type === 'Identifier' && name === 'String') { + return isWholeFileRead(node.arguments[0], tainted, fragments, calls); + } + return false; + } + default: + return false; + } +} + +const FUNCTION_NODE_TYPES = new Set([ + 'ArrowFunctionExpression', + 'FunctionExpression', + 'FunctionDeclaration', + 'ObjectMethod', + 'ClassMethod', +]); + +/** Walk a function body without descending into functions nested inside it. */ +function walkOwnBody(node, visit) { + if (!node || typeof node.type !== 'string') { + return; + } + visit(node); + for (const key of childKeys(node)) { + const value = node[key]; + const children = Array.isArray(value) ? value : [value]; + for (const child of children) { + if ( + child && + typeof child.type === 'string' && + !FUNCTION_NODE_TYPES.has(child.type) + ) { + walkOwnBody(child, visit); + } + } + } +} + +/** + * The source an assertion call makes a claim about, and how the call spells + * it: an `expect()` matcher, a `node:assert` method, a call to a helper that + * asserts on the argument it is given, or such a helper handed to a callback + * that receives the text. + */ +function assertedSource(node, tainted, calls, helpers) { + if ( + node.type !== 'CallExpression' && + node.type !== 'OptionalCallExpression' + ) { + return undefined; + } + const name = calleeName(node.callee); + if (name && TEXT_MATCHERS.has(name)) { + const expectCall = findExpectCall(node.callee); + const kind = expectCall && firstTaint(expectCall.arguments, tainted, calls); + if (kind) { + return { kind, label: `expect(...).${name}()` }; + } + } + const method = assertMethod(node.callee); + if (ASSERT_TEXT_METHODS.has(method)) { + const kind = firstTaint(node.arguments, tainted, calls); + if (kind) { + return { kind, label: `assert.${method}()` }; + } + } + const parameterIndex = helpers.get(bindingOf(node.callee)); + if (parameterIndex !== undefined) { + const kind = taintKind(node.arguments[parameterIndex], tainted, calls); + if (kind) { + return { kind, label: `${name}()` }; + } + } + // `lines.forEach(expectNoConsole)`: the helper is called with the text. + const handedToHelper = callbackSlots(node, calls).find( + (slot) => + slot.parameters.includes(helpers.get(bindingOf(slot.callback))) && + taintKind(slot.value, tainted, calls), + ); + return handedToHelper + ? { + kind: taintKind(handedToHelper.value, tainted, calls), + label: `${handedToHelper.callback.name}()`, + } + : undefined; +} + +/** + * Named helpers that make the assertion for you: `expectNoTimers(source)` puts + * the sink inside the helper, where the parameter is just a parameter. The + * claim is still made about whatever the caller handed over. A helper that + * hands its parameter on to another helper is one too, so this runs until no + * new helper turns up. + */ +function collectAssertionHelpers(ast, definitions, tainted, calls) { + const helpers = new Map(); + let before; + do { + before = helpers.size; + walk(ast, (node) => { + const named = namedFunction(node); + if (!named || helpers.has(named.binding)) { + return; + } + const index = named.parameters.findIndex( + (parameter) => + parameter !== undefined && + assertsOnParameter(named.fn, parameter, { + definitions, + tainted, + calls, + helpers, + }), + ); + if (index >= 0) { + helpers.set(named.binding, index); + } + }); + } while (helpers.size > before); + return helpers; +} + +/** + * Does anything inside `fn` - its callbacks and `it(...)` bodies included - + * assert on text that came in through `parameter`? The text is followed + * through the locals built from it. An assertion already about source without + * the parameter is recorded where it stands, so it makes nothing a helper. + */ +function assertsOnParameter( + fn, + parameter, + { definitions, tainted, calls, helpers }, +) { + const probe = new Map(tainted); + probe.set(parameter, 'script'); + propagateTaint( + definitions.filter( + (definition) => + definition.node.start >= fn.start && definition.node.end <= fn.end, + ), + probe, + new Set(), + calls, + ); + let asserts = false; + walk(fn.body, (node) => { + asserts = + asserts || + (Boolean(assertedSource(node, probe, calls, helpers)) && + !assertedSource(node, tainted, calls, helpers)); + }); + return asserts; +} + +/** + * A function declaration or a function-valued binding: the binding a call to + * it goes through, and the binding of each plain parameter. + */ +function namedFunction(node) { + let binding; + let fn; + if (node.type === 'FunctionDeclaration' && node.id) { + binding = bindingOf(node.id); + fn = node; + } else if ( + node.type === 'VariableDeclarator' && + node.id.type === 'Identifier' && + (node.init?.type === 'ArrowFunctionExpression' || + node.init?.type === 'FunctionExpression') + ) { + binding = bindingOf(node.id); + fn = node.init; + } + if (!binding) { + return undefined; + } + return { + binding, + fn, + parameters: fn.params.map((parameter) => + parameter.type === 'Identifier' ? bindingOf(parameter) : undefined, + ), + }; +} + +/** + * Named helpers that hand back a reworked version of one of their arguments, + * `(source) => source.replace(/\s+/gu, ' ')` being the usual shape. A call to + * one carries whatever text it was given. + */ +function collectTransformHelpers(ast, calls, helpers) { + // Until nothing new turns up, so a helper can call one defined below it. + let before; + do { + before = helpers.size; + walk(ast, (node) => { + const returned = returnedPathExpression(node); + if (!returned || helpers.has(returned.binding)) { + return; + } + returned.parameters.some((parameter, parameterIndex) => { + if (!parameter) { + return false; + } + const probe = new Map([[parameter, 'script']]); + if (!taintKind(returned.value, probe, calls)) { + return false; + } + helpers.set(returned.binding, { + parameterIndex, + preservesWhole: isWholeFileRead( + returned.value, + probe, + new Set(), + calls, + ), + }); + return true; + }); + }); + } while (helpers.size > before); +} + +/** + * Everywhere a binding is given a value: a declarator's initializer, the + * right-hand side of an assignment to it (compound ones included), a callback + * parameter a known API fills, or a `for...of` loop variable. `fragment` marks + * a value that is only ever part of the expression it comes from, such as one + * element of it. + */ +function collectDefinitions(ast, calls) { + const definitions = []; + const overrides = []; + const define = (node, value, identifiers, fragment = false) => { + const bindings = identifiers.map(bindingOf).filter(Boolean); + if (bindings.length > 0) { + definitions.push({ node, value, bindings, fragment }); + } + }; + const store = (node, { key, value, literal = false }) => { + key.defined = true; + key.fromLiteral = key.fromLiteral || literal; + definitions.push({ node, value, bindings: [key], fragment: false }); + }; + const namedFunctions = []; + walk(ast, (node) => { + const named = namedFunction(node); + if (named) { + namedFunctions.push(named); + } + const target = bindingTarget(node); + if (target) { + destructured(target).forEach(({ pattern, identifiers, value }) => { + define(node, value, identifiers); + // A variable given a literal knows what each part of it holds. + if (pattern?.type === 'Identifier') { + literalParts(bindingOf(pattern), value, overrides).forEach((part) => + store(node, part), + ); + } + }); + storedValues(target, overrides).forEach((part) => store(node, part)); + } + callbackSlots(node, calls).forEach( + ({ callback, parameters, value, fragment }) => { + if ( + callback?.type === 'ArrowFunctionExpression' || + callback?.type === 'FunctionExpression' + ) { + define( + callback, + value, + parameters.flatMap((index) => + patternIdentifiers(callback.params[index]), + ), + fragment, + ); + } + }, + ); + // A default value, for a parameter or inside a pattern, is one more value + // the identifiers it defaults can hold. + if (node.type === 'AssignmentPattern') { + define(node, node.right, patternIdentifiers(node.left)); + } + if (node.type === 'ForOfStatement') { + const pattern = + node.left.type === 'VariableDeclaration' + ? node.left.declarations[0]?.id + : node.left; + define(node, node.right, patternIdentifiers(pattern), true); + } + if ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'Promise' + ) { + const settled = settledKey(node); + settlements(node, calls).forEach((settlement) => + definitions.push({ ...settlement, bindings: [settled] }), + ); + } + }); + // What a named function returns, once every local it could build a path + // from is known. A read whose path comes from the function's parameters is + // left out: only the call says what that reads, which is why such a + // function is a read helper when it is simple enough to be one. + namedFunctions.forEach((named) => { + if (calls.isReadHelper(named.binding)) { + return; + } + const values = returnedValues(named.fn); + const reads = values.flatMap((value) => readCalls(value, calls)); + if (reads.length > 0 && named.parameters.some(Boolean)) { + const fromParameters = parameterDerived(named, definitions); + if ( + reads.some((read) => + read.arguments.some((argument) => + refersToAny(argument, fromParameters), + ), + ) + ) { + return; + } + } + const returned = returnedKey(named.binding); + values.forEach((value) => { + definitions.push({ + node: value, + value, + bindings: [returned], + fragment: false, + }); + // `return { source, ast }` hands back each property on its own too. + literalParts(returned, value, overrides).forEach((part) => + store(value, part), + ); + }); + }); + // A literal with a spread can bring in any name it does not spell out + // itself, so every name recorded on the same owner may also hold what each + // spread holds under that name, whichever literal recorded it. + overrides.forEach(({ node, owner, spelled, overriders }) => { + STORED_VALUES.get(owner)?.forEach((key, name) => { + if (key.defined && !spelled.has(name)) { + overriders.forEach((overrider) => + store(node, { key, value: overriddenValue(overrider, name) }), + ); + } + }); + }); + return definitions; +} + +/** + * What each identifier in a definition's pattern is given, however deeply + * the pattern nests. An element of an array literal goes to the pattern in the + * same position, a property of an object literal to the pattern under that + * key, and destructuring an owner reads that property as `ctx.source` or + * `files[0]` would. Anything else receives the whole value. + */ +function destructured({ pattern, value }) { + const whole = [{ pattern, identifiers: patternIdentifiers(pattern), value }]; + const part = (inner, innerValue) => + destructured({ pattern: inner, value: innerValue }); + if (pattern?.type === 'ArrayPattern') { + const literal = unwrapCollection(value); + if ( + literal?.type === 'ArrayExpression' && + !literal.elements.some((element) => element?.type === 'SpreadElement') + ) { + return pattern.elements.flatMap((element, index) => + element?.type === 'RestElement' + ? part(element, value) + : part(element, literal.elements[index]), + ); + } + if (!ownerKey(value)) { + return whole; + } + return pattern.elements.flatMap((element, index) => + element?.type === 'RestElement' + ? part(element, value) + : part( + element, + propertyAccess(value, { type: 'NumericLiteral', value: index }), + ), + ); + } + if (pattern?.type !== 'ObjectPattern') { + return whole; + } + const literal = unwrapCollection(value); + if (literal?.type !== 'ObjectExpression' && !ownerKey(value)) { + return whole; + } + return pattern.properties.flatMap((property) => { + const name = + property.type === 'ObjectProperty' + ? staticName(property.key, property) + : undefined; + if (name === undefined) { + return part( + property.type === 'RestElement' ? property : property.value, + value, + ); + } + return part( + property.value, + literal?.type === 'ObjectExpression' + ? literalProperty(literal, name) + : propertyAccess(value, property.key, property.computed), + ); + }); +} + +/** + * What an object literal gives `name`: the last property spelling it out, or + * the whole literal when a spread written after that property may replace it. + */ +function literalProperty(literal, name) { + for (let index = literal.properties.length - 1; index >= 0; index -= 1) { + const property = literal.properties[index]; + if (overridesAnyName(property)) { + return literal; + } + if ( + property.type === 'ObjectProperty' && + staticName(property.key, property) === name + ) { + return property.value; + } + } + return undefined; +} + +/** + * A property access the source never spells out, so that taint and wholeness + * are read for it exactly as they would be for `owner.name` or `owner[0]`. + */ +function propertyAccess(owner, key, computed = key.type !== 'Identifier') { + return { type: 'MemberExpression', object: owner, property: key, computed }; +} + +/** + * Properties a definition stores on a member: `ctx.source = read(...)` stores + * under `source` on `ctx`, along with anything the assigned literal spells + * out beneath it. + */ +function storedValues({ pattern, value }, overrides) { + const stored = storedKey(pattern); + return stored + ? [{ key: stored, value }, ...literalParts(stored, value, overrides)] + : []; +} + +/** + * The parts an object or array literal gives its owner, by property name or + * position, all the way down through nested literals. + */ +function literalParts(owner, value, overrides) { + const literal = unwrapCollection(value); + let parts = []; + if (literal?.type === 'ObjectExpression') { + const overriders = literal.properties + .map((property, index) => ({ property, index })) + .filter(({ property }) => overridesAnyName(property)); + const named = literal.properties + .map((property, index) => ({ + property, + index, + name: + property.type === 'ObjectProperty' + ? staticName(property.key, property) + : undefined, + })) + .filter(({ name }) => name !== undefined); + if (owner && overriders.length > 0) { + // Names this literal leaves to its spreads are resolved once every name + // recorded on the owner is known; see collectDefinitions. + overrides.push({ + node: literal, + owner, + spelled: new Set(named.map(({ name }) => name)), + overriders: overriders.map(({ property }) => property), + }); + } + parts = named + .flatMap(({ property, index, name }) => { + const key = propertyKey(owner, name); + // A spread or computed key written after a property can replace it, + // with whatever it holds under that name. + const later = overriders.filter((overrider) => overrider.index > index); + return [ + { + key, + value: property.value, + literal: true, + overridden: later.length > 0, + }, + ...later.map(({ property: overrider }) => ({ + key, + value: overriddenValue(overrider, name), + })), + ]; + }) + .filter(({ key }) => key); + } else if (literal?.type === 'ArrayExpression') { + const spread = literal.elements.findIndex( + (element) => element?.type === 'SpreadElement', + ); + parts = literal.elements + .slice(0, spread === -1 ? undefined : spread) + .map((element, index) => ({ + key: propertyKey(owner, String(index)), + value: element, + literal: true, + })) + .filter(({ value: element }) => element); + } + return parts.flatMap((part) => + part.literal && !part.overridden + ? [part, ...literalParts(part.key, part.value, overrides)] + : [part], + ); +} + +/** What a spread or computed key can put under `name`. */ +function overriddenValue(overrider, name) { + return overrider.type === 'SpreadElement' + ? propertyAccess( + overrider.argument, + { type: 'StringLiteral', value: name }, + true, + ) + : overrider.value; +} + +/** A spread, or a computed key whose name is not written down. */ +function overridesAnyName(property) { + return ( + property.type === 'SpreadElement' || + (property.type === 'ObjectProperty' && + staticName(property.key, property) === undefined) + ); +} + +/** + * The literal a collection settles into. `await Promise.all([...])` is the + * array of what each input settles with, and `Promise.allSettled([...])` the + * array of `{ status, value, reason }` records with each input as `value`. + */ +function unwrapCollection(node) { + switch (node?.type) { + case 'AwaitExpression': + return unwrapCollection(node.argument); + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + return unwrapCollection(node.expression); + case 'CallExpression': { + const name = + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Promise' + ? calleeName(node.callee) + : undefined; + const inputs = + name === 'all' || name === 'allSettled' + ? unwrapCollection(node.arguments[0]) + : undefined; + if (name === 'allSettled' && inputs?.type === 'ArrayExpression') { + return { + type: 'ArrayExpression', + elements: inputs.elements.map((input) => + input?.type === 'SpreadElement' ? input : settledRecord(input), + ), + }; + } + return name === 'all' ? inputs : node; + } + default: + return node; + } +} + +/** `{ status, value, reason }`, of which only `value` holds the input. */ +function settledRecord(input) { + const property = (name, value) => ({ + type: 'ObjectProperty', + key: { type: 'Identifier', name }, + value, + computed: false, + }); + return { + type: 'ObjectExpression', + properties: [ + property('status', undefined), + property('value', input), + property('reason', undefined), + ], + }; +} + +/** The expressions a function hands back to its caller. */ +function returnedValues(fn) { + if (fn.body.type !== 'BlockStatement') { + return [fn.body]; + } + const values = []; + walkOwnBody(fn.body, (node) => { + if (node.type === 'ReturnStatement' && node.argument) { + values.push(node.argument); + } + }); + return values; +} + +/** Bindings inside a function whose value is built from its parameters. */ +function parameterDerived({ fn, parameters }, definitions) { + const derived = new Set(parameters.filter(Boolean)); + const inside = definitions.filter( + (definition) => + definition.node.start >= fn.start && definition.node.end <= fn.end, + ); + let before; + do { + before = derived.size; + inside + .filter((definition) => refersToAny(definition.value, derived)) + .forEach((definition) => + definition.bindings.forEach((binding) => derived.add(binding)), + ); + } while (derived.size > before); + return derived; +} + +/** The reads in an expression, direct or through a read helper. */ +function readCalls(node, calls) { + const found = []; + walk(node, (current) => { + if ( + (current.type === 'CallExpression' || + current.type === 'OptionalCallExpression') && + (calls.isRead(current) || calls.isReadHelper(bindingOf(current.callee))) + ) { + found.push(current); + } + }); + return found; +} + +function refersToAny(node, bindings) { + let found = false; + walk(node, (current) => { + if (bindings.has(bindingOf(current))) { + found = true; + } + }); + return found; +} + +/** + * What a `new Promise(...)` executor resolves with: the argument of each + * `resolve(...)` call, and the text any callback slot hands to `resolve` + * passed by reference, as in `read(path).then(resolve)`. + */ +function settlements(promise, calls) { + const executor = promise.arguments[0]; + const resolve = + executor?.type === 'ArrowFunctionExpression' || + executor?.type === 'FunctionExpression' + ? bindingOf(executor.params[0]) + : undefined; + const found = []; + if (resolve) { + walk(executor.body, (node) => { + if ( + (node.type === 'CallExpression' || + node.type === 'OptionalCallExpression') && + bindingOf(node.callee) === resolve && + node.arguments[0] + ) { + found.push({ node, value: node.arguments[0], fragment: false }); + } + callbackSlots(node, calls) + .filter( + (slot) => + bindingOf(slot.callback) === resolve && slot.parameters.includes(0), + ) + .forEach((slot) => + found.push({ node, value: slot.value, fragment: slot.fragment }), + ); + }); + } + return found; +} + +/** + * Taint every binding a definition can fill with first-party source text, and + * record the ones it can fill with less than a whole file. Deliberately blind + * to control flow: a binding holds source if any definition puts it there, + * whether that sits in a hook, a callback or a later statement. A kind only + * ever rises and a fragment is never unmarked, so the loop always settles. + */ +function propagateTaint(definitions, tainted, fragments, calls) { + let changed = true; + while (changed) { + changed = false; + for (const { value, bindings, fragment } of definitions) { + const kind = taintKind(value, tainted, calls); + const filled = kind ? bindings : []; + const whole = + filled.length > 0 && + !fragment && + isWholeFileRead(value, tainted, fragments, calls); + for (const binding of filled) { + // 'script' outranks 'native': it is the kind that fails the gate. + const current = tainted.get(binding); + if (current !== kind && current !== 'script') { + tainted.set(binding, kind); + changed = true; + } + if (!whole && !fragments.has(binding)) { + fragments.add(binding); + changed = true; + } + } + } + } +} + +/** + * The source text a callback hands back, if any. Only the returned value + * counts: a read performed for a side effect does not decide the result. + */ +function callbackBodyTaint(node, tainted, calls) { + if ( + node?.type !== 'ArrowFunctionExpression' && + node?.type !== 'FunctionExpression' + ) { + return undefined; + } + if (node.body.type !== 'BlockStatement') { + return taintKind(node.body, tainted, calls); + } + let kind; + walkOwnBody(node.body, (current) => { + if (!kind && current.type === 'ReturnStatement') { + kind = taintKind(current.argument, tainted, calls); + } + }); + return kind; +} + +/** + * Where a call hands text to a callback it is given: the callback argument, + * which of its parameters receive the text, the expression that text comes + * from, and whether a parameter only ever gets part of it. + */ +function callbackSlots(node, calls) { + if ( + node.type !== 'CallExpression' && + node.type !== 'OptionalCallExpression' + ) { + return []; + } + // `readFile(path, 'utf8', (error, text) => ...)` + if (calls.isRead(node)) { + return [ + { + callback: node.arguments.at(-1), + parameters: [1], + value: node, + fragment: false, + }, + ]; + } + if ( + node.callee.type !== 'MemberExpression' && + node.callee.type !== 'OptionalMemberExpression' + ) { + return []; + } + const name = calleeName(node.callee); + const receiver = node.callee.object; + // `readFile(path, 'utf8').then((text) => ...)` + if (name === 'then') { + return [ + { + callback: node.arguments[0], + parameters: [0], + value: receiver, + fragment: false, + }, + ]; + } + // `source.split('\n').forEach((line) => ...)` + if (CALLBACK_PROPAGATORS.has(name)) { + return [ + { + callback: node.arguments[0], + parameters: CALLBACK_PROPAGATORS.get(name), + value: receiver, + fragment: true, + }, + ]; + } + // `source.replace(/import .*/gu, (statement) => ...)` + if (name === 'replace' || name === 'replaceAll') { + return [ + { + callback: node.arguments[1], + parameters: [0], + value: receiver, + fragment: true, + }, + ]; + } + return []; +} + +function firstTaint(nodes, tainted, calls) { + for (const node of nodes ?? []) { + const kind = taintKind(node, tainted, calls); + if (kind) { + return kind; + } + } + return undefined; +} + +// `path.dirname` and `require.resolve` land inside the tree; `process.cwd()` is +// the repository root because Jest runs from it. +const PATH_BUILDERS = new Set(['join', 'resolve', 'normalize', 'dirname']); +const REPO_ANCHOR_IDENTIFIERS = new Set(['__dirname', '__filename']); +// A checked-in path written literally: relative, or from a workspace root. +// Answers "does this stay inside the repository", so `../../..` qualifies. +const WORKSPACE_PATH_RE = /^(?:\.{1,2}\/|apps\/|packages\/|development\/)/u; +// Answers the narrower "does this name a directory that holds source", which +// an ascent like `../../..` and a dotfile directory like `.github/` do not. +const SOURCE_DIRECTORY_RE = /^(?:apps|packages|development)\//u; + +/** + * The node:assert method a call reaches: `assert.equal(...)`, + * `assert.strict.equal(...)` and `t.assert.equal(...)` but not `x.equal(...)`, + * the module called directly as `assert(...)`, and any of them imported or + * required under another name. + */ +function assertMethod(callee) { + if (callee.type === 'Identifier') { + const imported = importOf(callee); + if (imported && ASSERT_MODULE_RE.test(imported.module)) { + return assertExportMethod(imported.name); + } + return callee.name === 'assert' ? 'ok' : undefined; + } + if (callee.type !== 'MemberExpression' || !reachesAssert(callee.object)) { + return undefined; + } + return assertExportMethod(staticName(callee.property, callee)); +} + +/** The module itself and `strict` are callable, and assert like `ok`. */ +function assertExportMethod(name) { + return name === 'default' || name === 'strict' ? 'ok' : name; +} + +/** `assert`, `assert.strict`, a test context's `t.assert`, or node:assert imported under any name. */ +function reachesAssert(node) { + if (node.type === 'Identifier') { + const imported = importOf(node); + return ( + node.name === 'assert' || + Boolean( + imported && + ASSERT_MODULE_RE.test(imported.module) && + assertExportMethod(imported.name) === 'ok', + ) + ); + } + return ( + node.type === 'MemberExpression' && + (staticName(node.property, node) === 'assert' || reachesAssert(node.object)) + ); +} + +/** + * The module and export an identifier was imported or required as, with + * `default` standing for a default import, a namespace, or the whole module. + */ +function importOf(identifier) { + const binding = bindingOf(identifier); + const declaration = binding?.path?.node; + switch (declaration?.type) { + case 'ImportDefaultSpecifier': + case 'ImportNamespaceSpecifier': + return { module: binding.path.parent.source.value, name: 'default' }; + case 'ImportSpecifier': + return { + module: binding.path.parent.source.value, + name: staticName(declaration.imported, declaration), + }; + case 'VariableDeclarator': + return requiredAs(declaration, binding.identifier); + default: + return undefined; + } +} + +/** + * `const assert = require('node:assert')`, `require('assert').strict`, and + * `const { match } = require('node:assert')`. + */ +function requiredAs(declarator, identifier) { + let required = declarator.init; + let name = 'default'; + if (required?.type === 'MemberExpression') { + name = staticName(required.property, required); + required = required.object; + } + if ( + required?.type !== 'CallExpression' || + required.callee.type !== 'Identifier' || + required.callee.name !== 'require' || + required.arguments[0]?.type !== 'StringLiteral' + ) { + return undefined; + } + const module = required.arguments[0].value; + if (declarator.id === identifier) { + return { module, name }; + } + const property = + name === 'default' && declarator.id.type === 'ObjectPattern' + ? declarator.id.properties.find( + (candidate) => + candidate.type === 'ObjectProperty' && + (candidate.value === identifier || + candidate.value.left === identifier), + ) + : undefined; + return property + ? { module, name: staticName(property.key, property) } + : undefined; +} + +/** Leading literal chunk of a template, which is where a path prefix sits. */ +function firstTemplateChunk(node) { + const [head] = node.quasis; + return head?.value.cooked ?? head?.value.raw ?? ''; +} + +/** Is this path expression rooted at the checked-in tree rather than a temp dir? */ +function isRepoAnchored(node, repoAnchored, anchoredHelpers) { + if (!node) { + return false; + } + switch (node.type) { + case 'Identifier': + return ( + REPO_ANCHOR_IDENTIFIERS.has(node.name) || + repoAnchored.has(bindingOf(node)) + ); + case 'StringLiteral': + return WORKSPACE_PATH_RE.test(node.value); + case 'TemplateLiteral': + return ( + WORKSPACE_PATH_RE.test(firstTemplateChunk(node)) || + node.expressions.some((expression) => + isRepoAnchored(expression, repoAnchored, anchoredHelpers), + ) + ); + case 'CallExpression': + case 'OptionalCallExpression': { + const name = calleeName(node.callee); + if (name === 'cwd') { + return true; + } + // A helper that returns a repository path, e.g. `repoRoot()`. Matched on + // a bare identifier so `fixture.repoRoot()` and a helper that happens to + // be named `resolve` cannot stand in for the head check below. + if (anchoredHelpers.has(bindingOf(node.callee))) { + return true; + } + // Only the head of a built path says where it starts. A workspace-shaped + // literal further along is a suffix under whatever the head was, which + // may well be a temp directory that mirrors the repository layout. + return ( + Boolean(name) && + PATH_BUILDERS.has(name) && + isRepoAnchored(node.arguments[0], repoAnchored, anchoredHelpers) + ); + } + default: + return false; + } +} + +/** + * Bindings that hold a path into the checked-in tree, mapped to the string + * literals that built them. A read whose argument is one of these bindings has + * no literal of its own, so the extension that decides source-vs-data lives + * here. + */ +function collectRepoAnchoredBindings(ast) { + const anchored = new Map(); + const helpers = new Set(); + let before; + do { + before = anchored.size; + walk(ast, (node) => { + const returned = returnedPathExpression(node); + if (returned && isRepoAnchored(returned.value, anchored, helpers)) { + helpers.add(returned.binding); + anchored.set(returned.binding, describePath(returned.value, anchored)); + return; + } + const target = bindingTarget(node); + if (!target || !isRepoAnchored(target.value, anchored, helpers)) { + return; + } + const described = describePath(target.value, anchored); + target.identifiers + .map(bindingOf) + .filter(Boolean) + .forEach((binding) => anchored.set(binding, described)); + }); + } while (anchored.size > before); + return { anchored, helpers }; +} + +/** + * What a path expression contributes to classifying a read made through it: + * the literals that built it, whether it ends in a value only known at run + * time, and whether its shape alone makes an unextended name source. All of + * it has to travel with the binding, because a read through a bare identifier + * carries none of its own. + */ +function describePath(node, anchored) { + const literals = pathLiterals(node, anchored); + return { + literals, + endsInVariable: endsInVariableName(node, anchored), + unextendedSource: namesUnextendedSource(node, literals, anchored), + }; +} + +/** `readFileSync`, `fs.readFileSync`, or a binding that aliases one. */ +function isReadReference(node, aliases) { + const name = calleeName(node); + if (!name) { + return false; + } + const reference = + node.type === 'SequenceExpression' ? node.expressions.at(-1) : node; + return READ_FUNCTIONS.has(name) || aliases.has(bindingOf(reference)); +} + +/** + * Bindings that stand in for `readFileSync` / `readFile`: an alias binding, a + * renamed destructure, or a renamed import. + */ +function collectReadAliases(ast) { + const aliases = new Set(); + const add = (identifier) => { + const binding = bindingOf(identifier); + if (binding) { + aliases.add(binding); + } + }; + let before; + do { + before = aliases.size; + walk(ast, (node) => { + if (node.type === 'ImportDeclaration') { + node.specifiers.forEach((specifier) => { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + READ_FUNCTIONS.has(specifier.imported.name) + ) { + add(specifier.local); + } + }); + return; + } + if (node.type !== 'VariableDeclarator') { + return; + } + // `const read = fs.readFileSync`, `const read = readFileSync`, and + // `const read = promisify(fs.readFile)` + if (node.id.type === 'Identifier') { + const reference = + calleeName(node.init?.callee) === 'promisify' + ? node.init.arguments[0] + : node.init; + if (reference && isReadReference(reference, aliases)) { + add(node.id); + } + return; + } + // `const { readFileSync: slurp } = require('fs')` + if (node.id.type === 'ObjectPattern') { + node.id.properties.forEach((property) => { + if ( + property.type === 'ObjectProperty' && + property.key.type === 'Identifier' && + READ_FUNCTIONS.has(property.key.name) + ) { + add(property.value); + } + }); + } + }); + } while (aliases.size > before); + return aliases; +} + +/** Is `binding` the head of this path expression, rather than a later segment? */ +function isHeadIdentifier(node, binding) { + if (!node || !binding) { + return false; + } + if (node.type === 'Identifier') { + return bindingOf(node) === binding; + } + if ( + node.type === 'CallExpression' || + node.type === 'OptionalCallExpression' + ) { + return isHeadIdentifier(node.arguments[0], binding); + } + return false; +} + +function refersTo(node, binding) { + let found = false; + walk(node, (current) => { + if (bindingOf(current) === binding) { + found = true; + } + }); + return found; +} + +/** + * Named helpers whose whole job is to read a file, mapped either to the kind + * their own path resolves to, or to the index of the parameter they read. + */ +function collectReadHelpers(ast, isReadCallee, classify) { + const helpers = new Map(); + walk(ast, (node) => { + const returned = returnedRead(node, isReadCallee); + const pathArgument = returned?.pathArgument; + if (!pathArgument) { + return; + } + const conditions = { + fallbackGuardChains: returned.fallbackGuardChains, + readGuards: returned.readGuards, + parameters: returned.parameters, + }; + // The whole path is the parameter: classify the call-site argument. + const passedWhole = returned.parameters.findIndex( + (parameter) => + parameter !== undefined && bindingOf(pathArgument) === parameter, + ); + if (passedWhole >= 0) { + helpers.set(returned.binding, { + ...conditions, + parameterIndex: passedWhole, + }); + return; + } + // The parameter is spliced into a path the helper owns, e.g. + // `(name) => readFileSync(join(__dirname, '__fixtures__', name))`. The + // filename still comes from the caller, so the body alone cannot say + // whether a call reads source or data. + const splicedIn = returned.parameters.findIndex( + (parameter) => + parameter !== undefined && refersTo(pathArgument, parameter), + ); + if (splicedIn >= 0) { + helpers.set(returned.binding, { + ...conditions, + parameterIndex: splicedIn, + path: pathArgument, + // `(root) => readFileSync(join(root, 'index.ts'))`: the caller supplies + // the head, so the caller decides whether this reads the repository. + headIsParameter: isHeadIdentifier( + pathArgument, + returned.parameters[splicedIn], + ), + }); + return; + } + const kind = classify(pathArgument); + if (kind) { + helpers.set(returned.binding, { ...conditions, kind }); + } + }); + return helpers; +} + +const FILE_AVAILABILITY_CHECKS = new Set([ + 'accessSync', + 'exists', + 'existsSync', + 'lstatSync', + 'statSync', +]); + +/** Whether a condition checks if a file or directory is available. */ +function hasFileAvailabilityCheck(node) { + let found = false; + walk(node, (current) => { + if ( + (current.type === 'CallExpression' || + current.type === 'OptionalCallExpression') && + FILE_AVAILABILITY_CHECKS.has(calleeName(current.callee)) + ) { + found = true; + } + }); + return found; +} + +/** Whether a statement always completes the current switch case. */ +function alwaysTerminates(node) { + if (!node) { + return false; + } + switch (node.type) { + case 'BreakStatement': + case 'ContinueStatement': + case 'ReturnStatement': + case 'ThrowStatement': + return true; + case 'BlockStatement': { + const last = node.body.at(-1); + return Boolean(last) && alwaysTerminates(last); + } + case 'IfStatement': + return ( + Boolean(node.alternate) && + alwaysTerminates(node.consequent) && + alwaysTerminates(node.alternate) + ); + case 'LabeledStatement': + return alwaysTerminates(node.body); + case 'TryStatement': + if (node.finalizer && alwaysTerminates(node.finalizer)) { + return true; + } + return ( + alwaysTerminates(node.block) && + (!node.handler || alwaysTerminates(node.handler.body)) + ); + default: + return false; + } +} + +/** Return values paired with the guards that select them. */ +function readHelperReturns(fn) { + if (fn.body.type !== 'BlockStatement') { + return [{ value: fn.body, guards: [], availabilityFallback: false }]; + } + const values = []; + const visit = (node, guards, availabilityFallback) => { + if (!node || typeof node.type !== 'string') { + return; + } + if (node.type === 'ReturnStatement') { + values.push({ value: node.argument, guards, availabilityFallback }); + return; + } + if (node.type === 'IfStatement') { + const availability = hasFileAvailabilityCheck(node.test); + const guard = { + kind: 'if', + test: node.test, + taken: true, + availability, + }; + visit( + node.consequent, + [...guards, guard], + availabilityFallback || availability, + ); + if (node.alternate) { + visit( + node.alternate, + [...guards, { ...guard, taken: false }], + availabilityFallback || availability, + ); + } + return; + } + if (node.type === 'SwitchStatement') { + const tests = node.cases.map((caseNode) => caseNode.test).filter(Boolean); + let fallthroughTests = []; + node.cases.forEach((caseNode) => { + const caseTests = [ + ...fallthroughTests, + { caseTest: caseNode.test, conditional: false }, + ]; + const guard = { + kind: 'switch', + caseTest: caseNode.test, + caseTests, + discriminant: node.discriminant, + tests, + }; + caseNode.consequent.forEach((statement) => + visit(statement, [...guards, guard], availabilityFallback), + ); + const stopsFallthrough = caseNode.consequent.some(alwaysTerminates); + if (stopsFallthrough) { + fallthroughTests = []; + } else if (caseNode.consequent.length === 0) { + fallthroughTests = caseTests; + } else { + fallthroughTests = caseTests.map((entry) => ({ + ...entry, + conditional: true, + })); + } + }); + return; + } + const childAvailability = + availabilityFallback || node.type === 'CatchClause'; + for (const key of childKeys(node)) { + const value = node[key]; + const children = Array.isArray(value) ? value : [value]; + for (const child of children) { + if ( + child && + typeof child.type === 'string' && + !FUNCTION_NODE_TYPES.has(child.type) + ) { + visit(child, guards, childAvailability); + } + } + } + }; + visit(fn.body, [], false); + return values; +} + +/** Whether a return is a static fallback, including a local constant. */ +function isStaticFallbackValue(value, fn, depth = 0) { + if (!value) { + return true; + } + if (value.type === 'Identifier') { + if (bindingOf(value)?.global === 'undefined') { + return true; + } + const constant = constantValue(value, fn); + return Boolean( + constant && depth < 16 && isStaticFallbackValue(constant, fn, depth + 1), + ); + } + if (value.type === 'TemplateLiteral') { + return value.expressions.length === 0; + } + if (value.type === 'UnaryExpression' && value.operator === 'void') { + return true; + } + return value.type.endsWith('Literal'); +} + +/** Evaluate the small set of call-site values used by conditional guards. */ +function staticPrimitiveValue(node, parameters, argumentsList, depth = 0) { + if (!node || depth > 16) { + return undefined; + } + if (node.type === 'Identifier') { + const parameterIndex = parameters.findIndex( + (parameter) => parameter && bindingOf(node) === parameter, + ); + if (parameterIndex >= 0) { + return staticPrimitiveValue( + argumentsList[parameterIndex], + parameters, + argumentsList, + depth + 1, + ); + } + return bindingOf(node)?.global === 'undefined' + ? { known: true, value: undefined } + : undefined; + } + if ( + node.type === 'StringLiteral' || + node.type === 'NumericLiteral' || + node.type === 'BooleanLiteral' || + node.type === 'NullLiteral' || + node.type === 'BigIntLiteral' + ) { + return { known: true, value: node.value }; + } + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { + return { + known: true, + value: node.quasis.map((quasi) => quasi.value.cooked ?? '').join(''), + }; + } + if ( + node.type === 'ParenthesizedExpression' || + node.type === 'TSAsExpression' || + node.type === 'TSSatisfiesExpression' || + node.type === 'TSNonNullExpression' + ) { + return staticPrimitiveValue( + node.expression, + parameters, + argumentsList, + depth + 1, + ); + } + if (node.type === 'UnaryExpression') { + if (node.operator === 'void') { + return { known: true, value: undefined }; + } + const argument = staticPrimitiveValue( + node.argument, + parameters, + argumentsList, + depth + 1, + ); + if (!argument?.known) { + return undefined; + } + if (node.operator === '!') { + return { known: true, value: !argument.value }; + } + if (node.operator === '+') { + return { known: true, value: +argument.value }; + } + if (node.operator === '-') { + return { known: true, value: -argument.value }; + } + return undefined; + } + if (node.type === 'BinaryExpression') { + const left = staticPrimitiveValue( + node.left, + parameters, + argumentsList, + depth + 1, + ); + const right = staticPrimitiveValue( + node.right, + parameters, + argumentsList, + depth + 1, + ); + if (!left?.known || !right?.known) { + return undefined; + } + switch (node.operator) { + case '===': + return { known: true, value: left.value === right.value }; + case '!==': + return { known: true, value: left.value !== right.value }; + default: + return undefined; + } + } + if (node.type === 'LogicalExpression') { + const left = staticPrimitiveValue( + node.left, + parameters, + argumentsList, + depth + 1, + ); + if (!left?.known) { + return undefined; + } + if (node.operator === '&&' && !left.value) { + return { known: true, value: left.value }; + } + if (node.operator === '||' && left.value) { + return { known: true, value: left.value }; + } + return staticPrimitiveValue( + node.right, + parameters, + argumentsList, + depth + 1, + ); + } + return undefined; +} + +function staticBooleanValue(node, parameters, argumentsList) { + const value = staticPrimitiveValue(node, parameters, argumentsList); + return value?.known ? Boolean(value.value) : undefined; +} + +function staticGuardValue(guard, parameters, argumentsList) { + if (guard.kind === 'switch') { + const actual = staticPrimitiveValue( + guard.discriminant, + parameters, + argumentsList, + ); + if (!actual?.known) { + return undefined; + } + const matchesCase = (caseTest) => { + const expected = staticPrimitiveValue( + caseTest, + parameters, + argumentsList, + ); + return expected?.known === true + ? actual.value === expected.value + : undefined; + }; + const defaultMatches = () => { + let unknown = false; + for (const test of guard.tests) { + const result = matchesCase(test); + if (result === true) { + return false; + } + unknown ||= result === undefined; + } + return unknown ? undefined : true; + }; + let unknown = false; + for (const entry of guard.caseTests ?? [guard.caseTest]) { + const caseTest = + entry && typeof entry === 'object' && 'caseTest' in entry + ? entry.caseTest + : entry; + if ( + entry && + typeof entry === 'object' && + 'conditional' in entry && + entry.conditional + ) { + unknown = true; + } else { + const result = + caseTest === null ? defaultMatches() : matchesCase(caseTest); + if (result === true) { + return true; + } + unknown ||= result === undefined; + } + } + return unknown ? undefined : false; + } + const condition = staticBooleanValue(guard.test, parameters, argumentsList); + return condition === undefined ? undefined : condition === guard.taken; +} + +/** + * The read a named function with a single return hands back, and its path + * with the constants the function declares for itself written in place. A + * helper that builds its path or its text in locals first, + * `const file = join(__dirname, name); return readFileSync(file)`, is then as + * plain a read as the one-line form. + */ +function returnedRead(node, isReadCallee) { + const named = namedFunction(node); + if (!named) { + return undefined; + } + const { fn } = named; + const values = readHelperReturns(fn).map((entry, index, all) => { + // In `if (name) return read(name); return ''`, the trailing return is + // the false branch even though the AST does not put it under the IfStatement. + // Carry the inverse guard so the caller can select the read branch safely. + if (!isStaticFallbackValue(entry.value, fn) && entry.guards.length === 0) { + return entry; + } + const previous = all[index - 1]; + const lastGuard = previous?.guards.at(-1); + if ( + !previous || + isStaticFallbackValue(previous.value, fn) || + entry.guards.length > 0 || + lastGuard?.kind !== 'if' || + lastGuard.taken !== true + ) { + return entry; + } + return { + ...entry, + guards: [...previous.guards.slice(0, -1), { ...lastGuard, taken: false }], + }; + }); + // Every static return is a fallback. Keep one guard chain per return so a + // nested fallback is selected only when all of its conditions are true. + // Catch and availability fallbacks are intentionally treated as reads + // because the filesystem decides whether they run. + const fallbackGuardChains = []; + const readCandidates = []; + values.forEach(({ value, guards }) => { + if (isStaticFallbackValue(value, fn)) { + const chain = guards.filter((guard) => !guard.availability); + if (chain.length > 0) { + fallbackGuardChains.push(chain); + } + return; + } + readCandidates.push({ value, guards }); + }); + if (readCandidates.length !== 1) { + return undefined; + } + // Peel conversions and constants off the returned value until a read shows. + let value = readCandidates[0].value; + for (let step = 0; step < 16; step += 1) { + value = unwrapWholeConversion(value); + const constant = constantValue(value, fn); + if (!constant) { + break; + } + value = constant; + } + const read = + (value?.type === 'CallExpression' || + value?.type === 'OptionalCallExpression') && + isReadCallee(value.callee) && + value.arguments[0]; + return read + ? { + ...named, + fallbackGuardChains, + readGuards: readCandidates[0].guards, + pathArgument: inlineConstants(read, fn), + } + : undefined; +} + +/** The initializer of a constant declared inside `fn` that `node` names. */ +function constantValue(node, fn) { + const binding = bindingOf(node); + const declarator = binding?.path?.node; + return binding?.constant && + declarator?.type === 'VariableDeclarator' && + declarator.id.type === 'Identifier' && + declarator.init && + declarator.start >= fn.start && + declarator.end <= fn.end + ? declarator.init + : undefined; +} + +/** + * `node` with every identifier that names a constant declared inside `fn` + * replaced by that constant's initializer, all the way down. Untouched + * subtrees are the original nodes, so their bindings still resolve. + */ +function inlineConstants(node, fn, depth = 0) { + if (!node || typeof node.type !== 'string' || depth > 16) { + return node; + } + if (node.type === 'Identifier') { + const constant = constantValue(node, fn); + return constant ? inlineConstants(constant, fn, depth + 1) : node; + } + let copy; + for (const key of childKeys(node)) { + const value = node[key]; + const inlined = Array.isArray(value) + ? value.map((child) => inlineConstants(child, fn, depth)) + : inlineConstants(value, fn, depth); + const changed = Array.isArray(value) + ? inlined.some((child, index) => child !== value[index]) + : inlined !== value; + if (changed) { + copy = copy ?? { ...node }; + copy[key] = inlined; + } + } + return copy ?? node; +} + +/** + * The expression under conversions that keep a whole file whole, so + * `readFileSync(file).toString()` and `await readFile(file)` are still reads. + */ +function unwrapWholeConversion(node) { + switch (node?.type) { + case 'AwaitExpression': + return unwrapWholeConversion(node.argument); + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + return unwrapWholeConversion(node.expression); + case 'CallExpression': + case 'OptionalCallExpression': { + const name = calleeName(node.callee); + if ( + name && + WHOLE_PRESERVING_METHODS.has(name) && + (node.callee.type === 'MemberExpression' || + node.callee.type === 'OptionalMemberExpression') + ) { + return unwrapWholeConversion(node.callee.object); + } + if (node.callee.type === 'Identifier' && name === 'String') { + return unwrapWholeConversion(node.arguments[0]); + } + return node; + } + default: + return node; + } +} + +/** + * A named function whose body is a single returned expression, so a call to it + * can be treated the same as the expression it returns. + */ +function returnedPathExpression(node) { + const named = namedFunction(node); + if (!named) { + return undefined; + } + const { binding, fn, parameters } = named; + if (fn.body.type !== 'BlockStatement') { + return { binding, value: fn.body, parameters }; + } + const [statement, ...rest] = fn.body.body; + return statement?.type === 'ReturnStatement' && + rest.length === 0 && + statement.argument + ? { binding, value: statement.argument, parameters } + : undefined; +} + +/** Literals in a path expression, including those behind anchored bindings. */ +function pathLiterals(node, anchored) { + const literals = collectStringLiterals(node).filter(Boolean); + const seen = new Set(); + walk(node, (current) => { + const binding = bindingOf(current); + if (anchored.has(binding) && !seen.has(binding)) { + seen.add(binding); + literals.push(...anchored.get(binding).literals); + } + }); + return literals; +} + +/** Does the path finish with a value only known at run time? */ +function endsInVariableName(node, anchored) { + if (node.type === 'StringLiteral' || node.type === 'TemplateLiteral') { + return false; + } + if (node.type === 'Identifier') { + // A binding built from a literal filename already said what it is; only an + // unknown name is genuinely a tail that is only known at run time. + const described = anchored.get(bindingOf(node)); + return described ? described.endsInVariable : true; + } + if ( + node.type === 'CallExpression' || + node.type === 'OptionalCallExpression' + ) { + const last = node.arguments.at(-1); + return Boolean(last) && endsInVariableName(last, anchored); + } + return true; +} + +/** Does this path reach source whose extension is not written down? */ +function namesUnextendedSource(node, literals, anchored) { + // `const file = path.join(__dirname, name)` already said what it names. + const described = anchored.get(bindingOf(node)); + if (described) { + return described.unextendedSource; + } + // `path.join(repoRoot, 'packages/kit/src/views/X', name)` names a source + // directory explicitly and ends in a variable, so the filename is source. + // Only that shape: a path that ends in a literal already said what it is, + // and a repo path naming no source directory (`.github/workflows`) is not. + if ( + endsInVariableName(node, anchored) && + literals.some((literal) => SOURCE_DIRECTORY_RE.test(literal)) + ) { + return true; + } + let found = false; + walk(node, (current) => { + if ( + current.type === 'Identifier' && + REPO_ANCHOR_IDENTIFIERS.has(current.name) + ) { + // `path.resolve(__dirname, fileFromTestTable)` reads a sibling of the + // test file, which is source whatever the table happens to hold. + found = true; + } + if ( + (current.type === 'CallExpression' || + current.type === 'OptionalCallExpression') && + current.callee.type === 'MemberExpression' && + current.callee.object.type === 'Identifier' && + current.callee.object.name === 'require' && + calleeName(current.callee) === 'resolve' + ) { + // A module specifier resolves to JS/TS by definition. + found = true; + } + }); + return found; +} + +/** Every identifier a pattern binds, so destructuring carries taint too. */ +function patternIdentifiers(node, collected = []) { + if (!node) { + return collected; + } + switch (node.type) { + case 'Identifier': + collected.push(node); + break; + case 'ArrayPattern': + node.elements.forEach((element) => + patternIdentifiers(element, collected), + ); + break; + case 'ObjectPattern': + node.properties.forEach((property) => + patternIdentifiers( + property.type === 'RestElement' ? property.argument : property.value, + collected, + ), + ); + break; + case 'RestElement': + patternIdentifiers(node.argument, collected); + break; + case 'AssignmentPattern': + patternIdentifiers(node.left, collected); + break; + default: + break; + } + return collected; +} + +/** + * The identifiers a definition fills and the value it fills them with, for + * `const x = ...`, `x = ...` and `x += ...`. + */ +function bindingTarget(node) { + if (node.type === 'VariableDeclarator' && node.init) { + return { + pattern: node.id, + identifiers: patternIdentifiers(node.id), + value: node.init, + }; + } + if (node.type === 'AssignmentExpression') { + return { + pattern: node.left, + identifiers: patternIdentifiers(node.left), + value: node.right, + }; + } + return undefined; +} + +function analyzeFile( + absolutePath, + source, + allowlist = [], + usedEntries = new Map(), +) { + const relativePath = path + .relative(REPO_ROOT, absolutePath) + .split(path.sep) + .join('/'); + const ast = parseSource(source); + resolveBindings(ast); + + // `readdirSync` + a source-extension filter + `readFileSync` is a directory + // walk over first-party source; the read path is then a variable, so the + // per-call classifier alone cannot see it. + const walksSourceTree = + /\breaddirSync\b|\breaddir\b/u.test(source) && + /\breadFileSync\b|\breadFile\b/u.test(source) && + walksSource(ast); + + const { anchored: repoAnchored, helpers: anchoredHelpers } = + collectRepoAnchoredBindings(ast); + const classify = (pathNode, callSite) => + classifyPath(pathNode, repoAnchored, anchoredHelpers, callSite) ?? + (walksSourceTree ? 'script' : undefined); + const readAliases = collectReadAliases(ast); + const isReadCallee = (callee) => isReadReference(callee, readAliases); + const readHelpers = collectReadHelpers(ast, isReadCallee, classify); + const transformHelpers = new Map(); + // How a call relates to source text. A read is a direct call, a call through + // an alias of one, or a call to a helper that does nothing but read. A + // transform helper is not a read, but it hands an argument's text back, so + // the taint travels through it. + const calls = { + isRead: (callNode) => isReadCallee(callNode.callee), + isReadHelper: (binding) => readHelpers.has(binding), + readKind(callNode) { + if (isReadCallee(callNode.callee)) { + return classify(callNode.arguments[0]); + } + const helper = readHelpers.get(bindingOf(callNode.callee)); + if (!helper) { + return undefined; + } + const readSelected = !helper.readGuards?.some( + (guard) => + staticGuardValue(guard, helper.parameters, callNode.arguments) === + false, + ); + const fallbackSelected = helper.fallbackGuardChains?.some((chain) => + chain.every( + (guard) => + staticGuardValue(guard, helper.parameters, callNode.arguments) === + true, + ), + ); + if (!readSelected || fallbackSelected) { + return undefined; + } + if (helper.kind) { + return helper.kind; + } + const passed = callNode.arguments[helper.parameterIndex]; + if (!helper.path) { + return classify(passed); + } + // Classify the helper's own path with what the caller actually named. + return classify(helper.path, { + literals: passed ? pathLiterals(passed, repoAnchored) : [], + endsInVariable: passed + ? endsInVariableName(passed, repoAnchored) + : true, + headIsParameter: helper.headIsParameter, + anchored: Boolean( + passed && isRepoAnchored(passed, repoAnchored, anchoredHelpers), + ), + }); + }, + transform(callNode) { + const helper = transformHelpers.get(bindingOf(callNode.callee)); + return helper + ? { + argument: callNode.arguments[helper.parameterIndex], + preservesWhole: helper.preservesWhole, + } + : undefined; + }, + }; + collectTransformHelpers(ast, calls, transformHelpers); + + // Pass 1: taint every binding that holds first-party source text. Seeding + // walks down from each definition rather than up from each read, so the read + // can sit anywhere inside it -- behind an await, a cast, an optional chain, + // or a string method -- and derived bindings (`const body = + // source.slice(a, b)`) follow. + const definitions = collectDefinitions(ast, calls); + const tainted = new Map(); + const fragments = new Set(); + propagateTaint(definitions, tainted, fragments, calls); + const assertionHelpers = collectAssertionHelpers( + ast, + definitions, + tainted, + calls, + ); + + // Pass 2: locate assertions and eval sinks fed by tainted text, and record + // which `it()` block each one sits in so partial files can be fixed in place. + const violations = []; + const testBlocks = []; + const blockStack = []; + + let sharedSetupViolation = false; + const record = (rule, node, message) => { + const violation = { + rule, + file: relativePath, + line: node.loc?.start.line ?? 0, + block: blockStack.length + ? blockStack[blockStack.length - 1].title + : undefined, + message, + }; + // Exemptions are applied here, not by the caller, so a reviewed block never + // counts toward the whole-file verdict that --list drives. + const entry = matchingEntry(allowlist, relativePath, violation); + if (entry) { + const seen = (usedEntries.get(entry) ?? 0) + 1; + usedEntries.set(entry, seen); + // Beyond the reviewed count the block has grown unchecked assertions. + if (seen <= entry.count) { + return; + } + } + violations.push(violation); + if (ADVISORY_RULES.has(rule)) { + // An advisory hit is never a reason to delete anything, so it must not + // feed the whole-file verdict that --list drives. + return; + } + if (blockStack.length) { + blockStack[blockStack.length - 1].violated = true; + } else { + // Outside any test block: shared setup every test in the file depends on. + sharedSetupViolation = true; + } + }; + + const recordEvalSink = (node, name) => { + if (!name || !EVAL_FUNCTIONS.has(name)) { + return; + } + const kind = firstTaint(node.arguments, tainted, calls); + // A fragment is anything that is not the file as it was read. + const sliced = node.arguments.some( + (a) => + taintKind(a, tainted, calls) && + !isWholeFileRead(a, tainted, fragments, calls), + ); + if (kind === 'script' && sliced) { + record( + 'source-slice-eval', + node, + `${name}() evaluates a fragment sliced out of a source file`, + ); + } + }; + + const visit = (node) => { + if (node.type === 'NewExpression') { + recordEvalSink(node, calleeName(node.callee)); + } + if ( + node.type === 'CallExpression' || + node.type === 'OptionalCallExpression' + ) { + const name = calleeName(node.callee); + + if (isTestBlock(node)) { + const titleNode = node.arguments[0]; + const title = + titleNode?.type === 'StringLiteral' + ? titleNode.value + : collectStringLiterals(titleNode ?? {}).join(' ') || + '(dynamic title)'; + const block = { + title, + line: node.loc?.start.line ?? 0, + violated: false, + }; + testBlocks.push(block); + blockStack.push(block); + for (const key of childKeys(node)) { + const value = node[key]; + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child.type === 'string') { + visitTree(child); + } + } + } else if (value && typeof value.type === 'string') { + visitTree(value); + } + } + blockStack.pop(); + return false; + } + + // expect().toContain(...), assert.match(, /.../), and + // expectNoTimers(), where the sink is inside the helper but the + // claim is about what this call handed it. + const asserted = assertedSource(node, tainted, calls, assertionHelpers); + if (asserted) { + record( + asserted.kind === 'native' + ? 'native-source-text-assertion' + : 'source-text-assertion', + node, + `${asserted.label} asserts on the text of a ${asserted.kind} source file`, + ); + } + + // vm.runInNewContext() / transformSync() + recordEvalSink(node, name); + } + return true; + }; + + function visitTree(node) { + if (!node || typeof node.type !== 'string') { + return; + } + if (visit(node) === false) { + return; + } + for (const key of childKeys(node)) { + const value = node[key]; + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child.type === 'string') { + visitTree(child); + } + } + } else if (value && typeof value.type === 'string') { + visitTree(value); + } + } + } + + visitTree(ast); + + if (!collectsFirstPartyModule(ast)) { + violations.push({ + rule: 'missing-subject-import', + file: relativePath, + line: 1, + message: + 'test loads no first-party module, so it cannot execute the code it claims to cover', + }); + } + + const blockingViolations = violations.filter( + (violation) => !ADVISORY_RULES.has(violation.rule), + ); + const violatedBlocks = testBlocks.filter((block) => block.violated).length; + const wholeFile = + blockingViolations.length > 0 && + (sharedSetupViolation || + (testBlocks.length > 0 && violatedBlocks === testBlocks.length)); + + return { file: relativePath, violations, testBlocks, wholeFile }; +} + +/** Does the AST contain a regex that selects first-party source files? */ +function walksSource(ast) { + let found = false; + walk(ast, (node) => { + if (node.type === 'RegExpLiteral' && SOURCE_FILTER_RE.test(node.pattern)) { + found = true; + } + }); + return found; +} + +/** + * `it(...)`, `it.only(...)`, `it.only.each(table)(...)` and the tagged-template + * form. Anchored on the root *object* so `/re/.test(x)` is never mistaken for + * a test block, and `it.each(table)` on its own is not one: it only builds the + * block that the call it returns declares. + */ +function isTestBlock(node) { + const callee = node.callee; + if (callee?.type === 'Identifier') { + return TEST_BLOCK_NAMES.has(callee.name); + } + let member; + if (callee?.type === 'MemberExpression') { + if (calleeName(callee) === 'each') { + return false; + } + member = callee; + } else if (callee?.type === 'CallExpression') { + member = callee.callee; + } else if (callee?.type === 'TaggedTemplateExpression') { + member = callee.tag; + } + let root = member?.type === 'MemberExpression' ? member.object : undefined; + while (root?.type === 'MemberExpression') { + root = root.object; + } + return root?.type === 'Identifier' && TEST_BLOCK_NAMES.has(root.name); +} + +/** `expect`, `x.expect`, or Jest's or Vitest's `expect` imported under another name. */ +function isExpectCallee(callee) { + if (calleeName(callee) === 'expect') { + return true; + } + const imported = callee.type === 'Identifier' ? importOf(callee) : undefined; + return Boolean( + imported && + EXPECT_MODULE_RE.test(imported.module) && + (imported.name === 'expect' || imported.name === 'default'), + ); +} + +function findExpectCall(node) { + let current = node; + while (current) { + if ( + current.type === 'MemberExpression' || + current.type === 'OptionalMemberExpression' + ) { + current = current.object; + } else if ( + current.type === 'CallExpression' || + current.type === 'OptionalCallExpression' + ) { + if (isExpectCallee(current.callee)) { + return current; + } + current = current.callee; + } else { + return undefined; + } + } + return undefined; +} + +/** Does the test load anything first-party — statically, dynamically or via jest? */ +function collectsFirstPartyModule(ast) { + let found = false; + walk(ast, (node) => { + if (found) { + return; + } + let specifier; + if (node.type === 'ImportDeclaration') { + specifier = node.source.value; + } else if ( + (node.type === 'CallExpression' || + node.type === 'OptionalCallExpression') && + MODULE_LOADERS.has(calleeName(node.callee) ?? '') && + node.arguments[0]?.type === 'StringLiteral' + ) { + specifier = node.arguments[0].value; + } else if (node.type === 'Import') { + return; + } + if (!specifier) { + return; + } + if (NODE_BUILTIN_RE.test(specifier) || TEST_TOOLING_RE.test(specifier)) { + return; + } + if (FIRST_PARTY_MODULE_RE.test(specifier)) { + found = true; + } + }); + return found; +} + +/** + * An entry exempts one reviewed violation, identified by the test block it sits + * in, so a later assertion added to the same file is still gated. `block: null` + * means the violation is in shared setup outside any test block. + */ +function matchingEntry(allowlist, file, violation) { + return allowlist.find( + (entry) => + entry.file === file && + entry.rule === violation.rule && + (entry.block ?? null) === (violation.block ?? null), + ); +} + +function analyzeOne(absolutePath, allowlist, usedEntries) { + const source = fs.readFileSync(absolutePath, 'utf8'); + if (!SCANNABLE_SOURCE_RE.test(source)) { + return undefined; + } + const relativePath = path + .relative(REPO_ROOT, absolutePath) + .split(path.sep) + .join('/'); + let result; + try { + result = analyzeFile(absolutePath, source, allowlist, usedEntries); + } catch (error) { + // Unparseable input is a fact about the file; anything else is a defect in + // this check, and swallowing it would drop the whole file from the gate + // without saying so. + if (!(error instanceof SyntaxError)) { + throw error; + } + return { + file: relativePath, + parseError: error.message, + violations: [], + testBlocks: [], + wholeFile: false, + }; + } + return result.violations.length ? result : undefined; +} + +function run() { + const allowlist = loadAllowlist(); + const usedEntries = new Map(); + const files = collectTestFiles(REPO_ROOT, []); + const results = files + .map((absolutePath) => analyzeOne(absolutePath, allowlist, usedEntries)) + .filter(Boolean); + const failing = results.filter((result) => + result.violations.some((violation) => !ADVISORY_RULES.has(violation.rule)), + ); + const advisory = results.filter((result) => !failing.includes(result)); + // An exemption must describe the code that is there now: one that matches + // nothing has to go, and one that matches less than it claims is wider than + // anybody reviewed. + const staleEntries = allowlist + .map((entry) => ({ ...entry, seen: usedEntries.get(entry) ?? 0 })) + .filter((entry) => entry.seen < entry.count); + return { scanned: files.length, results: failing, advisory, staleEntries }; +} + +function main() { + const flags = new Set(process.argv.slice(2)); + const { scanned, results, advisory, staleEntries } = run(); + + if (flags.has('--json')) { + process.stdout.write( + `${JSON.stringify({ scanned, results, advisory, staleEntries }, null, 2)}\n`, + ); + process.exitCode = results.length || staleEntries.length ? 1 : 0; + return; + } + + if (flags.has('--list')) { + for (const result of results.filter((entry) => entry.wholeFile)) { + process.stdout.write(`${result.file}\n`); + } + return; + } + + if (!results.length && !staleEntries.length) { + // Advisories are the whole reason the advisory rules exist, so say how many + // there are on a passing run too rather than only when something fails. + const advisoryNote = advisory.length + ? ` ${advisory.length} advisory, not gated: see --json.` + : ''; + process.stdout.write( + `Test integrity check passed (${scanned} test files).${advisoryNote}\n`, + ); + return; + } + + const lines = results.length + ? [ + `Test integrity check failed: ${results.length} of ${scanned} test file(s) assert on source text.`, + '', + ] + : ['Test integrity check failed.', '']; + for (const entry of staleEntries) { + lines.push( + `${entry.file} [stale allowlist entry]`, + ` ${entry.rule} in ${ + entry.block === null ? 'shared setup' : `"${entry.block}"` + }: reviewed ${entry.count}, found ${entry.seen}. ${ + entry.seen === 0 ? 'Remove the entry.' : 'Lower its "count".' + }`, + '', + ); + } + for (const result of results) { + lines.push( + `${result.file}${result.wholeFile ? ' [whole file]' : ' [partial]'}`, + ); + for (const violation of result.violations.filter( + (v) => !ADVISORY_RULES.has(v.rule), + )) { + lines.push( + ` ${result.file}:${violation.line} ${violation.rule}`, + ` ${violation.message}${violation.block ? ` (in "${violation.block}")` : ''}`, + ); + } + lines.push(''); + } + const advisoryCount = + advisory.length + + results.filter((result) => + result.violations.some((v) => ADVISORY_RULES.has(v.rule)), + ).length; + if (advisoryCount) { + lines.push( + `Advisory (not gated): ${advisoryCount} test file(s) load no first-party module,`, + 'or assert on native source Jest cannot execute. Run with --json to list them.', + '', + ); + } + lines.push( + 'A test must execute the code under test. If the unit is unreachable, export it', + 'from a sibling module and test it for real -- or write no test and say so.', + 'Genuine exceptions go in development/lint/test-integrity.allowlist.json with a reason.', + '', + ); + process.stderr.write(lines.join('\n')); + process.exitCode = 1; +} + +module.exports = { analyzeFile, collectTestFiles, run }; + +if (require.main === module) { + main(); +} diff --git a/development/lint/test-integrity.node-test.js b/development/lint/test-integrity.node-test.js new file mode 100644 index 000000000..2855a2565 --- /dev/null +++ b/development/lint/test-integrity.node-test.js @@ -0,0 +1,2734 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { analyzeFile, collectTestFiles } = require('./test-integrity'); + +// analyzeFile only parses the text it is handed, but it resolves `__dirname` +// relative reads by shape rather than by opening them, so any path inside the +// repository works as the notional location of the fixture. +const FIXTURE_PATH = path.join(__dirname, 'fixture.test.ts'); + +function gatedRules(source) { + return analyzeFile(FIXTURE_PATH, source) + .violations.filter( + (violation) => + violation.rule === 'source-text-assertion' || + violation.rule === 'source-slice-eval', + ) + .map((violation) => violation.rule); +} + +function assertGated(source, message) { + assert.ok(gatedRules(source).length > 0, message); +} + +function assertClean(source, message) { + assert.deepEqual(gatedRules(source), [], message); +} + +test('catches a direct source-text assertion', () => { + assertGated(` + const source = readFileSync(join(__dirname, 'Thing.tsx'), 'utf8'); + it('x', () => { + expect(source).toContain('testID="thing"'); + }); + `); +}); + +test('catches offset ordering stored in bindings', () => { + // The three-statement form of expect(s.indexOf(a)).toBeLessThan(s.indexOf(b)). + assertGated(` + const source = readFileSync(join(__dirname, 'thing.js'), 'utf8'); + const body = source.slice(source.indexOf('function go(')); + it('x', () => { + const first = body.indexOf('a('); + const second = body.indexOf('b('); + expect(second).toBeGreaterThan(first); + }); + `); +}); + +test('catches a read that is not the immediate initializer', () => { + assertGated( + ` + it('x', async () => { + const source = await readFile(join(__dirname, 'thing.ts'), 'utf8'); + expect(source).toMatch(/go/u); + }); + `, + 'await', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts')).toString(); + it('x', () => { + expect(source).toContain('go'); + }); + `, + 'toString', + ); + assertGated( + ` + it('x', () => { + expect(readFileSync(join(__dirname, 'thing.ts'), 'utf8')).toContain('go'); + }); + `, + 'inline, no binding', + ); +}); + +test('catches taint through an optional chain', () => { + assertGated(` + const source = readFileSync(resolve(__dirname, '../thing.ts'), 'utf-8'); + const method = source.match(/async go\\(\\) \\{[\\s\\S]*?\\n\\}/u)?.[0]; + it('x', () => { + expect(method).not.toMatch(/setTimeout/u); + }); + `); +}); + +test('catches a fragment sliced out of source and evaluated', () => { + assertGated(` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const fragment = source.slice(source.indexOf('const go =')); + const module = {}; + runInNewContext(transformSync(fragment).code, { module }); + it('x', () => { + expect(module.exports.go()).toBe(1); + }); + `); +}); + +test('catches node:assert sinks, not lookalike methods', () => { + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + test('x', () => { assert.equal(source, 'go'); }); + `, + 'assert.equal', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + test('x', () => { assert.ok(source.includes('go')); }); + `, + 'assert.ok', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + test('x', () => { assert.strict.deepEqual(source.split('\\n'), []); }); + `, + 'assert.strict.deepEqual', + ); + assertClean( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + test('x', () => { chai.expect(source).to.equal('go'); }); + `, + 'a .equal that is not node:assert', + ); +}); + +test('an assertion counts however the assertion function is reached', () => { + const tainted = `const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8');`; + const inTest = (preamble, call) => + `${preamble}\n${tainted}\ntest('x', (t) => { ${call}; });`; + for (const [shape, preamble, call] of [ + [ + 'assert()', + "const assert = require('node:assert');", + "assert(source.includes('go'))", + ], + [ + 'assert.strict()', + "import assert from 'assert';", + "assert.strict(source.includes('go'))", + ], + ['assert() without an import', '', "assert(source.includes('go'))"], + ['a test context', '', "t.assert.ok(source.includes('go'))"], + [ + 'a named import', + "import { match } from 'node:assert/strict';", + 'match(source, /go/u)', + ], + [ + 'a renamed import', + "import { ok as check } from 'node:assert';", + "check(source.includes('go'))", + ], + [ + 'a destructured require', + "const { equal } = require('node:assert');", + "equal(source, 'go')", + ], + [ + 'a renamed expect', + "import { expect as check } from '@jest/globals';", + "check(source).toContain('go')", + ], + ]) { + assertGated(inTest(preamble, call), shape); + } + // Controls: the same call on plain text, and a function that only shares a + // name with an assertion. + assertClean( + inTest( + "const assert = require('node:assert');", + "assert('plain'.includes('go'))", + ), + 'control: assert() on plain text', + ); + assertClean( + inTest("import { match } from './matchers';", 'match(source, /go/u)'), + 'control: a match() from elsewhere', + ); +}); + +test('catches a variable filename inside a named source directory', () => { + assertGated( + ` + const repoRoot = path.resolve(__dirname, '../../../..'); + const source = readFileSync(path.join(repoRoot, 'packages/kit/src/views', name), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'named source directory', + ); + assertClean( + ` + const repoRoot = path.resolve(__dirname, '../../../..'); + const workflow = readFileSync(path.join(repoRoot, '.github/workflows', name), 'utf8'); + it('x', () => { expect(workflow).toContain('go'); }); + `, + 'a directory that holds no source', + ); +}); + +test('separates evaluating a whole file from evaluating a fragment', () => { + // A .ts read so the target really is classified as source: the point under + // test is whole-versus-fragment, not the classification. + assertClean( + ` + it('x', () => { + const go = runInNewContext( + \`(\${readFileSync(join(__dirname, 'thing.ts'), 'utf8')})\`, + ); + expect(go(1)).toBe('one'); + }); + `, + 'whole file', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const fragment = source.slice(source.indexOf('const go =')); + runInNewContext(fragment, {}); + it('x', () => { expect(1).toBe(1); }); + `, + 'sliced fragment', + ); + // Cutting with two regex replaces instead of slice is the same thing. + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const fragment = source + .replace(/.*?(?=const go =)/su, '') + .replace(/const done.*$/su, ''); + runInNewContext(transformSync(fragment).code, {}); + it('x', () => { expect(1).toBe(1); }); + `, + 'regex-replace fragment', + ); +}); + +test('string concatenation carries source text like a template does', () => { + assertGated(` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { + expect('// ' + source).toContain('go'); + }); + `); +}); + +test('only a path ending in a variable falls back to the directory name', () => { + // The directory says "source" but the filename is not written down, so the + // directory is all there is to go on. + assertGated( + ` + ${REPO_ROOT_PREAMBLE} + const source = readFileSync(path.join(repoRoot, 'packages/kit/src', name), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'ends in a variable', + ); + // A path that ends in a literal already said what it is, whatever the + // directory is called. `.text-js` is a shipped artifact, not source. + assertClean( + ` + ${REPO_ROOT_PREAMBLE} + const source = readFileSync(path.join(repoRoot, 'packages/kit/src', 'thing.text-js'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'ends in a data literal', + ); + // Control: the same shape with a source extension is still gated, so the + // clean result above comes from the extension and not from the shape. + assertGated( + ` + ${REPO_ROOT_PREAMBLE} + const source = readFileSync(path.join(repoRoot, 'packages/kit/src', 'thing.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'control: ends in a source literal', + ); + // Read through a binding, so the path argument is a bare identifier and the + // shape rule cannot help: only the carried `.text-js` extension can. + assertClean( + ` + ${REPO_ROOT_PREAMBLE} + const file = path.join(repoRoot, 'packages/kit/src/thing.text-js'); + const source = readFileSync(file, 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'hyphenated extension carried through a binding', + ); +}); + +test('anchors through a helper that returns a repository path', () => { + // The shape apps/cli/src/output/__tests__/logger.test.ts already uses. + assertGated( + ` + function repoRoot() { + return path.resolve(__dirname, '../../../../..'); + } + const source = readFileSync(path.join(repoRoot(), 'apps/cli/src/x.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'function declaration', + ); + assertGated( + ` + const repoRoot = () => path.resolve(__dirname, '../..'); + const source = readFileSync(path.join(repoRoot(), 'apps/cli/src/x.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'arrow with an expression body', + ); + // A helper returning a temp directory anchors nothing, whatever it is called. + assertClean( + ` + function fixtureRoot() { + return fs.mkdtempSync(os.tmpdir()); + } + const source = readFileSync(path.join(fixtureRoot(), 'packages/kit/src/x.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'helper returning a temp directory', + ); +}); + +test('a whole read stays whole through an encoding conversion', () => { + assertClean( + ` + it('x', () => { + const go = runInNewContext(readFileSync(join(__dirname, 'thing.js')).toString()); + expect(go).toBeDefined(); + }); + `, + 'readFileSync(p).toString()', + ); + assertClean( + ` + it('x', () => { + const go = runInNewContext(String(readFileSync(join(__dirname, 'thing.js'))).trim()); + expect(go).toBeDefined(); + }); + `, + 'String(...).trim()', + ); + // Control: cutting it is still a fragment however it is spelled. + assertGated( + ` + it('x', () => { + const go = runInNewContext(readFileSync(join(__dirname, 'thing.js')).toString().slice(1)); + expect(go).toBeDefined(); + }); + `, + 'control: sliced after conversion', + ); +}); + +test('an extensionless literal filename is data whether or not it is hoisted', () => { + for (const [shape, read] of [ + [ + 'inline', + "readFileSync(path.join(repoRoot, 'apps/mobile/ios/Podfile'), 'utf8')", + ], + ['hoisted', "readFileSync(podfile, 'utf8')"], + ]) { + assertClean( + ` + ${REPO_ROOT_PREAMBLE} + const podfile = path.join(repoRoot, 'apps/mobile/ios/Podfile'); + const source = ${read}; + it('x', () => { expect(source).toContain('pod'); }); + `, + shape, + ); + } + // Control: a genuinely unknown filename in the same directory is source. + assertGated( + ` + ${REPO_ROOT_PREAMBLE} + const directory = path.join(repoRoot, 'apps/mobile/ios'); + const source = readFileSync(path.join(directory, name), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'control: variable filename', + ); +}); + +test('only a bare call to a path helper anchors', () => { + // A helper named after a path builder must not disable the head check. + assertClean( + ` + const resolve = (rel) => path.resolve(__dirname, rel); + const source = readFileSync(path.resolve(fs.mkdtempSync(os.tmpdir()), 'index.js'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'builder-named helper does not anchor a temp head', + ); + // A method call only shares a name with the helper; it is not the helper. + assertClean( + ` + function repoRoot() { + return path.resolve(__dirname, '..'); + } + const source = readFileSync(path.join(fixture.repoRoot(), 'packages/kit/src/x.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'member call sharing the helper name', + ); +}); + +test('catches direct evaluation, not only a vm or a transform', () => { + const setup = ` + const source = readFileSync(join(__dirname, 'thing.js'), 'utf8'); + const fragment = source.slice(source.indexOf('const go =')); + `; + assertGated( + `${setup} + const go = eval(fragment); + it('x', () => { expect(go).toBeDefined(); }); + `, + 'eval', + ); + assertGated( + `${setup} + const go = new Function('return ' + fragment)(); + it('x', () => { expect(go).toBeDefined(); }); + `, + 'new Function', + ); + assertGated( + `${setup} + const go = Function(fragment)(); + it('x', () => { expect(go).toBeDefined(); }); + `, + 'Function without new', + ); + // Whole-file evaluation stays clean through these sinks too. + assertClean( + ` + it('x', () => { + const go = eval(readFileSync(join(__dirname, 'thing.js'), 'utf8')); + expect(go).toBeDefined(); + }); + `, + 'eval of a whole file', + ); +}); + +test('covers the rest of the vm surface and indirect eval', () => { + const setup = ` + const source = readFileSync(join(__dirname, 'thing.js'), 'utf8'); + const fragment = source.slice(source.indexOf('const go =')); + `; + assertGated( + `${setup} + const go = new vm.Script(fragment).runInNewContext({}); + it('x', () => { expect(go).toBeDefined(); }); + `, + 'new vm.Script', + ); + assertGated( + `${setup} + const go = vm.compileFunction(fragment, [], {}); + it('x', () => { expect(go).toBeDefined(); }); + `, + 'vm.compileFunction', + ); + assertGated( + `${setup} + const go = (0, eval)(fragment); + it('x', () => { expect(go).toBeDefined(); }); + `, + 'indirect eval', + ); + assertClean( + ` + it('x', () => { + const go = new vm.Script(readFileSync(join(__dirname, 'thing.js'), 'utf8')); + expect(go).toBeDefined(); + }); + `, + 'control: whole file through vm.Script', + ); +}); + +test('follows a read through an alias or a one-line wrapper', () => { + assertGated( + ` + const read = fs.readFileSync; + const source = read(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'alias binding', + ); + assertGated( + ` + const { readFileSync: slurp } = require('fs'); + const source = slurp(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'destructured rename', + ); + assertGated( + ` + import { readFileSync as slurp } from 'fs'; + const source = slurp(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'renamed import', + ); + assertGated( + ` + function readSource(file) { + return fs.readFileSync(file, 'utf8'); + } + const source = readSource(join(__dirname, 'thing.ts')); + it('x', () => { expect(source).toContain('go'); }); + `, + 'wrapper taking the path', + ); + assertGated( + ` + const readSource = () => fs.readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const source = readSource(); + it('x', () => { expect(source).toContain('go'); }); + `, + 'wrapper holding the path', + ); + // The wrapper is not what decides: a fixture path read through one is clean. + assertClean( + ` + function readFixture(file) { + return fs.readFileSync(file, 'utf8'); + } + it('x', () => { + const directory = fs.mkdtempSync(os.tmpdir()); + expect(readFixture(join(directory, 'index.js'))).toBe('done'); + }); + `, + 'wrapper reading a temp path', + ); +}); + +test('a wrapper that splices its parameter into a path defers to the call', () => { + // The fixture-reader shape: the helper owns the directory, the caller names + // the file, so the helper alone cannot say whether a call reads source. + const wrapper = ` + const readFixture = (name) => + readFileSync(join(__dirname, '__fixtures__', name), 'utf8'); + `; + assertClean( + `${wrapper} + it('x', () => { expect(readFixture('a.json')).toEqual({}); }); + `, + 'data extension at the call site', + ); + assertClean( + `${wrapper} + it('x', () => { expect(readFixture('Podfile')).toContain('pod'); }); + `, + 'extensionless literal at the call site', + ); + assertGated( + `${wrapper} + it('x', () => { expect(readFixture('a.ts')).toContain('go'); }); + `, + 'control: source extension at the call site', + ); + assertGated( + `${wrapper} + it('x', () => { expect(readFixture(name)).toContain('go'); }); + `, + 'control: the caller does not name the file either', + ); +}); + +test('a wrapper whose parameter is the path head defers anchoring too', () => { + const wrapper = ` + function readIn(root) { + return readFileSync(join(root, 'index.ts'), 'utf8'); + } + `; + assertGated( + `${wrapper} + const source = readIn(path.join(__dirname, '../src')); + it('x', () => { expect(source).toContain('go'); }); + `, + 'caller passes a repository path', + ); + assertClean( + `${wrapper} + it('x', () => { + const source = readIn(fs.mkdtempSync(os.tmpdir())); + expect(source).toBe('done'); + }); + `, + 'caller passes a temp directory', + ); + // An unrelated binding that happens to share the parameter name must not + // decide the verdict for every call. + assertClean( + ` + const root = 'apps/web/src/root.ts'; + ${wrapper} + it('x', () => { + const source = readIn(fs.mkdtempSync(os.tmpdir())); + expect(source).toBe('done'); + }); + `, + 'a same-named binding elsewhere in the file', + ); +}); + +test('a read inside an iteration callback still reaches the assertion', () => { + assertGated( + ` + const files = ['a.ts']; + it('x', () => { + expect( + files.filter((file) => readFileSync(join(__dirname, file), 'utf8').includes('go')), + ).toEqual([]); + }); + `, + 'filter', + ); + assertGated( + ` + const files = ['a.ts']; + it('x', () => { + expect( + files.flatMap((file) => readFileSync(join(__dirname, file), 'utf8').split(',')), + ).toHaveLength(2); + }); + `, + 'flatMap', + ); + // A callback that never reads source keeps the result untainted. + assertClean( + ` + const files = ['a.ts']; + it('x', () => { + expect(files.filter((file) => file.endsWith('.ts'))).toEqual(['a.ts']); + }); + `, + 'callback without a read', + ); +}); + +test('a callback taints only what it hands back', () => { + const tainted = `const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8');`; + // A property or key that merely shares a name with a tainted binding is not + // that binding. + assertClean( + `${tainted} + const items = []; + it('x', () => { expect(items.map((item) => item.source)).toEqual([]); }); + `, + 'member property named source', + ); + assertClean( + `${tainted} + const items = []; + it('x', () => { expect(items.map((item) => ({ source: item.id }))).toEqual([]); }); + `, + 'object key named source', + ); + // A read that does not decide the result does not taint it. + assertClean( + ` + const files = ['a']; + it('x', () => { + expect( + files.map((file) => { + const contents = readFileSync(join(__dirname, file), 'utf8'); + parse(contents); + return file; + }), + ).toEqual(['a']); + }); + `, + 'read for a side effect only', + ); + // Controls: a returned read still counts, from either body form. + assertGated( + ` + const files = ['a.ts']; + it('x', () => { + expect( + files.filter((file) => readFileSync(join(__dirname, file), 'utf8').includes('go')), + ).toEqual([]); + }); + `, + 'control: expression body', + ); + assertGated( + ` + const files = ['a.ts']; + it('x', () => { + expect( + files.map((file) => { + const contents = readFileSync(join(__dirname, file), 'utf8'); + return contents.includes('go'); + }), + ).toEqual([false]); + }); + `, + 'control: block body returning the read', + ); +}); + +test('a one-line transform helper carries the text it was given', () => { + assertGated( + ` + function normalize(text) { + return text.replace(/x/gu, ' '); + } + const files = ['a.ts']; + it('x', () => { + expect( + files.filter((file) => + normalize(readFileSync(join(__dirname, file), 'utf8')).includes('go'), + ), + ).toEqual([]); + }); + `, + 'read reworked by a helper', + ); + assertClean( + ` + function increment(value) { + return value + 1; + } + const files = ['a']; + it('x', () => { expect(files.map((file) => increment(file.length))).toEqual([2]); }); + `, + 'a helper that handles no source', + ); +}); + +test('chained one-line helpers resolve without crashing the file', () => { + // A crash here used to be caught and reported as an unparseable file, which + // dropped every assertion in it from the gate with no failure. + assertGated( + ` + const collapse = (text) => text.trim(); + const normalize = (text) => collapse(text).replace(/x/gu, ' '); + const files = ['a.ts']; + it('x', () => { + expect( + files.filter((file) => + normalize(readFileSync(join(__dirname, file), 'utf8')).includes('go'), + ), + ).toEqual([]); + }); + `, + 'helper calling a helper', + ); + // Declared the other way round, which a single pass in source order misses. + assertGated( + ` + const normalize = (text) => collapse(text).replace(/x/gu, ' '); + const collapse = (text) => text.trim(); + const files = ['a.ts']; + it('x', () => { + expect( + files.filter((file) => + normalize(readFileSync(join(__dirname, file), 'utf8')).includes('go'), + ), + ).toEqual([]); + }); + `, + 'helper calling a helper declared after it', + ); + // A local declared inside the callback reaches the transform helper too. + assertGated( + ` + function normalize(text) { + return text.replace(/x/gu, ' '); + } + const files = ['a.ts']; + it('x', () => { + expect( + files.map((file) => { + const contents = readFileSync(join(__dirname, file), 'utf8'); + return normalize(contents); + }), + ).toEqual([]); + }); + `, + 'callback local through a transform', + ); +}); + +test('text a read hands to a callback or a promise is still source', () => { + const thing = "join(__dirname, 'thing.ts')"; + assertGated( + ` + it('x', (done) => { + readFile(${thing}, 'utf8', (error, text) => { + expect(text).toContain('go'); + done(); + }); + }); + `, + 'node-style callback', + ); + assertGated( + ` + it('x', () => + fs.promises.readFile(${thing}, 'utf8').then((text) => { + expect(text).toContain('go'); + })); + `, + 'then', + ); + assertGated( + ` + it('x', async () => { + const text = await new Promise((resolve, reject) => { + fs.readFile(${thing}, 'utf8', (error, data) => + error ? reject(error) : resolve(data), + ); + }); + expect(text).toContain('go'); + }); + `, + 'wrapped in a promise', + ); + assertGated( + ` + it('x', async () => { + const text = await new Promise((resolve) => { + fs.promises.readFile(${thing}, 'utf8').then(resolve); + }); + expect(text).toContain('go'); + }); + `, + 'resolve handed on by reference', + ); + assertGated( + ` + const readText = promisify(fs.readFile); + it('x', async () => { + expect(await readText(${thing}, 'utf8')).toContain('go'); + }); + `, + 'promisified', + ); + assertGated( + ` + it('x', async () => { + const [before, after] = await Promise.all([ + readFile(${thing}, 'utf8'), + readFile(${thing}, 'utf8'), + ]); + expect(after).toContain(before); + }); + `, + 'Promise.all', + ); + assertGated( + ` + it('x', async () => { + const text = await Promise.race([readFile(${thing}, 'utf8'), timeout()]); + expect(text).toContain('go'); + }); + `, + 'Promise.race', + ); + // Each settled value is its own: data loaded next to source is still data. + const loadPair = ` + const [source, fixture] = await Promise.all([ + readFile(${thing}, 'utf8'), + readFile(join(tmp, 'expected.json'), 'utf8'), + ]);`; + assertGated( + `it('x', async () => { ${loadPair} expect(source).toContain('go'); });`, + 'Promise.all: the source half', + ); + assertClean( + `it('x', async () => { ${loadPair} expect(fixture).toEqual('{}'); });`, + 'control: Promise.all: the data half', + ); + // Promise.allSettled settles into records, and only `value` holds an input. + const settlePair = ` + const [sourceResult, fixtureResult] = await Promise.allSettled([ + readFile(${thing}, 'utf8'), + readFile(join(tmp, 'expected.json'), 'utf8'), + ]);`; + assertGated( + `it('x', async () => { ${settlePair} expect(sourceResult.value).toContain('go'); });`, + 'Promise.allSettled: the source value', + ); + assertClean( + ` + it('x', async () => { + ${settlePair} + expect(fixtureResult.value).toEqual('{}'); + expect(sourceResult.status).toBe('fulfilled'); + }); + `, + 'control: Promise.allSettled: the data value, and a status', + ); + // A nested pattern takes each part of a record on its own. + const settleNested = `const [{ status, value }] = await Promise.allSettled([readFile(${thing}, 'utf8')]);`; + assertGated( + `it('x', async () => { ${settleNested} expect(value).toContain('go'); });`, + 'Promise.allSettled: a nested pattern, the value', + ); + assertClean( + `it('x', async () => { ${settleNested} expect(status).toBe('fulfilled'); });`, + 'control: Promise.allSettled: a nested pattern, the status', + ); + // Controls: the same callback reading data, and a promise holding no source. + assertClean( + ` + it('x', (done) => { + readFile(join(__dirname, 'fixture.json'), 'utf8', (error, text) => { + expect(text).toContain('go'); + done(); + }); + }); + `, + 'control: callback reading a data file', + ); + assertClean( + ` + it('x', () => load().then((value) => { expect(value).toContain('go'); })); + `, + 'control: then on a promise that holds no source', + ); + // A whole file handed on whole is not a fragment. + assertClean( + ` + it('x', () => + readFile(${thing}, 'utf8').then((code) => { + expect(runInNewContext(code)).toBeDefined(); + })); + `, + 'control: evaluating the whole file a then receives', + ); +}); + +test('an element of source text is source, however it is iterated', () => { + const tainted = `const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8');`; + assertGated( + `${tainted} + it('x', () => { + source.split('\\n').forEach((line) => { + expect(line).not.toMatch(/console/u); + }); + }); + `, + 'forEach callback', + ); + assertGated( + `${tainted} + it('x', () => { + for (const line of source.split('\\n')) { + expect(line).not.toMatch(/console/u); + } + }); + `, + 'for...of', + ); + assertGated( + `${tainted} + it('x', () => { + source.replace(/import .*/gu, (statement) => { + expect(statement).not.toContain('lodash'); + return statement; + }); + }); + `, + 'replace callback', + ); + assertGated( + `${tainted} + function expectNoConsole(line) { + expect(line).not.toMatch(/console/u); + } + it('x', () => { source.split('\\n').forEach(expectNoConsole); }); + `, + 'assertion helper passed by reference', + ); + // An element is only part of what it came from, even a whole file. + assertGated( + `${tainted} + for (const character of source) { + runInNewContext(character); + } + it('x', () => { expect(1).toBe(1); }); + `, + 'evaluating an element', + ); + // Controls: the index is not text, and neither is an element of anything else. + assertClean( + `${tainted} + it('x', () => { + source.split('\\n').forEach((line, index) => { + expect(index).toBeGreaterThanOrEqual(0); + }); + }); + `, + 'control: the index parameter', + ); + assertClean( + `${tainted} + it('x', () => { + for (const name of ['a', 'b']) { + expect(name).not.toMatch(/console/u); + } + }); + `, + 'control: iterating something else', + ); +}); + +test('what a named function returns is still source', () => { + const thing = "join(__dirname, 'thing.ts')"; + assertGated( + ` + const source = readFileSync(${thing}, 'utf8'); + const offsetOf = (needle) => source.indexOf(needle); + it('x', () => { expect(offsetOf('a')).toBeLessThan(offsetOf('b')); }); + `, + 'closure over source', + ); + assertGated( + ` + function loadSource() { + const file = ${thing}; + return readFileSync(file, 'utf8'); + } + it('x', () => { expect(loadSource()).toContain('go'); }); + `, + 'several statements', + ); + // Each property of a returned object literal is its own. + const loader = ` + function loadIndex() { + const source = readFileSync(${thing}, 'utf8'); + return { source, ast: parse(source) }; + }`; + assertGated( + `${loader} + it('x', () => { expect(loadIndex().source).toContain('go'); }); + `, + 'the property of a returned object that holds source', + ); + assertClean( + `${loader} + it('x', () => { expect(loadIndex().ast.type).toBe('Program'); }); + `, + 'control: a property of the same object that does not', + ); + assertClean( + `${loader} + it('x', () => { + const { ast } = loadIndex(); + expect(ast.type).toBe('Program'); + }); + `, + 'control: the same property destructured', + ); + // A read behind a conversion is still a read helper, classified per call. + const readText = + 'const readText = (name) => readFileSync(join(__dirname, name)).toString();'; + assertGated( + `${readText} + it('x', () => { expect(readText('thing.ts')).toContain('go'); }); + `, + 'read helper through toString, source at the call', + ); + assertClean( + `${readText} + it('x', () => { expect(readText('fixture.json')).toContain('go'); }); + `, + 'control: the same helper reading data', + ); + // A helper that builds its path and its text in locals first is still a + // read helper, classified per call. + const readInSteps = ` + function readSource(name) { + const file = join(__dirname, 'src', name); + const text = readFileSync(file, 'utf8'); + return text; + }`; + assertGated( + `${readInSteps} + it('x', () => { expect(readSource('Thing.ts')).toContain('go'); }); + `, + 'read helper built in locals, source at the call', + ); + assertClean( + `${readInSteps} + it('x', () => { expect(readSource('fixture.json')).toContain('go'); }); + `, + 'control: the same helper reading data', + ); + const guardedRead = ` + function loadFixture(name) { + const file = join(__dirname, '__fixtures__', name); + if (!existsSync(file)) { + return ''; + } + return readFileSync(file, 'utf8'); + } + `; + assertGated( + `${guardedRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'guarded read helper reading source at the call', + ); + assertClean( + `${guardedRead} + it('x', () => { expect(loadFixture('a.json')).toContain('go'); }); + `, + 'guarded read helper reading data at the call', + ); + const literalGuardedRead = ` + function loadFixture(name) { + if (!name) { + return ''; + } + return readFileSync(join(__dirname, 'src', name), 'utf8'); + } + `; + assertGated( + `${literalGuardedRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'literal guard reading source at the call', + ); + assertClean( + `${literalGuardedRead} + it('x', () => { expect(loadFixture('')).toContain('go'); }); + `, + 'literal guard selecting its fallback at the call', + ); + const constantGuardedRead = guardedRead.replace( + "return '';", + "const empty = '';\n return empty;", + ); + assertGated( + `${constantGuardedRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'guarded read helper with a constant fallback reading source at the call', + ); + const conditionalFallbackRead = ` + function loadFixture(name, useFallback) { + if (useFallback) { + return ''; + } + return readFileSync(join(__dirname, 'src', name), 'utf8'); + } + `; + assertClean( + `${conditionalFallbackRead} + it('x', () => { + expect(loadFixture('Thing.ts', true)).toContain('go'); + }); + `, + 'conditional literal fallback is not treated as a source read', + ); + assertGated( + `${conditionalFallbackRead} + it('x', () => { + expect(loadFixture('Thing.ts', false)).toContain('go'); + }); + `, + 'conditional read branch is still gated', + ); + const nestedFallbackRead = ` + function loadFixture(name, strict) { + if (strict) { + if (!name) { + return ''; + } + } + return readFileSync(join(__dirname, 'src', name), 'utf8'); + } + `; + assertGated( + `${nestedFallbackRead} + it('x', () => { + expect(loadFixture('Thing.ts', true)).toContain('go'); + }); + `, + 'nested fallback conditions keep the source read gated', + ); + assertClean( + `${nestedFallbackRead} + it('x', () => { + expect(loadFixture('', true)).toContain('go'); + }); + `, + 'nested fallback conditions select the fallback when both are true', + ); + const invertedGuardedRead = ` + function loadFixture(name) { + if (name) { + return readFileSync(join(__dirname, 'src', name), 'utf8'); + } + return ''; + } + `; + assertGated( + `${invertedGuardedRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'trailing fallback keeps the guarded source read gated', + ); + assertClean( + `${invertedGuardedRead} + it('x', () => { expect(loadFixture('')).toContain('go'); }); + `, + 'trailing fallback is selected when the read guard is false', + ); + const switchFallbackRead = ` + function loadFixture(name) { + switch (name) { + case 'fallback': + return ''; + default: + return readFileSync(join(__dirname, 'src', name), 'utf8'); + } + } + `; + assertClean( + `${switchFallbackRead} + it('x', () => { expect(loadFixture('fallback')).toContain('go'); }); + `, + 'switch fallback is selected at the call', + ); + assertGated( + `${switchFallbackRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'switch read branch is still gated', + ); + const switchFallthroughRead = ` + function loadFixture(kind) { + switch (kind) { + case 'Thing.ts': + case 'Thing.tsx': + return readFileSync(join(__dirname, 'src', kind), 'utf8'); + default: + return ''; + } + } + `; + assertGated( + `${switchFallthroughRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'a fallthrough switch case still selects the source read', + ); + assertClean( + `${switchFallthroughRead} + it('x', () => { expect(loadFixture('json')).toContain('go'); }); + `, + 'the fallthrough switch default still selects its fallback', + ); + const bracedSwitchRead = ` + function loadFixture(kind) { + switch (kind) { + case 'Thing.ts': { + return readFileSync(join(__dirname, 'src', kind), 'utf8'); + } + default: + return ''; + } + } + `; + assertGated( + `${bracedSwitchRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'a braced switch case terminates before the fallback case', + ); + assertClean( + `${bracedSwitchRead} + it('x', () => { expect(loadFixture('fixture.json')).toContain('go'); }); + `, + 'a braced switch default still selects its fallback', + ); + const constantSwitchRead = ` + const KIND = 'Thing.ts'; + function loadFixture(kind) { + switch (kind) { + case KIND: + return readFileSync(join(__dirname, 'src', kind), 'utf8'); + default: + return ''; + } + } + `; + assertGated( + `${constantSwitchRead} + it('x', () => { expect(loadFixture('Thing.ts')).toContain('go'); }); + `, + 'an unknown switch label keeps the source read conservatively gated', + ); + const conditionalFallthroughRead = ` + function loadFixture(kind, shouldRead) { + const file = join(__dirname, 'src', kind); + switch (kind) { + case 'Thing.ts': + if (shouldRead) { + return readFileSync(file, 'utf8'); + } + default: + return ''; + } + } + `; + assertGated( + `${conditionalFallthroughRead} + it('x', () => { + expect(loadFixture('Thing.ts', true)).toContain('go'); + }); + `, + 'a may-fallthrough case does not select the static fallback', + ); + assertClean( + `${conditionalFallthroughRead} + it('x', () => { + expect(loadFixture('json', false)).toContain('go'); + }); + `, + 'a may-fallthrough default still selects its fallback when unmatched', + ); + // Whole or cut, as it was returned. + const script = + "const code = readFileSync(join(__dirname, 'thing.js'), 'utf8');"; + assertClean( + ` + function loadScript() { + ${script} + return code; + } + runInNewContext(loadScript()); + it('x', () => { expect(1).toBe(1); }); + `, + 'control: evaluating a whole file a function returns', + ); + assertGated( + ` + function loadBody() { + ${script} + return code.slice(code.indexOf('const go =')); + } + runInNewContext(loadBody()); + it('x', () => { expect(1).toBe(1); }); + `, + 'evaluating a fragment a function returns', + ); +}); + +test('what a variable stores under a property is still source', () => { + const read = "readFileSync(join(__dirname, 'thing.ts'), 'utf8')"; + assertGated( + ` + const context = {}; + beforeAll(() => { context.source = ${read}; }); + it('x', () => { expect(context.source).toContain('go'); }); + `, + 'assigned in a hook', + ); + assertGated( + ` + const context = {}; + beforeAll(() => { context['source'] = ${read}; }); + it('x', () => { expect(context['source']).toContain('go'); }); + `, + 'string keys', + ); + assertGated( + ` + const context = {}; + beforeAll(() => { context.source = ${read}; }); + it('x', () => { + const { source: text } = context; + expect(text).toContain('go'); + }); + `, + 'destructured out of the variable', + ); + // A literal spells out what each of its parts holds. + const literalOwner = `const context = { source: ${read}, count: 3 };`; + assertGated( + `${literalOwner} + it('x', () => { + const { source } = context; + expect(source).toContain('go'); + }); + `, + 'destructured out of an object literal', + ); + assertClean( + `${literalOwner} + it('x', () => { + const { count } = context; + expect(count).toBe(3); + expect(context.count).toBe(3); + }); + `, + 'control: another property of the same object literal', + ); + assertClean( + ` + const context = { files: { source: ${read}, count: 3 } }; + it('x', () => { expect(context.files.count).toBe(3); }); + `, + 'control: another property of a nested object literal', + ); + const nestedOwner = `const context = { files: { source: ${read}, count: 3 } };`; + assertGated( + `${nestedOwner} + it('x', () => { + const { files: { source } } = context; + expect(source).toContain('go'); + }); + `, + 'a nested pattern over a nested literal', + ); + assertClean( + `${nestedOwner} + it('x', () => { + const { files: { count } } = context; + expect(count).toBe(3); + }); + `, + 'control: the same nested pattern, another property', + ); + // A spread written after a property can replace it. + const payload = `const payload = { source: ${read} };`; + assertGated( + `${payload} + const context = { files: { source: 'plain', ...payload } }; + it('x', () => { expect(context.files.source).toContain('go'); }); + `, + 'a property a later spread can replace', + ); + assertGated( + `${payload} + it('x', () => { + const { source } = { source: 'plain', ...payload }; + expect(source).toContain('go'); + }); + `, + 'the same, destructured out of the literal', + ); + assertClean( + `${payload} + const context = { ...payload, source: 'plain' }; + it('x', () => { expect(context.source).toBe('plain'); }); + `, + 'control: a property written after the spread', + ); + // A spread can replace a name however the owner came to have it: spelled in + // an earlier literal, or left out of this literal altogether. + assertGated( + `${payload} + let context = { source: 'plain' }; + context = { source: 'plain', ...payload }; + it('x', () => { expect(context.source).toContain('go'); }); + `, + 'a name spelled in one literal and replaced by a spread in another', + ); + assertGated( + `${payload} + let context = { source: 'plain' }; + context = { ...payload }; + it('x', () => { expect(context.source).toContain('go'); }); + `, + 'a name a later literal leaves to its spread', + ); + assertClean( + `${payload} + let context = { source: 'plain' }; + context = { ...payload, source: 'plain' }; + it('x', () => { expect(context.source).toBe('plain'); }); + `, + 'control: a later literal that spells the name after its spread', + ); + // What a spread can put under a name is what its argument holds there. + assertClean( + ` + const counted = { source: ${read}, count: 3 }; + const context = { count: 1, ...counted }; + it('x', () => { expect(context.count).toBe(3); }); + `, + 'control: a name the spread argument holds no source under', + ); + // A computed key can be any name, so it can replace a property like a spread. + assertGated( + ` + const context = { source: 'plain', [key]: ${read} }; + it('x', () => { + const { source } = context; + expect(source).toContain('go'); + expect(context.source).toContain('go'); + }); + `, + 'a property a later computed key can replace', + ); + assertClean( + ` + const context = { [key]: ${read}, source: 'plain' }; + it('x', () => { expect(context.source).toBe('plain'); }); + `, + 'control: a property written after the computed key', + ); + const arrayOwner = `const files = [${read}, 'plain'];`; + assertGated( + `${arrayOwner} + it('x', () => { expect(files[0]).toContain('go'); }); + `, + 'an element of an array literal', + ); + assertClean( + `${arrayOwner} + it('x', () => { + const [, plain] = files; + expect(plain).toBe('plain'); + expect(files[1]).toBe('plain'); + }); + `, + 'control: another element of the same array literal', + ); + // A property that is only ever assigned may still hold whatever the rest of + // its owner does. + assertGated( + ` + function expectBody(page) { + page.body = page.body.trim(); + expect(page.body).toContain('go'); + } + it('x', () => { expectBody({ body: ${read} }); }); + `, + 'reassigned from itself on a parameter', + ); + // Controls: another property of it, and the same name on another variable. + assertClean( + ` + const context = {}; + const other = { source: 'plain' }; + beforeAll(() => { + context.source = ${read}; + context.count = 1; + }); + it('x', () => { + expect(context.count).toBe(1); + expect(other.source).toContain('go'); + }); + `, + 'control: other properties and other variables', + ); + // A whole file stored in an object literal is still the whole file. + assertClean( + ` + const files = { script: readFileSync(join(__dirname, 'thing.js'), 'utf8') }; + runInNewContext(files.script); + it('x', () => { expect(1).toBe(1); }); + `, + 'control: evaluating a whole file an object holds', + ); + assertClean( + ` + const files = { script: readFileSync(join(__dirname, 'thing.js'), 'utf8') }; + const { script } = files; + runInNewContext(script); + it('x', () => { expect(1).toBe(1); }); + `, + 'control: evaluating a whole file destructured out of an object', + ); +}); + +test('a path moved into a binding classifies like the path itself', () => { + assertGated( + ` + it.each(['thing'])('x', (name) => { + const file = path.join(__dirname, name); + expect(readFileSync(file, 'utf8')).toContain('go'); + }); + `, + 'sibling of the test named by a variable', + ); + assertGated( + ` + const file = require.resolve('../thing'); + const source = readFileSync(file, 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'module specifier', + ); + // Control: a path whose head is built on __dirname but that names a + // directory holding no source is still not source. + assertClean( + `${REPO_ROOT_PREAMBLE} + const file = path.join(repoRoot, '.github/workflows', name); + const source = readFileSync(file, 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'control: a workflow named by a variable', + ); +}); + +test('a default value is one more value its identifier can hold', () => { + const read = "readFileSync(join(__dirname, 'thing.ts'), 'utf8')"; + assertGated( + ` + it('x', () => { + const { source = ${read} } = {}; + expect(source).toContain('go'); + }); + `, + 'destructuring default', + ); + assertGated( + ` + it.each([undefined])('x', (text = ${read}) => { + expect(text).toContain('go'); + }); + `, + 'parameter default', + ); + assertClean( + ` + it('x', () => { + const { count = 3 } = {}; + expect(count).toBe(3); + }); + `, + 'control: a default that holds no source', + ); +}); + +test('a transform helper that cuts text does not launder a fragment', () => { + const read = "readFileSync(join(__dirname, 'thing.js'), 'utf8')"; + assertGated( + ` + const cut = (text) => text.slice(text.indexOf('const go =')); + it('x', () => { + const go = runInNewContext(cut(${read})); + expect(go).toBeDefined(); + }); + `, + 'slice inside the helper', + ); + // Control: a helper that only trims hands the whole file back. + assertClean( + ` + const tidy = (text) => text.trim(); + it('x', () => { + const go = runInNewContext(tidy(${read})); + expect(go).toBeDefined(); + }); + `, + 'control: trim inside the helper', + ); +}); + +test('a binding cut anywhere is a fragment wherever it is evaluated', () => { + assertGated( + ` + let code = readFileSync(join(__dirname, 'thing.js'), 'utf8'); + code = code.slice(code.indexOf('const go =')); + runInNewContext(code, {}); + it('x', () => { expect(1).toBe(1); }); + `, + 'read whole, then cut in place', + ); + // Control: reassigned, but never cut. + assertClean( + ` + let code = readFileSync(join(__dirname, 'thing.js'), 'utf8'); + code = code.trim(); + runInNewContext(code, {}); + it('x', () => { expect(1).toBe(1); }); + `, + 'control: reassigned whole', + ); +}); + +test('anchors, read aliases and helpers resolve by binding, not by name', () => { + // A temp directory bound under the name the file uses for the repository + // root is still a temp directory. + const anchoredRoot = "const root = path.resolve(__dirname, '../..');"; + const readUnderRoot = + "expect(readFileSync(path.join(root, 'packages/kit/src/Thing.ts'), 'utf8')).toBe('done');"; + assertClean( + `${anchoredRoot} + it('x', () => { + const root = fs.mkdtempSync(os.tmpdir()); + ${readUnderRoot} + }); + `, + 'temp directory shadowing an anchored name', + ); + assertGated( + `${anchoredRoot} + it('x', () => { ${readUnderRoot} }); + `, + 'control: the anchored binding itself', + ); + // A parameter that shares an alias's name is not the alias. + assertClean( + ` + const read = fs.readFileSync; + it('x', () => { + expect(loaders.map((read) => read(join(__dirname, 'thing.ts')))).toEqual([]); + }); + `, + 'parameter shadowing a read alias', + ); + assertGated( + ` + const read = fs.readFileSync; + it('x', () => { + expect(loaders.map((loader) => read(join(__dirname, 'thing.ts')))).toEqual([]); + }); + `, + 'control: the alias itself', + ); + // A function's own name is looked up around it, not inside it, where a + // parameter of the same name lives. + assertGated( + ` + function contents(contents) { + expect(contents).toContain('go'); + } + it('x', () => { contents(readFileSync(join(__dirname, 'thing.ts'), 'utf8')); }); + `, + 'assertion helper whose parameter shares its name', + ); + // A name nothing declares is one global, wherever it is assigned or read. + assertGated( + ` + beforeAll(() => { source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); }); + it('x', () => { expect(source).toContain('go'); }); + `, + 'undeclared global assigned in a hook', + ); +}); + +test('a helper asserts for its caller only on text passed in', () => { + const { violations } = analyzeFile( + FIXTURE_PATH, + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + function expectMentions(needle) { + expect(source).toContain(needle); + } + it('passes source as the needle', () => { expectMentions(source); }); + `, + ); + // The assertion is about `source` whatever the caller passes, so it is + // recorded once, where it is written, and the call adds nothing. + assert.deepEqual( + violations + .filter((violation) => violation.rule === 'source-text-assertion') + .map((violation) => violation.block), + [undefined], + ); +}); + +test('a name the callback binds is its own, not the file-level one', () => { + const tainted = `const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8');`; + assertClean( + `${tainted} + const devices = []; + it('x', () => { expect(devices.map((source) => source.id)).toEqual([]); }); + `, + 'parameter shadows', + ); + assertClean( + `${tainted} + const items = []; + it('x', () => { + expect( + items.map((item) => { + try { + go(); + } catch (source) { + return source.id; + } + return 1; + }), + ).toEqual([]); + }); + `, + 'catch binding shadows', + ); + assertClean( + `${tainted} + const items = []; + it('x', () => { + expect( + items.map((item) => { + for (const source of item) { + return source.id; + } + return 1; + }), + ).toEqual([]); + }); + `, + 'for-of binding shadows', + ); + // Control: a callback that really does capture the binding is gated. + assertGated( + `${tainted} + const items = []; + it('x', () => { expect(items.map((item) => source.includes(item))).toEqual([]); }); + `, + 'control: genuine capture', + ); +}); + +test('unparseable input throws a SyntaxError and nothing else', () => { + // analyzeOne tolerates exactly this and rethrows everything else, so that an + // internal defect cannot drop a file from the gate while looking clean. + assert.throws( + () => analyzeFile(FIXTURE_PATH, 'const a = (((;'), + (error) => error instanceof SyntaxError, + ); + // The parser recovers from a redeclaration and scope analysis rejects it, + // which must surface the same way rather than as a crash in this check. + assert.throws( + () => analyzeFile(FIXTURE_PATH, 'let a = 1;\nlet a = 2;'), + (error) => error instanceof SyntaxError, + ); +}); + +test('an assertion helper is where the claim is made, not where it is spelled', () => { + assertGated( + ` + function expectContains(text, needle) { + expect(text).toContain(needle); + } + it('x', () => { + expectContains(readFileSync(join(__dirname, 'thing.ts'), 'utf8'), 'go'); + }); + `, + 'expect helper', + ); + assertGated( + ` + const expectNoTimers = (text) => { + expect(text).not.toMatch(/setTimeout/u); + }; + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expectNoTimers(source); }); + `, + 'arrow helper', + ); + assertGated( + ` + function assertClean(text) { + assert.ok(!text.includes('go')); + } + it('x', () => { + assertClean(readFileSync(join(__dirname, 'thing.ts'), 'utf8')); + }); + `, + 'node:assert helper, negated', + ); + // Declared before the helper it hands its parameter to. + assertGated( + ` + function expectClean(text) { + expectNoConsole(text); + } + function expectNoConsole(text) { + expect(text).not.toMatch(/console/u); + } + it('x', () => { + expectClean(readFileSync(join(__dirname, 'thing.ts'), 'utf8')); + }); + `, + 'helper delegating to a helper', + ); + // The helper is not what decides: a plain string through it is clean. + assertClean( + ` + function expectContains(text, needle) { + expect(text).toContain(needle); + } + it('x', () => { expectContains('plain text', 'go'); }); + `, + 'helper called with no source', + ); + // A helper that asserts nothing is not an assertion helper. + assertClean( + ` + function measure(text) { + return text.length; + } + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { measure(source); }); + `, + 'helper that asserts nothing', + ); +}); + +test('negation does not launder a claim about source', () => { + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expect(!source.includes('go')).toBe(true); }); + `, + 'negated subject', + ); + assertClean( + ` + const items = []; + it('x', () => { expect(!items.length).toBe(true); }); + `, + 'negation with no source in it', + ); +}); + +test('a local the callback declares shadows the file-level binding', () => { + const tainted = `const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8');`; + assertClean( + `${tainted} + const items = []; + it('x', () => { + expect( + items.map((item) => { + const source = item.text; + return source.length; + }), + ).toEqual([]); + }); + `, + 'const from an untainted value', + ); + assertClean( + `${tainted} + const items = []; + it('x', () => { + expect( + items.map((item) => { + let source; + source = item.text; + return source.length; + }), + ).toEqual([]); + }); + `, + 'let assigned an untainted value', + ); + // Control: the same local built from a read keeps its taint. + assertGated( + `${tainted} + const items = []; + it('x', () => { + expect( + items.map((item) => { + const source = readFileSync(join(__dirname, item), 'utf8'); + return source.length; + }), + ).toEqual([]); + }); + `, + 'control: local built from a read', + ); +}); + +test('an assertion helper counts assertions made inside its callbacks', () => { + const forEachHelper = ` + const expectAllPresent = (text, needles) => + needles.forEach((needle) => expect(text).toContain(needle)); + `; + assertGated( + `${forEachHelper} + it('x', () => { + expectAllPresent(readFileSync(join(__dirname, 'thing.ts'), 'utf8'), ['a']); + }); + `, + 'sink inside a forEach callback', + ); + assertGated( + ` + function describeContract(text) { + it('inner', () => { expect(text).toContain('go'); }); + } + describeContract(readFileSync(join(__dirname, 'thing.ts'), 'utf8')); + `, + 'sink inside an it body', + ); + assertClean( + `${forEachHelper} + it('x', () => { expectAllPresent('plain text', ['a']); }); + `, + 'same helper, no source', + ); + // A callback that rebinds the name is asserting on its own value. + assertClean( + ` + const expectAll = (text, needles) => + needles.forEach((text) => expect(text).toBeDefined()); + it('x', () => { + expectAll(readFileSync(join(__dirname, 'thing.ts'), 'utf8'), ['a']); + }); + `, + 'inner callback shadows the parameter', + ); +}); + +test('a helper body is not a claim about an outer binding it shadows', () => { + const { violations, wholeFile } = analyzeFile( + FIXTURE_PATH, + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + function expectClean(source) { + expect(source).not.toContain('x'); + } + it('asserts through the helper', () => { expectClean(source); }); + it('does something else', () => { expect(1).toBe(1); }); + `, + ); + const hits = violations.filter( + (violation) => violation.rule === 'source-text-assertion', + ); + // One call, one violation, in the block that made it - not a second one + // from the definition landing in shared setup. + assert.deepEqual( + hits.map((hit) => hit.block), + ['asserts through the helper'], + ); + assert.equal(wholeFile, false); +}); + +test('a local filled from a hook or another test is still source', () => { + assertGated( + ` + describe('d', () => { + let source; + beforeAll(() => { + source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'describe-level let assigned in beforeAll', + ); + assertGated( + ` + describe('d', () => { + let source = ''; + beforeEach(() => { + source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'initialised let reassigned in a hook', + ); + // Jest runs beforeAll before beforeEach whatever order they are written in, + // so a definition can depend on one that appears below it. + assertGated( + ` + describe('d', () => { + let source; + let body; + beforeEach(() => { body = source.slice(source.indexOf('go')); }); + beforeAll(() => { source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); }); + it('x', () => { expect(body).toContain('go'); }); + }); + `, + 'derived in a hook written above the one that reads', + ); + assertClean( + ` + describe('d', () => { + let source; + beforeAll(() => { source = 'plain'; }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'hook assigns something untainted', + ); + // An assignment inside a callback that rebinds the name is to that binding. + assertClean( + ` + describe('d', () => { + let source; + items.forEach((source) => { + source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'nested parameter rebinds the name', + ); +}); + +test('source held in an object literal is still source', () => { + assertGated( + ` + it('x', () => { + expect({ text: readFileSync(join(__dirname, 'thing.ts'), 'utf8') }).toEqual({ text: 'go' }); + }); + `, + 'property value + toEqual', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expect({ source }).toMatchObject({ source: 'go' }); }); + `, + 'shorthand + toMatchObject', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expect({ ...{ source } }).toHaveProperty('source'); }); + `, + 'spread + toHaveProperty', + ); + // A key that happens to be named source says nothing about the value. + assertClean( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { expect({ source: 1 }).toEqual({ source: 1 }); }); + `, + 'key named source, untainted value', + ); +}); + +test('a hook assignment is read in the hook, and its own bindings stay its own', () => { + // The right-hand side lives in the hook, so the hook's locals must be visible + // to it - including when an outer binding of the same name would hide them. + assertGated( + ` + describe('d', () => { + const text = 'outer'; + let source; + beforeAll(() => { + const text = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + source = text; + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'hook-local intermediate under a shadowing outer name', + ); + assertGated( + ` + describe('d', () => { + let source; + beforeAll(() => { + items.forEach((file) => { + const contents = readFileSync(join(__dirname, file), 'utf8'); + source = contents; + }); + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'two levels down', + ); + // A hook that declares or catches its own `source` is assigning that one. + assertClean( + ` + describe('d', () => { + let source = 'plain'; + beforeAll(() => { + let source; + source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'hook redeclares the name', + ); + assertClean( + ` + describe('d', () => { + let source = 'plain'; + beforeAll(() => { + try { + go(); + } catch (source) { + source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + } + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'hook catch binding rebinds the name', + ); +}); + +test('a hook local built up by its own callback is followed outward', () => { + assertGated( + ` + describe('d', () => { + let source; + beforeAll(() => { + let text = ''; + files.forEach((file) => { + text += readFileSync(join(__dirname, file), 'utf8'); + }); + source = text; + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'accumulated in a forEach, then assigned out', + ); + assertClean( + ` + describe('d', () => { + let source; + beforeAll(() => { + let text = ''; + files.forEach(() => { text += 'plain'; }); + source = text; + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'the loop accumulates something untainted', + ); + // Reworked after the loop has filled it: the order the statements are + // written in must not decide whether the taint arrives. + assertGated( + ` + describe('d', () => { + let source; + beforeAll(() => { + let text = ''; + files.forEach((file) => { + text += readFileSync(join(__dirname, file), 'utf8'); + }); + const trimmed = text.trim(); + source = trimmed; + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'reworked after the loop, then assigned out', + ); + assertClean( + ` + describe('d', () => { + let source; + beforeAll(() => { + let text = ''; + files.forEach(() => { text += 'plain'; }); + const trimmed = text.trim(); + source = trimmed; + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'the same rework of untainted text', + ); + // Through intermediates on both sides of the loop. + assertGated( + ` + describe('d', () => { + let source; + beforeAll(() => { + let text = ''; + files.forEach((file) => { + const contents = readFileSync(join(__dirname, file), 'utf8'); + const trimmed = contents.trim(); + text += trimmed; + }); + const joined = text; + source = joined; + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'intermediates inside the loop and after it', + ); + // The loop body's own `text` is a different binding from the hook's. + assertClean( + ` + describe('d', () => { + let source; + beforeAll(() => { + let text = ''; + files.forEach((file) => { + let text = ''; + text += readFileSync(join(__dirname, file), 'utf8'); + }); + source = text; + }); + it('x', () => { expect(source).toContain('go'); }); + }); + `, + 'the loop redeclares its own text', + ); +}); + +test('ios and android are skipped only as native project roots', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'test-integrity-dirs-')); + try { + const write = (relative, contents = '') => { + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + }; + write('ios/Podfile'); + write('ios/Native.test.js'); + write('android/settings.gradle'); + write('android/Native.test.js'); + write('src/ios/Platform.test.ts'); + write('src/android/Platform.test.ts'); + write('out-dir-bundle/ios/Bundled.test.js'); + + const found = collectTestFiles(root, []) + .map((file) => path.relative(root, file).split(path.sep).join('/')) + .toSorted(); + + assert.deepEqual(found, [ + 'src/android/Platform.test.ts', + 'src/ios/Platform.test.ts', + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test('ignores a read anchored at a temp directory', () => { + // The literal names a .js file, but the path is something the test built. + assertClean(` + it('x', () => { + const directory = mkdtempSync(join(tmpdir(), 'fixture-')); + const built = readFileSync(join(directory, 'index.js'), 'utf8'); + expect(built).toBe('done'); + }); + `); +}); + +test('ignores declarative config read from the repository', () => { + assertClean(` + const manifest = readFileSync(join(__dirname, 'AndroidManifest.xml'), 'utf8'); + it('x', () => { + expect(manifest).toContain('android:exported="false"'); + }); + `); +}); + +test('reports native source as advisory rather than gated', () => { + const source = ` + const delegate = readFileSync(join(__dirname, 'AppDelegate.swift'), 'utf8'); + it('x', () => { + expect(delegate).toContain('applicationDidFinishLaunching'); + }); + `; + const { violations } = analyzeFile(FIXTURE_PATH, source); + assert.deepEqual(gatedRules(source), []); + assert.ok( + violations.some( + (violation) => violation.rule === 'native-source-text-assertion', + ), + ); +}); + +test('an advisory hit alone never produces a whole-file verdict', () => { + const { wholeFile } = analyzeFile( + FIXTURE_PATH, + ` + const delegate = readFileSync(join(__dirname, 'AppDelegate.swift'), 'utf8'); + it('x', () => { + expect(delegate).toContain('a'); + }); + it('y', () => { + expect(delegate).toContain('b'); + }); + `, + ); + assert.equal(wholeFile, false); +}); + +test('a gated hit in every block produces a whole-file verdict', () => { + const { wholeFile } = analyzeFile( + FIXTURE_PATH, + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { + expect(source).toContain('a'); + }); + it('y', () => { + expect(source).toContain('b'); + }); + `, + ); + assert.equal(wholeFile, true); +}); + +test('a parameterized test is one block, whatever it is chained from', () => { + const source = `const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8');`; + // `it.each(table)` only builds the block, so a file whose one test violates + // is still a whole-file violation. + const each = analyzeFile( + FIXTURE_PATH, + `${source} + it.each(['a', 'b'])('mentions %s', (needle) => { + expect(source).toContain(needle); + }); + `, + ); + assert.deepEqual( + each.testBlocks.map((block) => block.title), + ['mentions %s'], + ); + assert.equal(each.wholeFile, true); + const only = analyzeFile( + FIXTURE_PATH, + `${source} + it.only.each(['a'])('only mentions %s', (needle) => { + expect(source).toContain(needle); + }); + `, + ); + assert.deepEqual( + only.violations + .filter((violation) => violation.rule === 'source-text-assertion') + .map((violation) => violation.block), + ['only mentions %s'], + ); +}); + +test('records the enclosing block so an exemption can name it', () => { + const { violations } = analyzeFile( + FIXTURE_PATH, + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('keeps the thing out', () => { + expect(source).not.toContain('thing'); + }); + `, + ); + assert.equal(violations[0].block, 'keeps the thing out'); +}); + +test('the shipped allowlist matches live violations and stays justified', () => { + const allowlistPath = path.join(__dirname, 'test-integrity.allowlist.json'); + const { entries } = JSON.parse(fs.readFileSync(allowlistPath, 'utf8')); + for (const entry of entries) { + assert.ok( + fs.existsSync(path.join(__dirname, '../..', entry.file)), + `${entry.file} does not exist`, + ); + // Mirrors the loadAllowlist contract: null is the shared-setup form. + assert.ok(entry.block === null || typeof entry.block === 'string'); + assert.ok(entry.reason.trim().length >= 40, `${entry.file} needs a reason`); + assert.ok(Number.isInteger(entry.count) && entry.count >= 1); + } + // `run()` reports an entry that no longer matches, which is what keeps a + // stale exemption from silently widening over time. + const { staleEntries } = require('./test-integrity').run(); + assert.deepEqual( + staleEntries.map((entry) => entry.file), + [], + ); +}); + +test('catches reads anchored without __dirname', () => { + assertGated( + ` + const source = readFileSync('packages/kit/src/Thing.ts', 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'cwd-relative literal', + ); + assertGated( + ` + const source = readFileSync(path.join(process.cwd(), 'apps/cli/src/x.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'process.cwd()', + ); + assertGated( + ` + const source = readFileSync(path.join(path.dirname(__filename), 'x.ts'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + '__filename', + ); + assertGated( + ` + const source = readFileSync(require.resolve('../thing'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'require.resolve', + ); +}); + +// Real tests build their paths off a repoRoot binding, and the fixture has to +// as well: without it these paths are not anchored at all and the assertions +// below would hold no matter what the classifier did. +const REPO_ROOT_PREAMBLE = + "const repoRoot = path.resolve(__dirname, '../..');\n"; + +test('follows the extension through the binding that built the path', () => { + assertGated( + `${REPO_ROOT_PREAMBLE} + const file = path.join(repoRoot, 'packages/kit/src/Thing.ts'); + const source = readFileSync(file, 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'source behind a binding', + ); + // Positive control for the two exclusions below: same shape, source path. + assertGated( + `${REPO_ROOT_PREAMBLE} + const file = path.join(repoRoot, 'packages/kit/src/x.js'); + const source = readFileSync(file, 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'control for the vendored case', + ); + assertClean( + `${REPO_ROOT_PREAMBLE} + const file = path.join(repoRoot, 'node_modules/react-native/x.js'); + const source = readFileSync(file, 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'vendored code behind a binding', + ); + assertClean( + `${REPO_ROOT_PREAMBLE} + const file = path.join(repoRoot, 'apps/mobile/ios/Podfile.lock'); + const source = readFileSync(file, 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'data file behind a binding', + ); + assertClean( + `${REPO_ROOT_PREAMBLE} + const source = readFileSync(path.join(repoRoot, '.github/workflows', name), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'workflow named by a variable', + ); +}); + +test('a temp directory that mirrors the repository layout stays clean', () => { + // Nothing but the head-only rule saves this one: the suffix names a real + // source directory and the file has a source extension. + assertClean(` + it('x', () => { + const root = fs.mkdtempSync(os.tmpdir()); + const packageRoot = path.join(root, 'packages/kit/src'); + expect(readFileSync(path.join(packageRoot, 'Thing.ts'), 'utf8')).toBe('done'); + }); + `); + // Control: the same read anchored for real is gated. + assertGated( + `${REPO_ROOT_PREAMBLE} + const packageRoot = path.join(repoRoot, 'packages/kit/src'); + it('x', () => { + expect(readFileSync(path.join(packageRoot, 'Thing.ts'), 'utf8')).toBe('done'); + }); + `, + 'control: genuinely anchored', + ); +}); + +test('a directory whose last segment is an artifact root is not source', () => { + assertClean( + `${REPO_ROOT_PREAMBLE} + const vendorRoot = path.join(repoRoot, 'apps/desktop/app/node_modules'); + const source = readFileSync(path.join(vendorRoot, 'index.js'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'trailing node_modules segment', + ); + // Control: same shape without the artifact segment. + assertGated( + `${REPO_ROOT_PREAMBLE} + const sourceRoot = path.join(repoRoot, 'apps/desktop/app/utils'); + const source = readFileSync(path.join(sourceRoot, 'index.js'), 'utf8'); + it('x', () => { expect(source).toContain('go'); }); + `, + 'control: not an artifact root', + ); +}); + +test('keeps taint across array methods, fallbacks and destructuring', () => { + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('x', () => { + expect(source.split('\\n').filter((line) => line.includes('go'))).toHaveLength(2); + }); + `, + 'split().filter()', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const body = source.match(/go/u)?.[1] ?? ''; + it('x', () => { expect(body).toContain('go'); }); + `, + '?? fallback', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const [, body] = source.match(/go/u); + it('x', () => { expect(body).toContain('go'); }); + `, + 'array destructuring', + ); + assertGated( + ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const body = ready ? source.slice(1) : ''; + it('x', () => { expect(body).toContain('go'); }); + `, + 'ternary', + ); +}); + +test('an exempted block does not drive the whole-file verdict', () => { + const source = ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('exempted', () => { expect(source).toContain('a'); }); + it('gated', () => { expect(source).toContain('b'); }); + `; + const allowlist = [ + { + file: path.relative(path.join(__dirname, '../..'), FIXTURE_PATH), + rule: 'source-text-assertion', + block: 'exempted', + count: 1, + reason: 'x'.repeat(40), + }, + ]; + const used = new Map(); + const result = analyzeFile(FIXTURE_PATH, source, allowlist, used); + + assert.equal(used.size, 1); + assert.deepEqual( + result.violations + .filter((violation) => violation.rule === 'source-text-assertion') + .map((violation) => violation.block), + ['gated'], + ); + // Both blocks violate, but only one of them lacks a reviewed exemption, so + // the file is not a deletion candidate. + assert.equal(result.wholeFile, false); +}); + +test('a shared-setup violation is exemptable with block null', () => { + const source = ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + const fragment = source.slice(source.indexOf('const go =')); + runInNewContext(transformSync(fragment).code, {}); + it('x', () => { expect(true).toBe(true); }); + `; + const allowlist = [ + { + file: path.relative(path.join(__dirname, '../..'), FIXTURE_PATH), + rule: 'source-slice-eval', + block: null, + count: 1, + reason: 'x'.repeat(40), + }, + ]; + const used = new Map(); + const result = analyzeFile(FIXTURE_PATH, source, allowlist, used); + + assert.equal(used.size, 1); + assert.deepEqual(gatedRules(source).length > 0, true); + assert.deepEqual( + result.violations.filter( + (violation) => violation.rule === 'source-slice-eval', + ), + [], + ); +}); + +test('an exemption covers only the number of violations reviewed', () => { + const source = ` + const source = readFileSync(join(__dirname, 'thing.ts'), 'utf8'); + it('exempted', () => { + expect(source).toContain('a'); + expect(source).toContain('b'); + }); + `; + const entry = { + file: path.relative(path.join(__dirname, '../..'), FIXTURE_PATH), + rule: 'source-text-assertion', + block: 'exempted', + count: 1, + reason: 'x'.repeat(40), + }; + const result = analyzeFile(FIXTURE_PATH, source, [entry], new Map()); + + // The block was reviewed with one assertion; the second one is new. + assert.equal( + result.violations.filter( + (violation) => violation.rule === 'source-text-assertion', + ).length, + 1, + ); +}); + +test('temp directory helper names are not treated as repository anchors', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'test-integrity-')); + try { + assertClean(` + const root = join('${directory.replace(/\\/gu, '/')}', 'pkg'); + const built = readFileSync(join(root, 'index.ts'), 'utf8'); + it('x', () => { + expect(built).toContain('go'); + }); + `); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); diff --git a/package.json b/package.json index 5342d5a91..ee46f8e8e 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "packageManager": "yarn@4.1.0", "version": "0.0.1", "scripts": { + "lint:test-integrity": "node development/lint/test-integrity.js", "create:module": "node scripts/create-nitro-module.js", "create:view": "node scripts/create-nitro-view.js", "version:patch:update": "yarn workspaces foreach --all --exclude @onekeyfe/app-modules-example --exclude @onekeyfe/app-modules --topological version --deferred patch", @@ -23,7 +24,9 @@ "node": ">=20" }, "devDependencies": { + "@babel/parser": "7.28.5", "@babel/runtime": "^7.25.0", + "@babel/traverse": "7.28.5", "@commitlint/config-conventional": "^19.8.1", "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", diff --git a/yarn.lock b/yarn.lock index c67f70f84..4dd9a8011 100644 --- a/yarn.lock +++ b/yarn.lock @@ -348,7 +348,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:7.28.5, @babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" dependencies: @@ -1578,7 +1578,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4, @babel/traverse@npm:^7.28.5": +"@babel/traverse@npm:7.28.5, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4, @babel/traverse@npm:^7.28.5": version: 7.28.5 resolution: "@babel/traverse@npm:7.28.5" dependencies: @@ -3019,7 +3019,9 @@ __metadata: version: 0.0.0-use.local resolution: "@onekeyfe/app-modules@workspace:." dependencies: + "@babel/parser": "npm:7.28.5" "@babel/runtime": "npm:^7.25.0" + "@babel/traverse": "npm:7.28.5" "@commitlint/config-conventional": "npm:^19.8.1" "@eslint/compat": "npm:^1.3.2" "@eslint/eslintrc": "npm:^3.3.1" From 6b3b816f31a00da1499b586b91d9567990419021 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 17:23:46 +0800 Subject: [PATCH 4/4] docs(text-input): document Android paste behavior --- .../react-native-text-input/docs/SPEC.md | 75 ++++++++++++------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/native-views/react-native-text-input/docs/SPEC.md b/native-views/react-native-text-input/docs/SPEC.md index b03ef4550..82bde5219 100644 --- a/native-views/react-native-text-input/docs/SPEC.md +++ b/native-views/react-native-text-input/docs/SPEC.md @@ -1,32 +1,39 @@ # React Native Text Input behavioral contract -Status: Implemented in source; iOS device interaction remains to be verified. +Status: Implemented in source; iOS and Android device interaction remains to be verified. ## Purpose, scope, and ownership This package wraps React Native text inputs and emits paste events for text and images. The native input owns the platform paste action; callers own handling the -emitted event and any temporary image file URL. The package does not inspect -application-specific clipboard data. +emitted event and any referenced image data. The package does not inspect +application-specific clipboard data. Its native paste integration supports +iOS and Android; it does not define Web paste behavior. ## Public API and defaults `TextInput` accepts React Native `TextInputProps` and an optional `onPaste` -callback. The callback receives `nativeEvent.items`; each item may contain -`type` and `data`. An image item has a MIME type and temporary file URL, and a -text item has type `text/plain`. With no `onPaste` callback, there is no -JavaScript paste subscription. This cache does not add a public prop or change -the callback payload. +callback. The callback receives `nativeEvent.items`; each reported item has a +MIME `type` and `data`. Text data is a string with type `text/plain`. Image +data is platform-specific: iOS supplies a temporary local file URL, while +Android supplies the clipboard item's URI when its content resolver returns a +MIME type. With no `onPaste` callback, iOS has no JavaScript paste +subscription and Android has no paste watcher. The cache does not add a +public prop or change the callback payload. ## Lifecycle, concurrency, and cache -The iOS observer starts with the image cache uninitialized, registers for -pasteboard changes, app activation, and text-input begin-editing, and requests -an initial refresh. It uses one serial background queue for pasteboard image -availability reads and coalesces concurrent notifications. A notification -during a read requires another read before the cache is current. The cache is -process-local and is not persisted; its value is only a hint for command -availability. The paste handler checks the actual clipboard content when used. +- iOS starts with the image cache uninitialized, registers for pasteboard + changes, app activation, and text-input begin-editing, and requests an + initial refresh. One serial background queue coalesces availability reads. + A notification during a read requires another read. The process-local cache + is only a command-availability hint; the paste handler checks the actual + clipboard content when used. +- Android attaches a watcher to each native input only while `onPaste` is + enabled. It reads the clipboard when the user selects Paste or Paste as plain + text, emits a non-coalesced direct event if it finds a supported first item, + then invokes the underlying text input's plain-text paste action. It has no + pasteboard availability cache or background refresh. ## Platform behavior @@ -39,21 +46,39 @@ availability. The paste handler checks the actual clipboard content when used. While the cache is uninitialized or a refresh is outstanding, Paste remains available. Once a refresh completes with no image, normal React Native Paste gating applies. -- Android keeps its existing paste watcher behavior. This iOS cache does not - change Android or Web paste behavior. +- Android (package default minimum SDK 24) relies on the system for Paste menu + availability. + For a clipboard whose description includes `text/plain`, it reports the + first item's text. Otherwise, if the first item has a URI and the content + resolver returns a MIME type, it reports that MIME type and the URI string; + this can include images. It does not copy the URI content into a temporary + file. Both Paste menu actions continue through the native plain-text paste + path after the optional event. This iOS cache does not affect Android. ## Failure, fallback, and resource budget -The cache may briefly allow Paste when the clipboard contains no image. In -that case, the native paste action falls back to the text input's normal -behavior. If loading an image fails, the native paste fallback remains -available. Command validation performs atomic reads only; pasteboard XPC work -stays on the serial background queue. No image data is retained in the cache. +- iOS may briefly allow Paste when the clipboard contains no image. The native + paste action then falls back to the text input's normal behavior. If loading + an image fails, the native paste fallback remains available. Command + validation performs atomic reads only; pasteboard XPC work stays on the + serial background queue. No image data is retained in the cache. +- Android reports no `onPaste` event when the watcher is absent or the first + clipboard item has neither reportable text nor a URI with a resolved MIME + type. The native paste action still runs. It reads at most the first item + for the event on supported Android versions and does not own a copied image + file. No explicit clipboard byte limit is enforced here. ## Conformance and acceptance The iOS implementation is in `ios/OneKeyTextInputPasteObserver.mm`. Focused native tests cover image-only Paste while a refresh is blocked after first -focus and foreground activation. Simulator interaction should also confirm -that Paste appears and emits the image event in both cases; that interaction -has not yet been verified. +focus and foreground activation. Simulator interaction should confirm that +Paste appears and emits the image event in both cases. + +The Android implementation is in `android/src/main/java/com/textinput/` +(`TextInputView.kt`, `TextInputViewManager.kt`, and +`TextInputPasteEvent.kt`). No focused Android paste test exists yet. Device +acceptance should check text and image-URI clipboard items with `onPaste` +enabled and disabled, missing URI/MIME fallback, and that the underlying +plain-text paste action still runs. Neither platform's interaction cases have +been runtime verified for this change.