Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 140 additions & 2 deletions electron/media/audioPeaks.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// @vitest-environment node
import { existsSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ffmpegCandidates, peakBlockCount, resolveFfmpeg } from "./audioPeaks";

const ROOT = path.resolve(__dirname, "..", "..");
Expand Down Expand Up @@ -52,6 +53,143 @@ describe("ffmpeg resolution", () => {
it("returns null rather than throwing when nothing is staged", () => {
expect(resolveFfmpeg(path.join(ROOT, "does", "not", "exist"))).toBeNull();
});

/**
* The shape that slipped through. A Linux dev checkout can have
* `electron/native/bin/<tag>/ffmpeg` as a DIRECTORY of shared libraries
* rather than the binary; `existsSync` accepted it, resolution stopped
* there, and the failure only surfaced later as `spawn … EACCES`.
*/
it("skips a candidate that is a directory rather than the binary", () => {
const here = mkdtempSync(path.join(tmpdir(), "openscreen-ffmpeg-"));
const tag = `${process.platform}-${process.arch}`;
const name = process.platform === "win32" ? "ffmpeg-shared.exe" : "ffmpeg";
const staged = path.join(here, "electron", "native", "bin", tag, name);
try {
// A directory sitting exactly where the executable is looked for.
mkdirSync(staged, { recursive: true });
writeFileSync(path.join(staged, "libavcodec.so.62"), "");

expect(resolveFfmpeg(here)).toBeNull();
} finally {
rmSync(here, { recursive: true, force: true });
}
});

it("accepts a candidate that is an executable file", () => {
const here = mkdtempSync(path.join(tmpdir(), "openscreen-ffmpeg-"));
const tag = `${process.platform}-${process.arch}`;
const name = process.platform === "win32" ? "ffmpeg-shared.exe" : "ffmpeg";
const staged = path.join(here, "electron", "native", "bin", tag, name);
try {
mkdirSync(path.dirname(staged), { recursive: true });
writeFileSync(staged, "", { mode: 0o755 });

expect(resolveFfmpeg(here)).toBe(staged);
} finally {
rmSync(here, { recursive: true, force: true });
}
});

// Non-executable files are the other half of the predicate, and the check is
// only meaningful where the OS enforces the bit.
it.runIf(process.platform !== "win32")(
"skips a candidate that is a file but not executable",
() => {
const here = mkdtempSync(path.join(tmpdir(), "openscreen-ffmpeg-"));
const staged = path.join(
here,
"electron",
"native",
"bin",
`${process.platform}-${process.arch}`,
"ffmpeg",
);
try {
mkdirSync(path.dirname(staged), { recursive: true });
writeFileSync(staged, "", { mode: 0o644 });

expect(resolveFfmpeg(here)).toBeNull();
} finally {
rmSync(here, { recursive: true, force: true });
}
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* REJECTING IS NOT THE SAME AS CONTINUING, and only the second is the
* property the predicate exists for: swallowing every failure is what stops
* one bad path from denying a later working one. The tests above prove the
* first — with a single candidate staged, `null` is equally consistent with
* "skipped it" and "gave up on the whole list".
*
* `OPENSCREEN_FFMPEG_PATH` is the vehicle because `ffmpegCandidates` puts it
* FIRST, so a bad value there is the one case that could shadow every real
* candidate behind it.
*/
describe("falling through to a later candidate", () => {
let here: string;
let staged: string;

beforeEach(() => {
here = mkdtempSync(path.join(tmpdir(), "openscreen-ffmpeg-"));
staged = path.join(
here,
"electron",
"native",
"bin",
`${process.platform}-${process.arch}`,
process.platform === "win32" ? "ffmpeg-shared.exe" : "ffmpeg",
);
mkdirSync(path.dirname(staged), { recursive: true });
writeFileSync(staged, "", { mode: 0o755 });
});

afterEach(() => {
delete process.env.OPENSCREEN_FFMPEG_PATH;
rmSync(here, { recursive: true, force: true });
});

it("passes over a leading candidate that does not exist", () => {
process.env.OPENSCREEN_FFMPEG_PATH = path.join(here, "nowhere", "ffmpeg");

expect(resolveFfmpeg(here)).toBe(staged);
});

it("passes over a leading candidate that is a directory", () => {
const decoy = path.join(here, "decoy-ffmpeg");
mkdirSync(decoy, { recursive: true });
writeFileSync(path.join(decoy, "libavcodec.so.62"), "");
process.env.OPENSCREEN_FFMPEG_PATH = decoy;

expect(resolveFfmpeg(here)).toBe(staged);
});

it.runIf(process.platform !== "win32")(
"passes over a leading candidate that is not executable",
() => {
const decoy = path.join(here, "decoy-ffmpeg");
writeFileSync(decoy, "", { mode: 0o644 });
process.env.OPENSCREEN_FFMPEG_PATH = decoy;

expect(resolveFfmpeg(here)).toBe(staged);
},
);

// An unreadable-but-executable binary is legitimate on Unix, so it must be
// ACCEPTED rather than fallen through — `X_OK` is deliberately not paired
// with `R_OK`. Skipped as root, for whom access checks always pass.
it.runIf(process.platform !== "win32" && process.getuid?.() !== 0)(
"still accepts an execute-only binary",
() => {
const executableOnly = path.join(here, "exec-only-ffmpeg");
writeFileSync(executableOnly, "", { mode: 0o111 });
process.env.OPENSCREEN_FFMPEG_PATH = executableOnly;

expect(resolveFfmpeg(here)).toBe(executableOnly);
},
);
});
});

// Only runs where the binary is actually staged; skipped elsewhere rather than
Expand Down
42 changes: 39 additions & 3 deletions electron/media/audioPeaks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { accessSync, constants as fsConstants, statSync } from "node:fs";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { app } from "electron";
Expand Down Expand Up @@ -98,10 +98,46 @@ export function ffmpegCandidates(here: string = process.cwd()): string[] {

let cachedFfmpeg: string | null | undefined;

/** First candidate that exists, or null when none does (callers fall back). */
/**
* Whether a candidate is something that can actually be run.
*
* EXISTENCE IS NOT ENOUGH, and the difference is not academic. `existsSync` was
* the test here, and it answers true for a DIRECTORY: on a Linux dev machine
* `electron/native/bin/<tag>/ffmpeg` is a folder holding the shared libraries
* (`libavcodec.so.62` and friends) rather than the binary, so resolution picked
* the folder, every later candidate was skipped, and the failure surfaced much
* later as `spawn … EACCES` — a message that blames permissions rather than
* saying the wrong candidate was chosen.
*
* Every failure mode is swallowed on purpose. A candidate that is absent, not a
* regular file, or not executable is simply not this one; throwing out of
* resolution would let a single bad path deny a later, working one.
*
* `X_OK` and not `X_OK | R_OK`: executing a binary needs the execute bit, not
* the read bit, so an install shipped `--x` is legitimate and must not be
* refused. On Windows `X_OK` is not enforced at all — there the `isFile` check
* is the whole guard, which is enough for the failure this exists to stop,
* since `ffmpegCandidates` only ever proposes `.exe` names of its own.
*/
function isExecutableFile(candidate: string): boolean {
try {
if (!statSync(candidate).isFile()) {
return false;
}
accessSync(candidate, fsConstants.X_OK);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'audioPeaks\.ts$' . || true

echo "== relevant file outline/lines =="
file=$(fd 'audioPeaks\.ts$' . | head -n 1)
if [ -n "$file" ]; then
  wc -l "$file"
  sed -n '1,180p' "$file" | cat -n
fi

echo "== search access sync usage =="
rg -n "accessSync|candidate|X_OK|R_OK|READABLE|audios\|mediaFiles" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

Repository: getopenscreen/openscreen

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inspect related path checks =="
for f in \
  electron/stt/whisperServer.ts \
  electron/ipc/handlers.ts \
  electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts \
  electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n -C 4 "accessSync|fsConstants" "$f" || true
  fi
done

echo "== behavioral probe for access flags on current sandbox tmp =="
python3 - <<'PY'
import os, stat, tempfile
d = tempfile.mkdtemp(prefix="audiosync_")
p = os.path.join(d, "x")
with open(p, "wb") as f:
    f.write(b"ok")
for mode in [(stat.S_IRUSR | stat.S_IXUSR), (stat.S_IRUSR,), (stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)]:
    os.chmod(p, mode)
    print(f"{oct(mode):14} -> R_OK={bool(os.access(p, os.R_OK))} X_OK={bool(os.access(p, os.X_OK))}")
print("tmpdir", d)
PY
rm -rf /tmp /*audiosync_*

Repository: getopenscreen/openscreen

Length of output: 4595


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== behavioral probe for access flags =="
python3 - <<'PY'
import os, stat, tempfile

d = tempfile.mkdtemp(prefix="audiosync_")
p = os.path.join(d, "x")
with open(p, "wb") as f:
    f.write(b"ok")

for mode in [(stat.S_IRUSR | stat.S_IXUSR), (stat.S_IRUSR,), (stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)]:
    os.chmod(p, mode)
    print(f"{oct(mode):14} -> R_OK={bool(os.access(p, os.R_OK))} X_OK={bool(os.access(p, os.X_OK))}")

try:
    os.chmod(d, 0o500)
    q = os.path.join(d, "y")
    with open(q, "wb") as f:
        f.write(b"ok")
except Exception as e:
    print(type(e).__name__, str(e))
else:
    os.chmod(q, 0o000)
    print("directory_with_nothing_ ->", f"R_OK={bool(os.access(q, os.R_OK))} X_OK={bool(os.access(q, os.X_OK))}")
    os.unlink(q)
PY

echo "== permissions probe =="
node - <<'JS'
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const d = fs.mkdtempSync(path.join(os.tmpdir(), "audiosync-"));
const p = path.join(d, "x");

fs.writeFileSync(p, "ok");
for (const mode of ["0555", "0444", "0755"]) {
  fs.chmodSync(p, mode);
  console.log(mode, {
    read: fs.existsSync(p) && fs.accessSync(p, fs.constants.R_OK) === undefined,
    exec: fs.existsSync(p) && fs.accessSync(p, fs.constants.X_OK) === undefined,
  });
}
fs.rmSync(d, { recursive: true, force: true });
JS

Repository: getopenscreen/openscreen

Length of output: 364


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Node permissions probe =="
node - <<'JS'
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const d = fs.mkdtempSync(path.join(os.tmpdir(), "audiosync-"));
const p = path.join(d, "x");

fs.writeFileSync(p, "ok");
for (const mode of ["0555", "0444", "0755"]) {
  try {
    fs.chmodSync(p, mode);
    const readOk = fs.accessSync(p, fs.constants.R_OK) === undefined;
    const execOk = fs.accessSync(p, fs.constants.X_OK) === undefined;
    console.log(mode, { read: readOk, exec: execOk });
  } catch (error) {
    console.log(mode, `error=${error.code}: ${error.message}`);
  }
}

try {
  fs.chmodSync(d, "0555");
  const q = path.join(d, "y");
  fs.writeFileSync(q, "ok");
  fs.chmodSync(q, "0000");
  console.log("dir_with_irregular_file", {
    read: fs.accessSync(q, fs.constants.R_OK) === undefined,
    exec: fs.accessSync(q, fs.constants.X_OK) === undefined,
  });
} catch (error) {
  console.log("dir_with_irregular_file", `error=${error.code}: ${error.message}`);
}

fs.rmSync(d, { recursive: true, force: true });
JS

Repository: getopenscreen/openscreen

Length of output: 1231


Require read access before selecting the ffmpeg candidate.

accessSync(candidate, fsConstants.X_OK) accepts 0555 files and rejects 0444 files, so X_OK alone does not implement unreadable-candidate rejection. Check R_OK here before returning this candidate.

Proposed fix
-		accessSync(candidate, fsConstants.X_OK);
+		accessSync(candidate, fsConstants.R_OK | fsConstants.X_OK);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
accessSync(candidate, fsConstants.X_OK);
accessSync(candidate, fsConstants.R_OK | fsConstants.X_OK);
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/media/audioPeaks.ts` at line 123, Update the ffmpeg candidate
validation around accessSync to require read access before selecting a
candidate: use the read permission constant together with the existing execute
check, while preserving the current rejection and candidate-selection flow.

return true;
} catch {
return false;
}
}

/**
* First candidate that is an executable file, or null when none is (callers
* fall back).
*/
export function resolveFfmpeg(here?: string): string | null {
if (cachedFfmpeg !== undefined && here === undefined) return cachedFfmpeg;
const found = ffmpegCandidates(here).find((p) => existsSync(p)) ?? null;
const found = ffmpegCandidates(here).find(isExecutableFile) ?? null;
if (here === undefined) cachedFfmpeg = found;
return found;
}
Expand Down
Loading