EV-WebKit

 view release on metacpan or  search on metacpan

t/99-fingerprint.t  view on Meta::CPAN

}

# --- full core coverage + the sparse rule ---
{
    my $b = EV::WebKit->new(window => [200,150], fingerprint => 'macos-safari');  # omits deviceMemory
    $b->mock_scheme('fp2', sub { ('<html><body>fp2</body></html>','text/html') });
    my %g;
    my $read = sub {
        my ($key, $js, $next) = @_;
        $b->script($js, sub { $g{$key} = $_[0]; $next->() });
    };
    $b->go('fp2://host/p', sub {
        $read->('vendor', 'return navigator.vendor', sub {
        $read->('langs',  'return JSON.stringify(navigator.languages)', sub {
        $read->('cores',  'return navigator.hardwareConcurrency', sub {
        $read->('touch',  'return navigator.maxTouchPoints', sub {
        $read->('devmem', 'return navigator.deviceMemory === undefined ? "UNDEF" : navigator.deviceMemory', sub {
        $read->('sw',     'return screen.width', sub {
        $read->('dpr',    'return window.devicePixelRatio', sub {
        $read->('lnative','return Object.getOwnPropertyDescriptor(Navigator.prototype,"languages").get.toString()', sub {
            EV::break;
        }); }); }); }); }); }); }); });
    });
    TWK::run_with_timeout(25);
    is($g{vendor}, 'Apple Computer, Inc.',         'navigator.vendor spoofed');
    is($g{langs},  '["en-US","en"]',               'navigator.languages spoofed (array)');
    is($g{cores},  10,                             'navigator.hardwareConcurrency spoofed');
    is($g{touch},  0,                              'navigator.maxTouchPoints spoofed');
    is($g{devmem}, 'UNDEF',                        'deviceMemory absent (sparse rule: macos-safari omits it)');
    is($g{sw},     1512,                           'screen.width spoofed');
    is($g{dpr},    2,                              'window.devicePixelRatio spoofed');
    like($g{lnative}, qr/\[native code\]/,         'the languages getter is native too');
    $b->quit;
}

