From 63667a5e7feea52af05cff56599975ab21438261 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:02:34 +0100 Subject: [PATCH 1/3] Bound video reports and require shadow DOM tests --- .github/workflows/check.yml | 2 + background.js | 2 + test/video-shadow.test.js | 12 +--- test/video.test.js | 43 +++++++++++++ video-menu.js | 121 +++++++++++++++++++++++++++++------- 5 files changed, 149 insertions(+), 31 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9e18383..b411677 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -12,6 +12,8 @@ jobs: run: find . -type f -name '*.js' -not -path './.git/*' -print0 | xargs -0 -n1 node --check - name: firefox manifest in sync run: python3 build-aux/gen-firefox-manifest.py --check + - name: Install shadow-root test dependency + run: npm install --no-save --package-lock=false --ignore-scripts jsdom@30.1.3 - name: tests # Explicit globs: `node --test test/` fails on Node 22. run: node --test test/*.test.js diff --git a/background.js b/background.js index 1640858..bd35546 100644 --- a/background.js +++ b/background.js @@ -155,6 +155,8 @@ if (typeof module !== "undefined" && module.exports) { MAX_GRAB_URL_BYTES, MAX_HANDOFF_BODY_BYTES, MAX_VIDEO_ITEMS, + MAX_VIDEO_FRAMES, + MAX_VIDEO_TITLE_LENGTH, manifestMenuRows, directMenuRow, manifestKind, diff --git a/test/video-shadow.test.js b/test/video-shadow.test.js index dda6126..5eb4331 100644 --- a/test/video-shadow.test.js +++ b/test/video-shadow.test.js @@ -1,20 +1,14 @@ // Shadow-root video detection tests (jsdom). -// Run with: node --test test/video-shadow.test.js -// Requires the jsdom package (npm install jsdom); skipped if unavailable. +// CI installs a pinned jsdom version before running this suite. const { test, describe } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const CONTENT_PATH = path.join(__dirname, "..", "content.js"); -let JSDOM; -try { - ({ JSDOM } = require("jsdom")); -} catch { - JSDOM = null; -} +const { JSDOM } = require("jsdom"); -describe("shadow-root video detection (jsdom)", { skip: !JSDOM }, () => { +describe("shadow-root video detection (jsdom)", () => { function loadContent(dom) { const messages = []; diff --git a/test/video.test.js b/test/video.test.js index a5f7aad..272db28 100644 --- a/test/video.test.js +++ b/test/video.test.js @@ -721,6 +721,49 @@ describe("detection wiring", () => { assert.equal(saved.frames[0][1].length, 10, "all active videos survive worker persistence"); }); + test("video report intake caps retained frames, entries, and titles", async () => { + const chrome = makeChrome(); + const bg = loadBackground(chrome); + const L = chrome._listeners; + const tabId = 121; + await L["tabs.onActivated"]({ tabId }); + + await L["runtime.onMessage"]( + { + type: "grab-videos-detected", + videos: Array.from({ length: bg.MAX_VIDEO_ITEMS * 4 }, (_, i) => ({ + src: `https://cdn.example.com/v/${i}.mp4`, + blob: false, + })), + title: "x".repeat(bg.MAX_VIDEO_TITLE_LENGTH * 4), + }, + { tab: { id: tabId, url: "https://example.com/" }, frameId: 0 } + ); + + for (let frameId = 1; frameId < bg.MAX_VIDEO_FRAMES + 10; frameId++) { + await L["runtime.onMessage"]( + { + type: "grab-videos-detected", + videos: [{ src: `https://cdn.example.com/frame-${frameId}.mp4`, blob: false }], + }, + { tab: { id: tabId, url: "https://example.com/" }, frameId } + ); + } + + const findings = bg._videoFinds.get(tabId); + const total = [...findings.frames.values()].reduce((sum, videos) => sum + videos.length, 0); + assert.ok(findings.frames.size <= bg.MAX_VIDEO_FRAMES, "frame IDs stay bounded"); + assert.ok(total <= bg.MAX_VIDEO_ITEMS, "DOM video entries stay bounded per tab"); + assert.ok(findings.title.length <= bg.MAX_VIDEO_TITLE_LENGTH, "page titles stay bounded"); + + const saved = chrome._session.get("videoFinds:" + tabId); + assert.ok(saved.frames.length <= bg.MAX_VIDEO_FRAMES, "persisted frame IDs stay bounded"); + assert.ok( + saved.frames.reduce((sum, pair) => sum + pair[1].length, 0) <= bg.MAX_VIDEO_ITEMS, + "persisted DOM entries stay bounded" + ); + }); + test("a blob: video is paired with the tab's sniffed manifest", async () => { const chrome = makeChrome(); loadBackground(chrome); diff --git a/video-menu.js b/video-menu.js index dcb002c..5701776 100644 --- a/video-menu.js +++ b/video-menu.js @@ -318,6 +318,8 @@ function mergeVideoFinds(domVideos, sniffedUrls, kinds) { // sniffed: [url] }. Reports are tracked per frame because the content script // runs in all frames; last-writer-wins per tab would drop other frames' // videos whenever two frames report at different times. +const MAX_VIDEO_FRAMES = 32; +const MAX_VIDEO_TITLE_LENGTH = 512; const videoFinds = new Map(); /// Storage.session outlives an MV3 service-worker instance and is cleared when @@ -340,6 +342,30 @@ function enqueueVideoStateWrite(tabId, write) { return next; } +/// Normalize page-derived data before retaining it. Bound scanning as well as +/// retained results; a blob video counts as one item. +function normalizeVideoReport(rawVideos, maximum) { + const input = Array.isArray(rawVideos) ? rawVideos.slice(0, MAX_VIDEO_ITEMS) : []; + const out = []; + const seen = new Set(); + for (const video of input) { + if (out.length >= maximum) break; + if (!video || typeof video !== "object") continue; + const src = + typeof video.src === "string" && video.src && fitsGrabUrl(video.src) + ? video.src + : null; + if (!src && video.blob !== true) continue; + const key = src || "__grab_blob__"; + if (seen.has(key)) continue; + seen.add(key); + const item = { src, blob: video.blob === true }; + if (Number.isFinite(video.height) && video.height > 0) item.height = video.height; + out.push(item); + } + return out; +} + function snapshotVideoFinds(finds) { const frames = []; let remaining = MAX_VIDEO_ITEMS; @@ -371,7 +397,8 @@ function snapshotVideoFinds(finds) { frames, sniffed: (finds.sniffed || []).filter((url) => fitsGrabUrl(url)).slice(-50), kinds: [...(finds.kinds || new Map()).entries()].slice(-200), - title: typeof finds.title === "string" ? finds.title : "", + title: + typeof finds.title === "string" ? finds.title.slice(0, MAX_VIDEO_TITLE_LENGTH) : "", }; } @@ -393,14 +420,38 @@ async function restoreVideoFinds(tabId) { const saved = result && result[key]; if (saved && typeof saved === "object") { const frames = new Map(); - for (const pair of Array.isArray(saved.frames) ? saved.frames : []) { - if (!Array.isArray(pair) || pair.length !== 2 || !Number.isInteger(pair[0]) || !Array.isArray(pair[1])) continue; - const videos = pair[1].filter((video) => video && typeof video === "object" && (video.src == null || fitsGrabUrl(video.src))).map((video) => { - const item = { src: typeof video.src === "string" ? video.src : null, blob: video.blob === true }; + let remaining = MAX_VIDEO_ITEMS; + const savedFrames = (Array.isArray(saved.frames) ? saved.frames : []) + .filter((pair) => + Array.isArray(pair) && + pair.length === 2 && + Number.isInteger(pair[0]) && + pair[0] >= 0 && + Array.isArray(pair[1]) + ) + .sort((left, right) => (left[0] === 0 ? -1 : right[0] === 0 ? 1 : 0)); + for (const [frameId, savedVideos] of savedFrames) { + if (remaining <= 0) break; + if (!frames.has(frameId) && frames.size >= MAX_VIDEO_FRAMES) continue; + const videos = []; + const seen = new Set(); + for (const video of savedVideos.slice(0, MAX_VIDEO_ITEMS)) { + if (remaining <= 0) break; + if (!video || typeof video !== "object") continue; + const src = + typeof video.src === "string" && video.src && fitsGrabUrl(video.src) + ? video.src + : null; + if (!src && video.blob !== true) continue; + const key = src || "__grab_blob__"; + if (seen.has(key)) continue; + seen.add(key); + const item = { src, blob: video.blob === true }; if (Number.isFinite(video.height) && video.height > 0) item.height = video.height; - return item; - }); - frames.set(pair[0], videos); + videos.push(item); + remaining -= 1; + } + frames.set(frameId, videos); } const sniffed = Array.isArray(saved.sniffed) ? saved.sniffed.filter((url) => fitsGrabUrl(url)).slice(-50) @@ -416,7 +467,8 @@ async function restoreVideoFinds(tabId) { frames, sniffed, kinds, - title: typeof saved.title === "string" ? saved.title : "", + title: + typeof saved.title === "string" ? saved.title.slice(0, MAX_VIDEO_TITLE_LENGTH) : "", }); } } catch { @@ -737,30 +789,55 @@ async function onVideoReport(msg, sender) { // Unparseable URL: fall through and let the menu-build check decide. } await restoreVideoFinds(tabId); - const frameId = sender && sender.frameId != null ? sender.frameId : 0; + const reportedFrameId = sender && sender.frameId != null ? sender.frameId : 0; + const frameId = + Number.isInteger(reportedFrameId) && reportedFrameId >= 0 ? reportedFrameId : 0; let f = videoFinds.get(tabId); if (!f) { f = { frames: new Map(), sniffed: [] }; videoFinds.set(tabId, f); } - // Ordinary empty re-reports may be a live stream swapping its