# --- overrides reach the browser end-to-end. These values differ from BOTH the
# base preset AND real WebKitGTK, so (unlike the macos coincidences above) they
# genuinely discriminate, and they exercise the resolve->gvariant->C-getter path
# for an ADDED field (deviceMemory onto a Safari base). ---
{
    my $b = EV::WebKit->new(window => [200,150], fingerprint => {
        profile => 'macos-safari', vendor => 'Google Inc.', maxTouchPoints => 9, deviceMemory => 16,
    });
    $b->mock_scheme('ov', sub { ('<html><body>ov</body></html>','text/html') });
    my %g;
    $b->go('ov://host/p', sub {
        $b->script('return JSON.stringify({vendor:navigator.vendor, touch:navigator.maxTouchPoints, mem:navigator.deviceMemory})',
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    is($r->{vendor}, 'Google Inc.', 'override reaches the browser (vendor, discriminating: preset is Apple)');
    is($r->{touch},  9,             'override reaches the browser (maxTouchPoints=9, discriminating)');
    is($r->{mem},    16,            'override ADDS a preset-omitted field: deviceMemory reaches navigator');
    $b->quit;
}

# --- negative control: a browser WITHOUT fingerprint reports the REAL platform.
# Guards against the process-global extension leaking into a non-fp instance. ---
#
# It also samples this HOST's pointer media. A desktop profile deliberately does
# NOT override pointer/hover, so those queries fall through to the engine -- which
# on a touchscreen machine legitimately answers (pointer: coarse). Hardcoding
# "fine matches" would therefore assert a property of the test machine, not of the
# spoof: green here, red there, and either way it never pinned "no flip". The
# desktop assertions below compare against these control values instead.
my ($HOST_FINE, $HOST_COARSE) = (1, 0);   # fallback if the control cannot be read
{
    my $b = EV::WebKit->new(window => [200,150]);
    $b->mock_scheme('real', sub { ('<html><body>real</body></html>','text/html') });
    my %g;
    $b->go('real://host/p', sub {
        $b->script('return JSON.stringify({platform:navigator.platform, chrome:typeof window.chrome, uaData:typeof navigator.userAgentData, mmNative:window.matchMedia.toString().includes("[native code]"), ptrFine:matchMedia("(pointer: fine)").matches...
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    unlike($r->{platform} // '', qr/Win32|MacIntel|iPhone/, "a non-fingerprint browser reports the real platform ($r->{platform}) -- no spoof leakage");
    is($r->{chrome}, 'undefined', 'no window.chrome leaked into a non-fingerprint browser');
    is($r->{uaData}, 'undefined', 'no userAgentData leaked into a non-fingerprint browser');
    ok($r->{mmNative}, 'matchMedia is the real native one (not wrapped) in a non-fingerprint browser');
    ($HOST_FINE, $HOST_COARSE) = ($r->{ptrFine} ? 1 : 0, $r->{ptrCoarse} ? 1 : 0)
        if exists $r->{ptrFine};
    $b->quit;
}

# --- navigator.languages. Real browsers expose it as a FrozenArray, so the SAME
# frozen array comes back on every read; the GStrv marshalling here builds a
# fresh mutable one, and identity/frozenness are a KNOWN, documented gap (see
# the Ceiling POD -- closing it leaked a JSCContext per navigation). The
# identity/frozen assertions below deliberately pin the gap rather than the
# ideal: if a future WebKitGTK or a future fix changes it, this test says so
# instead of silently agreeing. ---
{
    my $b = EV::WebKit->new(window => [200,150], fingerprint => {
        profile => 'windows-chrome', languages => ['en-US','en','x"y\\z'] });
    $b->mock_scheme('lg', sub { ('<html><body>lg</body></html>','text/html') });
    my %g;
    $b->go('lg://host/p', sub {
        $b->script(<<'JS', sub { $g{r} = $_[0]; EV::break });
          const d = Object.getOwnPropertyDescriptor(Navigator.prototype,'languages');
          const v = navigator.languages;
          return JSON.stringify({
            isArray:   Array.isArray(v),
            json:      JSON.stringify(v),
            identical: navigator.languages === navigator.languages,
            frozen:    Object.isFrozen(v),
            lenAfter:  navigator.languages.length,
            native:    !!(d && d.get && d.get.toString().includes('[native code]')),
            language:  navigator.language,
          });
JS
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    ok($r->{isArray},   'navigator.languages is a real Array');
    is($r->{json}, '["en-US","en","x\\"y\\\\z"]',
       'navigator.languages carries the profile tags verbatim, quote/backslash intact');
    is($r->{lenAfter},  3, 'it has the profile length');
    ok($r->{native},    'the languages getter is NATIVE (not a JS wrapper)');
    is($r->{language}, 'en-US', 'navigator.language agrees with languages[0]');
    # the documented gap, pinned so a change surfaces as a test failure
    ok(!$r->{identical}, 'KNOWN GAP: languages !== languages (real browsers cache one FrozenArray)');
    ok(!$r->{frozen},    'KNOWN GAP: the returned array is not frozen (real browsers freeze it)');
    $b->quit;
}

# --- WebGL getParameter: spoof GPU strings (JS wrapper), delegate everything else ---
{
    my $b = EV::WebKit->new(window => [200,150], fingerprint => 'windows-chrome');
    $b->mock_scheme('fp3', sub { ('<html><body><canvas id=c></canvas></body></html>','text/html') });
    my %g;
    my $js = <<'JS';
      const gl = document.getElementById('c').getContext('webgl');
      if (!gl) return JSON.stringify({ no_gl: true });
      const ext = gl.getExtension('WEBGL_debug_renderer_info');
      return JSON.stringify({
        renderer: gl.getParameter(ext.UNMASKED_RENDERER_WEBGL),
        vendor:   gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
        real_ver: typeof gl.getParameter(gl.VERSION),           // delegation still works
        ownTS:    Object.prototype.hasOwnProperty.call(gl.getParameter, 'toString'),
        ownKeys:  Object.keys(gl.getParameter).length,
        leaked:   ('__evwk_wv' in window) || ('__evwk_wr' in window),   // temp globals must be gone
      });
JS
    $b->go('fp3://host/p', sub { $b->script($js, sub { $g{r} = $_[0]; EV::break }) });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    SKIP: {
        skip 'no WebGL context in this headless GL environment', 6 if $r->{no_gl};
        like($r->{renderer}, qr/RTX 3060/,   'WebGL UNMASKED_RENDERER spoofed');
        like($r->{vendor},   qr/NVIDIA/,      'WebGL UNMASKED_VENDOR spoofed');
        is($r->{real_ver},   'string',        'a non-spoofed getParameter still delegates (returns the real VERSION string)');
        # The wrapper deliberately carries NO own toString. Masking it defeated
        # only a naive fn.toString() check -- Function.prototype.toString.call
        # bypasses an own property and reveals the wrapper regardless -- while
        # handing over a zero-false-positive enumeration: Object.keys() returned
        # ['toString'] on precisely the wrapped methods and on no real one.
        ok(!$r->{ownTS},                      'getParameter carries no own toString (no enumerable-artifact tell)');
        is($r->{ownKeys}, 0,                  'getParameter has no own enumerable keys, like every real method');
        ok(!$r->{leaked},                     'the temporary __evwk_wv/__evwk_wr globals were deleted (no injection tell)');
    }
    $b->quit;
}

# --- stage 2: Chrome coherence (window.chrome + navigator.userAgentData) ---
{
    my $b = EV::WebKit->new(fingerprint => 'windows-chrome');
    $b->mock_scheme('ch', sub { ('<html><body>ch</body></html>','text/html') });
    my %g;
    $b->go('ch://host/p', sub {
        $b->script(<<'JS', sub { $g{r} = $_[0]; EV::break });
          const u = navigator.userAgentData;
          // request ONLY architecture -- a hints-ignoring impl would also return platformVersion
          return u.getHighEntropyValues(['architecture']).then(h => JSON.stringify({
            chrome:      typeof window.chrome,
            chrome_app:  typeof window.chrome.app,        // real Chrome page has app/csi/loadTimes
            chrome_csi:  typeof window.chrome.csi,
            chrome_load: typeof window.chrome.loadTimes,
            chrome_rt:   ('runtime' in window.chrome),    // must be false (empty runtime is a tell)
            brands:      u.brands.map(b => b.brand).join(','),
            uaPlatform:  u.platform,
            uaMobile:    u.mobile,
            hevArch:     h.architecture,
            hevHasPV:    ('platformVersion' in h),        // NOT requested -> must be absent
            ghevProto:   ('prototype' in u.getHighEntropyValues),  // native-method-like: no .prototype
            desktopPtr:  matchMedia('(pointer: fine)').matches,    // desktop chrome: media unchanged
            desktopPtrC: matchMedia('(pointer: coarse)').matches,  // ... in both directions
          }));
JS
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    is($r->{chrome},     'object',   'window.chrome present for a Chrome profile');
    is($r->{chrome_app}, 'object',   'window.chrome.app present (real-Chrome shape)');
    is($r->{chrome_csi}, 'function', 'window.chrome.csi present');
    is($r->{chrome_load},'function', 'window.chrome.loadTimes present');
    ok(!$r->{chrome_rt},             'window.chrome has no bare runtime:{} (the puppeteer-stealth tell)');
    # Order AND the GREASE entry are curl-impersonate's chrome150 template, not
    # a constant: 131 sent "Not_A Brand";v="24" last, 150 sends
    # "Not;A=Brand";v="8" first.
    is($r->{brands}, 'Not;A=Brand,Chromium,Google Chrome',
       'userAgentData.brands order matches real Chrome 150 (same source as the wire sec-ch-ua)');
    is($r->{uaPlatform}, 'Windows',  'userAgentData.platform matches the profile');
    ok(!$r->{uaMobile},              'userAgentData.mobile false for a desktop profile');
    is($r->{hevArch}, 'x86',         'getHighEntropyValues resolves the requested architecture');
    ok(!$r->{hevHasPV},              'getHighEntropyValues RESPECTS hints (unrequested platformVersion absent)');
    ok(!$r->{ghevProto},             'getHighEntropyValues has no .prototype (native-method-like)');
    is($r->{desktopPtr}  ? 1 : 0, $HOST_FINE,
       'a desktop Chrome profile leaves (pointer: fine) exactly as the engine answers it');
    is($r->{desktopPtrC} ? 1 : 0, $HOST_COARSE,
       'a desktop Chrome profile does NOT flip pointer media to coarse');
    $b->quit;
}

# --- stage 2: mobile coherence (iphone-safari): geometry, touch, media, and
# correctly NO chrome/userAgentData (it is Safari) ---
{
    my $b = EV::WebKit->new(fingerprint => 'iphone-safari');
    $b->mock_scheme('mob', sub { ('<html><body>mob</body></html>','text/html') });
    my %g;
    $b->go('mob://host/p', sub {
        $b->script(<<'JS', sub { $g{r} = $_[0]; EV::break });
          const mq = matchMedia('(pointer: coarse)');
          return JSON.stringify({
            innerLEscreen: window.innerWidth <= screen.width,   // the geometric-impossibility fix
            innerW:    window.innerWidth, screenW: screen.width,
            ontouch:   ('ontouchstart' in window),
            maxTouch:  navigator.maxTouchPoints,
            coarse:    mq.matches,
            coarse_ns: matchMedia('(pointer:coarse)').matches,   // no space -> normalization
            coarse_uc: matchMedia('(POINTER: COARSE)').matches,  // case -> normalization
            fine:      matchMedia('(pointer: fine)').matches,
            hoverNone: matchMedia('(hover: none)').matches,
            dppx3:     matchMedia('(min-resolution: 3dppx)').matches,   // range query, from dpr
            isMQL:     (mq instanceof MediaQueryList),          // real MediaQueryList shape
            ownMatches:mq.hasOwnProperty('matches'),            // matches is on the prototype (own=false)
            orient:    window.orientation,
            delegated: matchMedia('(min-width: 1px)').matches,   // non-overridden query still delegates
            uaData:    typeof navigator.userAgentData,
            chrome:    typeof window.chrome,
          });
JS
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    ok($r->{innerLEscreen}, "mobile geometry coherent: innerWidth ($r->{innerW}) <= screen.width ($r->{screenW})");
    ok($r->{ontouch},    'ontouchstart present on a mobile profile');
    is($r->{maxTouch}, 5, 'maxTouchPoints spoofed');
    ok($r->{coarse},     'pointer:coarse matches on mobile');
    ok(!$r->{fine},      'pointer:fine does not match on mobile');
    ok($r->{hoverNone},  'hover:none matches on mobile');
    ok($r->{dppx3},      'min-resolution:3dppx matches (a RANGE query derived from devicePixelRatio)');
    ok($r->{coarse_ns},  'matchMedia normalizes whitespace ((pointer:coarse) with no space matches)');
    ok($r->{coarse_uc},  'matchMedia normalizes case ((POINTER: COARSE) matches)');
    ok($r->{isMQL},      'the override returns a real MediaQueryList (instanceof)');
    ok(!$r->{ownMatches},'.matches is on the prototype, not an own property (correct shape)');
    is($r->{orient}, 0,  'window.orientation present (0) on a mobile profile');
    ok($r->{delegated},  'a non-overridden matchMedia query still delegates to the real one');
    is($r->{uaData}, 'undefined', 'iphone-safari has NO userAgentData (Safari -- correct)');
    is($r->{chrome}, 'undefined', 'iphone-safari has NO window.chrome (Safari -- correct)');
    $b->quit;
}

# --- stage 2: a non-mobile Retina profile (macos-safari, dpr 2) must make its
# resolution media queries agree with the spoofed devicePixelRatio ---
{
    my $b = EV::WebKit->new(fingerprint => 'macos-safari');
    $b->mock_scheme('ret', sub { ('<html><body>ret</body></html>','text/html') });
    my %g;
    $b->go('ret://host/p', sub {
        $b->script('return JSON.stringify({ dpr:window.devicePixelRatio, minRes:matchMedia("(min-resolution: 2dppx)").matches, exact:matchMedia("(resolution:2dppx)").matches, maxRes1:matchMedia("(max-resolution: 1dppx)").matches, coarse:matchMedia("(...
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    is($r->{dpr}, 2,      'macos-safari devicePixelRatio 2');
    ok($r->{minRes},      'min-resolution:2dppx matches dpr=2 (resolution coherence on a NON-mobile Retina profile)');
    ok($r->{exact},       'exact resolution:2dppx matches');
    ok(!$r->{maxRes1},    'max-resolution:1dppx does NOT match dpr=2');
    is($r->{coarse} ? 1 : 0, $HOST_COARSE,
       'a desktop Retina profile does not touch pointer media (matches the unspoofed control)');
    $b->quit;
}

# --- stage 2: pixel-chrome is BOTH mobile AND Chrome -- exercise the combination ---
{
    my $b = EV::WebKit->new(fingerprint => 'pixel-chrome');
    $b->mock_scheme('px', sub { ('<html><body>px</body></html>','text/html') });
    my %g;
    $b->go('px://host/p', sub {
        $b->script('return JSON.stringify({ platform:navigator.platform, uaMobile:navigator.userAgentData.mobile, uaPlatform:navigator.userAgentData.platform, chrome:typeof window.chrome, ontouch:("ontouchstart" in window), coarse:matchMedia("(pointe...
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    ok($r->{uaMobile},              'pixel-chrome userAgentData.mobile is true (mobile Chrome)');
    is($r->{uaPlatform}, 'Android', 'pixel-chrome userAgentData.platform Android');
    is($r->{chrome}, 'object',      'pixel-chrome has window.chrome (it is Chrome)');
    ok($r->{ontouch},               'pixel-chrome has touch (it is mobile)');
    ok($r->{coarse},                'pixel-chrome pointer:coarse (mobile)');
    ok($r->{innerLE},               'pixel-chrome geometry coherent');
    $b->quit;
}

# --- stage 2: desktop geometry -- a large window is capped to the spoofed screen
# so window.innerWidth never exceeds screen.width (the desktop impossibility) ---
{
    my $b = EV::WebKit->new(window => [3000,2000], fingerprint => 'windows-chrome');  # window > screen (1920x1080)
    $b->mock_scheme('dg', sub { ('<html><body>dg</body></html>','text/html') });
    my %g;
    $b->go('dg://host/p', sub {
        $b->script('return JSON.stringify({ innerLEw:window.innerWidth<=screen.width, innerLEh:window.innerHeight<=screen.height, innerW:window.innerWidth, screenW:screen.width })',
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    ok($r->{innerLEw}, "desktop window capped to screen: innerWidth ($r->{innerW}) <= screen.width ($r->{screenW})");
    ok($r->{innerLEh}, 'desktop window height capped to screen too');
    $b->quit;
}

# every preset loads and reports its own platform natively (coherence smoke).
for my $name (EV::WebKit::Fingerprint::profiles()) {
    my $want = EV::WebKit::Fingerprint::resolve($name)->{platform};
    my $b = EV::WebKit->new(window => [200,150], fingerprint => $name);
    $b->mock_scheme('sm', sub { ('<html><body>x</body></html>','text/html') });
    my $got;
    $b->go('sm://host/p', sub { $b->script('return navigator.platform', sub { $got = $_[0]; EV::break }) });
    TWK::run_with_timeout(20);
    is($got, $want, "preset '$name' applies its platform ($want)");
    $b->quit;
}

# --- seed plumbing (pure Perl) ---
my $gv_seed = EV::WebKit::Fingerprint::gvariant(
    EV::WebKit::Fingerprint::resolve('windows-chrome'), 12345);
like($gv_seed->print(1), qr/'seed'/, 'gvariant carries seed when given');
my $gv_noseed = EV::WebKit::Fingerprint::gvariant(
    EV::WebKit::Fingerprint::resolve('windows-chrome'));
unlike($gv_noseed->print(1), qr/'seed'/, 'gvariant omits seed when not given');

eval { EV::WebKit->new(window => [200,150], fingerprint => 'windows-chrome', seed => -1) };
like($@, qr/seed must be a non-negative integer/, 'negative seed croaks');
eval { EV::WebKit->new(window => [200,150], fingerprint => 'windows-chrome', seed => 'x') };
like($@, qr/seed must be a non-negative integer/, 'non-integer seed croaks');
eval { EV::WebKit->new(window => [200,150], seed => 5) };
like($@, qr/seed requires fingerprint/, 'seed without fingerprint croaks');

# The seed is reduced to 32 bits so the extension's cast to guint32 is defined,
# and the reduction must give the SAME residue on every perl. Done with `%` on
# the whole number it would not: `%` is exact only while the operand fits a UV,
# so a seed above 2**53 -- exactly what a digest-derived seed is, the case the
# reduction exists for -- numifies first on a perl without 64-bit integers and
# lands on a different value than on a 64-bit one. Reducing digit by digit keeps
# every intermediate under 2**36 (worst case 4294967295*10+9), exact in an NV.
# Note honestly what these do and do not cover: on a perl WITH 64-bit integers
# (this one) `%= 2**32` gives the same answers, so no assertion here can tell the
# two implementations apart. They pin the intended values against the real
# function -- not a copy of it -- so a 32-bit tester diverging shows up as a
# failure there rather than as silently different noise.
{
    is(EV::WebKit::_reduce32('9007199254740993'), 1, 'a seed above 2**53 reduces exactly (2**53+1 -> 1)');
    is(EV::WebKit::_reduce32('4294967296'), 0,       '2**32 reduces to 0');
    is(EV::WebKit::_reduce32('4294967297'), 1,       '2**32+1 reduces to 1');
    is(EV::WebKit::_reduce32('12345'), 12345,        'a small seed is unchanged');
    # and the constructor really uses it: a huge seed must be accepted and
    # reduced, not rejected or passed through
    my $big = eval { EV::WebKit->new(window => [200,150], fingerprint => 'windows-chrome',
                                     seed => '9007199254740993') };
    is($@, '', 'a >2**53 seed is accepted by the constructor') or diag $@;
    $big->quit if $big;
}

# --- webgl capability data (pure Perl) ---
for my $name (EV::WebKit::Fingerprint::profiles()) {
    my $p = EV::WebKit::Fingerprint::resolve($name);
    is(ref $p->{webgl}, 'HASH', "$name has a webgl block");
    is($p->{webgl}{params1}{3379}, 16384, "$name MAX_TEXTURE_SIZE = 16384");
    is(ref $p->{webgl}{extensions1}, 'ARRAY', "$name has a WebGL1 extension list");
    ok(scalar(@{$p->{webgl}{extensions2}}) > 0, "$name has a WebGL2 extension list");
    ok((grep { $_ eq 'WEBGL_debug_renderer_info' } @{$p->{webgl}{extensions1}}),
       "$name advertises WEBGL_debug_renderer_info");
}
# folded into the coherence JSON
my $coh = EV::WebKit::Fingerprint::gvariant(EV::WebKit::Fingerprint::resolve('windows-chrome'))->print(1);
# NB: match a key unique to the webgl BLOCK. A bare /webgl/ also matches the
# long-standing top-level 'webgl_vendor'/'webgl_renderer' keys, so it stayed
# green even with the whole block removed.
like($coh, qr/params1/,     'coherence JSON carries the webgl params block');
like($coh, qr/extensions1/, 'coherence JSON carries the webgl extension list');
# validator: a non-hash webgl override croaks
eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', webgl => [1] }) };
like($@, qr/webgl.*must be a hashref/, 'non-hash webgl override croaks');

# --- feature-presence data (pure Perl) ---
my %want = (
    'windows-chrome' => [qw(connection storage battery usb bluetooth hid serial scheduling rtc)],
    'pixel-chrome'   => [qw(connection storage battery usb bluetooth scheduling rtc)],
    'macos-safari'   => [qw(storage rtc)],
    'iphone-safari'  => [qw(storage rtc)],
);
for my $name (sort keys %want) {
    my $p = EV::WebKit::Fingerprint::resolve($name);
    is_deeply($p->{features}, $want{$name}, "$name has the expected feature list");
}
ok(!grep({ $_ eq 'usb' } @{EV::WebKit::Fingerprint::resolve('macos-safari')->{features}}),
   'Safari does not advertise WebUSB');
ok(!grep({ $_ eq 'serial' } @{EV::WebKit::Fingerprint::resolve('pixel-chrome')->{features}}),
   'Android Chrome does not advertise Web Serial');
my $cj = EV::WebKit::Fingerprint::gvariant(EV::WebKit::Fingerprint::resolve('windows-chrome'))->print(1);
like($cj, qr/features/, 'coherence JSON carries the features list');
eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', features => 'x' }) };
like($@, qr/features.*arrayref/, 'non-arrayref features override croaks');

eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', features => ['webusb'] }) };
like($@, qr/unknown features group/, 'a mistyped features group croaks (never a silent no-op stub)');

# --- screen.orientation.type follows the spoofed SCREEN, not the mobile flag.
# Keyed off `mobile` it contradicted the profile's own screen.width/height on any
# portrait desktop or landscape tablet -- a two-property probe. ---
for my $case (
    [ 'a landscape screen'          => [1920,1080], 0, 'landscape-primary' ],
    [ 'a portrait screen'           => [1080,1920], 0, 'portrait-primary'  ],
    [ 'a portrait MOBILE screen'    => [ 390, 844], 1, 'portrait-primary'  ],
    # the case the old mobile-keyed rule got backwards in both directions
    [ 'a LANDSCAPE mobile screen'   => [ 844, 390], 1, 'landscape-primary' ],
    [ 'a PORTRAIT desktop screen'   => [1200,1600], 0, 'portrait-primary'  ],
    [ 'a square screen'             => [1000,1000], 0, 'landscape-primary' ],
) {
    my ($what, $screen, $mobile, $want) = @$case;
    my $p = EV::WebKit::Fingerprint::resolve({
        profile => $mobile ? 'iphone-safari' : 'windows-chrome', screen => $screen });
    my $c = EV::WebKit::Fingerprint::_coherence($p);
    is($c->{orientation}{type}, $want, "$what ($screen->[0]x$screen->[1]) reports $want");
}

# --- an EMPTY profile must produce NO coherence blob at all -----------------
# The extension is loaded for every browser, and new() promises it then "defines
# nothing, leaving a plain instance exactly as it was". Every native-getter
# field is gated individually, but the orientation entry deliberately does not
# key off `mobile` -- so an empty profile still emitted a blob, and the
# extension gates both COHERENCE_JS and the WebGL getParameter wrapper on that
# blob merely existing. Measured against the extension forced off, a default
# browser gained window.ScreenOrientation and lost getParameter's [native code].
#
# Asserted here rather than in the browser because it is engine-independent: a
# future WebKitGTK that ships ScreenOrientation natively would make the DOM-side
# absence check stale, but this stays exact.
is(EV::WebKit::Fingerprint::_coherence({}), undef,
   'an empty profile produces no coherence blob');
is(EV::WebKit::Fingerprint::_coherence(undef), undef,
   '...and neither does no profile at all');
{
    my $v = EV::WebKit::Fingerprint::gvariant(undef, undef, { boot => Glib::Variant->new('s', 'x') });
    my %keys;
    for my $i (0 .. $v->n_children - 1) {
        my $pair = $v->get_child_value($i);
        $keys{ $pair->get_child_value(0)->get_string } = 1;
    }
    ok(!$keys{coherence}, 'a plain instance carries no coherence key into the extension');
    ok($keys{boot}, '...while the caller-supplied entries still get through');
}

# --- round-3: pin the capability-table invariants in pure Perl ---
# The live t/A1 checks compare browser output against the same %PRESET data, and
# the real driver satisfies the ES3 identities too, so they pass even with the
# spoof removed. These assert the DATA directly.
for my $name (EV::WebKit::Fingerprint::profiles()) {
    my $w = EV::WebKit::Fingerprint::resolve($name)->{webgl};
    my ($p1, $p2) = ($w->{params1}, $w->{params2});
    is($p2->{35658}, $p1->{36347}*4, "$name MAX_VERTEX_UNIFORM_COMPONENTS == VECTORS*4");
    is($p2->{35657}, $p1->{36349}*4, "$name MAX_FRAGMENT_UNIFORM_COMPONENTS == VECTORS*4");
    is($p2->{35659}, $p1->{36348}*4, "$name MAX_VARYING_COMPONENTS == VARYING_VECTORS*4");
    cmp_ok($p2->{35374}, '>=', $p2->{35371} + $p2->{35373},
           "$name combined uniform blocks >= vertex+fragment");
    cmp_ok($p2->{35375}, '>=', $p2->{35374}, "$name uniform buffer bindings >= combined blocks");
    cmp_ok($p1->{35661}, '>=', $p1->{35660} + $p1->{34930},
           "$name combined texture image units >= vertex+fragment");
    cmp_ok($p2->{37157}, '>=', $p2->{35659}, "$name fragment input components >= varying components");
    # every shader/precision combination must be present or it falls through to the host
    is(scalar(keys %{$w->{precision}}), 12, "$name precision table covers all 12 combinations");
    # An fp16 mediump/lowp float pairs with a 16-bit int range; fp32 pairs with
    # 32-bit. Mixing them is a contradiction, and reverting either half of that
    # sweep previously left the suite green.
    my $f = $w->{precision}{'FRAGMENT.MEDIUM_FLOAT'};
    my $i = $w->{precision}{'FRAGMENT.MEDIUM_INT'};
    if ($f->[2] == 10) { is_deeply($i, [15,14,0], "$name fp16 mediump pairs with a 16-bit int range") }

t/99-fingerprint.t  view on Meta::CPAN

    my $base = EV::WebKit::Fingerprint::resolve('windows-chrome')->{webgl};
    eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', webgl => { %$base, bogus => 1 } }) };
    like($@, qr/unknown webgl key/, 'an unknown webgl key croaks');
    my %missing = %$base; delete $missing{precision};
    eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', webgl => \%missing }) };
    like($@, qr/webgl\.precision is required/, 'a missing webgl sub-key croaks');
    eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', webgl => { %$base, params1 => 'x' } }) };
    like($@, qr/webgl\.params1 must be a hashref/, 'a wrong-typed webgl sub-key croaks');
    eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', webgl => {} }) };
    like($@, qr/webgl\./, 'an empty webgl block croaks rather than silently disabling the spoof');
}

# --- round-3: resolve() must hand back a private deep copy ---
{
    my $a = EV::WebKit::Fingerprint::resolve('macos-safari');
    $a->{webgl}{params1}{3379} = 4096;
    $a->{languages}[0] = 'zz-ZZ';
    my $b = EV::WebKit::Fingerprint::resolve('iphone-safari');   # shares the same tables
    is($b->{webgl}{params1}{3379}, 16384, 'editing one resolved profile does not re-fingerprint its sibling');
    my $c = EV::WebKit::Fingerprint::resolve('macos-safari');
    is($c->{languages}[0], 'en-US', 'nested arrayrefs are copied too, not shared');
    # NESTED under a KNOWN key: `webgl => $cyc` croaks at the unknown-webgl-key
    # check, which runs before _clone -- so it never reached the depth guard at
    # all, and deleting that guard left this green.
    # The cycle has to be reachable by _clone: a bare `webgl => $cyc` croaks at
    # the unknown-webgl-key check and `{ params1 => $cyc }` at the required-key
    # check, both of which run first -- so neither ever reached the depth guard,
    # and deleting that guard left this green.
    my $cyc = {}; $cyc->{self} = $cyc;
    my $base = EV::WebKit::Fingerprint::resolve('windows-chrome')->{webgl};
    eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome',
                                             webgl => { %$base, params1 => $cyc } }) };
    like($@, qr/nested too deeply/, 'cyclic override data croaks instead of exhausting the stack');
}

# Both of these are documented: fingerprint may be a hashref with a `profile`
# base, and network_fingerprint => 1 derives the curl target from the profile.
# Together they croaked, because the raw hashref was handed to curl_target and
# looked up its stringified address.
SKIP: {
    skip 'Proxy::Impersonate not installed', 3
        unless eval { require Proxy::Impersonate; 1 };
    my $h = eval {
        EV::WebKit->new(window => [100,80], ephemeral => 1, timeout => 5,
                        fingerprint => { profile => 'windows-chrome', hardwareConcurrency => 4 },
                        network_fingerprint => 1);
    };
    ok($h, 'a hashref profile plus network_fingerprint => 1 constructs') or diag $@;
    is($h && $h->network_fingerprint, 'chrome150',
       '...deriving the curl target from the profile inside the hashref');
    $h->quit if $h;

    my $s = eval {
        EV::WebKit->new(window => [100,80], ephemeral => 1, timeout => 5,
                        fingerprint => 'windows-chrome', network_fingerprint => 1);
    };
    is($s && $s->network_fingerprint, 'chrome150', '...same target as the string form');
    $s->quit if $s;

    # on_request ALONE takes the same derivation, and this branch is the
    # dangerous one: it does not croak on a miss, it falls back to desktop
    # Chrome. So a hashref profile used to present a Safari (or Android Chrome)
    # identity in JS and headers over a Chrome JA3/JA4 -- silently, and
    # network_fingerprint() reported 'chrome131' as if that were the default.
    for my $case (['macos-safari', 'safari26_0'], ['pixel-chrome', 'chrome131_android']) {
        my ($preset, $want) = @$case;
        my %made;
        for my $form (['string',  $preset],
                      ['hashref', { profile => $preset }]) {
            my ($name, $fp) = @$form;
            my $r = eval {
                EV::WebKit->new(window => [100,80], ephemeral => 1, timeout => 5,
                                fingerprint => $fp, on_request => sub { undef });
            };
            ok($r, "on_request alone with a $name $preset fingerprint constructs") or diag $@;
            $made{$name} = $r && $r->network_fingerprint;
            $r->quit if $r;
        }
        is($made{hashref}, $want,
           "on_request alone: a hashref $preset derives $want, not a silent chrome150");
        is($made{hashref}, $made{string}, '...the same target the string form gets');
    }
}

done_testing;



( run in 1.669 second using v1.01-cache-2.11-cpan-b16cb0d3907 )