Ctrl K
ComunidadGuías…

scripts/linux-after-install.test.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const hook = path.join(root, "build", "linux-after-install.sh");
const removeHook = path.join(root, "build", "linux-after-remove.sh");
const browserPolicy = path.join(root, "build", "linux-openmausbot-browser.apparmor");
const temporaryDirectories = [];

function fixture() {
  const appRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "omb-deb-upgrade-"));
  temporaryDirectories.push(appRoot);
  const resources = path.join(appRoot, "resources");
  const cuaRoot = path.join(resources, "cua-linux-x64");
  fs.mkdirSync(cuaRoot, { recursive: true, mode: 0o775 });
  for (const directory of [appRoot, resources, cuaRoot]) fs.chmodSync(directory, 0o775);
  for (const executable of ["cua-driver", "cua-cursor-theme"]) {
    fs.writeFileSync(path.join(cuaRoot, executable), "fixture", { mode: 0o664 });
    fs.chmodSync(path.join(cuaRoot, executable), 0o664);
  }
  const chromiumSandbox = path.join(appRoot, "chrome-sandbox");
  fs.writeFileSync(chromiumSandbox, "fixture", { mode: 0o664 });
  fs.chmodSync(chromiumSandbox, 0o664);
  const browserRoot = path.join(resources, "browser-engine");
  const chromeRoot = path.join(browserRoot, "chrome", "chrome-headless-shell-linux64");
  fs.mkdirSync(chromeRoot, { recursive: true });
  for (const directory of [browserRoot, path.dirname(chromeRoot), chromeRoot]) fs.chmodSync(directory, 0o775);
  fs.writeFileSync(path.join(browserRoot, "agent-browser"), "fixture", { mode: 0o664 });
  fs.writeFileSync(path.join(chromeRoot, "chrome-headless-shell"), "fixture", { mode: 0o664 });
  fs.writeFileSync(path.join(chromeRoot, "chrome_crashpad_handler"), "fixture", { mode: 0o775 });
  fs.writeFileSync(path.join(chromeRoot, "icudtl.dat"), "fixture", { mode: 0o664 });
  fs.copyFileSync(browserPolicy, path.join(resources, "openmausbot-browser.apparmor"));
  const systemRoot = path.join(appRoot, "test-system");
  const apparmorDir = path.join(systemRoot, "apparmor.d");
  // Model the primary target host: Ubuntu 24.04 with the AppArmor 4 ABI.
  fs.mkdirSync(path.join(apparmorDir, "abi"), { recursive: true });
  fs.writeFileSync(path.join(apparmorDir, "abi", "4.0"), "fixture\n");
  fs.chmodSync(apparmorDir, 0o755);
  const parser = path.join(systemRoot, "apparmor_parser");
  fs.writeFileSync(parser, '#!/bin/sh\nprintf "profile operation: %s %s\\n" "$1" "$2"\n', { mode: 0o755 });
  const apparmorStatus = path.join(systemRoot, "apparmor_status");
  fs.writeFileSync(apparmorStatus, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
  fs.writeFileSync(path.join(systemRoot, "apparmor_restrict_unprivileged_userns"), "1\n");
  fs.writeFileSync(path.join(systemRoot, "apparmor-profiles"), "openmausbot-browser (unconfined)\n");
  return { appRoot, resources, cuaRoot, chromiumSandbox, browserRoot, chromeRoot, systemRoot, apparmorDir, parser, apparmorStatus };
}

function runHook(appRoot) {
  return spawnSync("/bin/sh", [hook], {
    encoding: "utf8",
    env: { ...process.env, OPENMAUSBOT_POSTINSTALL_TEST_ROOT: appRoot },
  });
}

function runRemoveHook(appRoot, operation = "remove") {
  return spawnSync("/bin/sh", [removeHook, operation], {
    encoding: "utf8",
    env: { ...process.env, OPENMAUSBOT_POSTINSTALL_TEST_ROOT: appRoot },
  });
}

afterEach(() => {
  for (const directory of temporaryDirectories.splice(0)) {
    fs.rmSync(directory, { recursive: true, force: true });
  }
});

describe("Linux DEB sandbox policy", () => {
  it("allows user namespaces only for the exact installed browser executable", () => {
    const policy = fs.readFileSync(browserPolicy, "utf8").replace(/^\s*#.*$/gm, "");
    expect(policy).toContain("profile openmausbot-browser /opt/OpenMausBot/resources/browser-engine/chrome/chrome-headless-shell-linux64/chrome-headless-shell flags=(unconfined)");
    expect(policy).toContain("userns,");
    expect(policy).not.toMatch(/\*|@\{HOME\}|\/home\/|\/tmp\//);
  });

  it.skipIf(process.platform === "win32")("keeps both privileged hooks valid POSIX shell", () => {
    for (const script of [hook, removeHook]) {
      const result = spawnSync("/bin/sh", ["-n", script], { encoding: "utf8" });
      expect(result.status, result.stderr).toBe(0);
    }
  });
});

describe.skipIf(process.platform !== "linux")("Linux DEB upgrade hook", () => {
  it("skips an unsupported optional policy on legacy hosts without userns restriction", () => {
    const { appRoot, systemRoot, parser, apparmorDir } = fixture();
    fs.rmSync(path.join(apparmorDir, "abi"), { recursive: true });
    fs.unlinkSync(path.join(systemRoot, "apparmor_restrict_unprivileged_userns"));
    fs.writeFileSync(parser, "#!/bin/sh\nexit 42\n", { mode: 0o755 });
    const result = runHook(appRoot);
    expect(result.status).toBe(0);
    expect(fs.existsSync(path.join(apparmorDir, "openmausbot-browser"))).toBe(false);
  });
  it("skips the optional policy on an unrestricted host without the AppArmor 4 ABI", () => {
    const { appRoot, systemRoot, parser, apparmorDir } = fixture();
    // Ubuntu 22.04 + HWE exposes the restriction sysctl set to 0, but its
    // AppArmor 3.0 parser cannot load an abi/4.0 policy.
    fs.rmSync(path.join(apparmorDir, "abi"), { recursive: true });
    fs.writeFileSync(path.join(systemRoot, "apparmor_restrict_unprivileged_userns"), "0\n");
    fs.writeFileSync(parser, "#!/bin/sh\nexit 42\n", { mode: 0o755 });
    const result = runHook(appRoot);
    expect(result.status, result.stderr).toBe(0);
    expect(result.stdout).not.toContain("profile operation");
    expect(fs.existsSync(path.join(apparmorDir, "openmausbot-browser"))).toBe(false);
  });
  it("repairs legacy directory and executable modes idempotently", () => {
    const { appRoot, resources, cuaRoot, chromiumSandbox, browserRoot, chromeRoot, apparmorDir } = fixture();

    for (let pass = 0; pass < 2; pass += 1) {
      const result = runHook(appRoot);
      expect(result.status, result.stderr).toBe(0);
      for (const directory of [appRoot, resources, cuaRoot, browserRoot, path.dirname(chromeRoot), chromeRoot]) {
        expect(fs.lstatSync(directory).mode & 0o777).toBe(0o755);
      }
      for (const executable of ["cua-driver", "cua-cursor-theme"]) {
        expect(fs.lstatSync(path.join(cuaRoot, executable)).mode & 0o777).toBe(0o755);
      }
      expect(fs.lstatSync(chromiumSandbox).mode & 0o7777).toBe(0o4755);
      for (const executable of [path.join(browserRoot, "agent-browser"), path.join(chromeRoot, "chrome-headless-shell"), path.join(chromeRoot, "chrome_crashpad_handler")]) {
        expect(fs.lstatSync(executable).mode & 0o7777).toBe(0o755);
      }
      expect(fs.lstatSync(path.join(chromeRoot, "icudtl.dat")).mode & 0o777).toBe(0o644);
      expect(fs.readFileSync(path.join(apparmorDir, "openmausbot-browser"), "utf8")).toBe(fs.readFileSync(browserPolicy, "utf8"));
      expect(result.stdout).toContain(`profile operation: -r ${apparmorDir}/openmausbot-browser`);
    }
  });

  it("refuses to follow a replaced package directory symlink", () => {
    const { appRoot, resources } = fixture();
    const external = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "omb-deb-external-"));
    temporaryDirectories.push(external);
    fs.chmodSync(external, 0o777);
    fs.rmSync(resources, { recursive: true });
    fs.symlinkSync(external, resources, "dir");

    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("missing or unsafe");
    expect(fs.lstatSync(external).mode & 0o777).toBe(0o777);
  });

  it("fails the install when a bundled executable is missing", () => {
    const { appRoot, cuaRoot } = fixture();
    fs.unlinkSync(path.join(cuaRoot, "cua-driver"));

    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("package executable is missing or unsafe");
  });

  it("fails the install when the Chromium sandbox is replaced by a symlink", () => {
    const { appRoot, chromiumSandbox } = fixture();
    const external = path.join(appRoot, "external-sandbox");
    fs.writeFileSync(external, "fixture", { mode: 0o755 });
    fs.unlinkSync(chromiumSandbox);
    fs.symlinkSync(external, chromiumSandbox, "file");

    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("Chromium sandbox is missing or unsafe");
  });

  it("refuses browser sidecar symlinks without changing their targets", () => {
    const { appRoot, chromeRoot } = fixture();
    const external = path.join(appRoot, "external-browser-library");
    fs.writeFileSync(external, "outside", { mode: 0o666 });
    fs.chmodSync(external, 0o666);
    fs.symlinkSync(external, path.join(chromeRoot, "libEGL.so"));
    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("browser resource is missing or unsafe");
    expect(fs.lstatSync(external).mode & 0o777).toBe(0o666);
  });

  it("fails closed if the restricted host cannot load AppArmor policy", () => {
    const { appRoot, parser } = fixture();
    fs.unlinkSync(parser);
    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("needs apparmor and apparmor_parser");
  });

  it("refuses browser hard links without changing another file's permissions", () => {
    const { appRoot, chromeRoot } = fixture();
    const external = path.join(appRoot, "external-browser-data");
    fs.writeFileSync(external, "outside", { mode: 0o666 });
    fs.chmodSync(external, 0o666);
    fs.linkSync(external, path.join(chromeRoot, "linked-data"));
    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("unsafe hard link");
    expect(fs.lstatSync(external).mode & 0o777).toBe(0o666);
  });

  it("does not require AppArmor when user namespaces are unrestricted", () => {
    const { appRoot, systemRoot, parser } = fixture();
    fs.unlinkSync(parser);
    fs.writeFileSync(path.join(systemRoot, "apparmor_restrict_unprivileged_userns"), "0\n");
    expect(runHook(appRoot).status).toBe(0);
  });

  it("reads numeric procfs sysctls without byte-at-a-time shell reads", () => {
    const { appRoot, systemRoot } = fixture();
    // A harmless read-only stand-in with the same numeric procfs handler.
    // Unlike an ordinary fixture file, procfs ends a subsequent read at EOF;
    // dash's read builtin therefore exits 1 before seeing the newline.
    const procSysctl = "/proc/sys/kernel/core_uses_pid";
    expect(fs.readFileSync(procSysctl, "utf8").trim()).toMatch(/^[01]$/);
    const restriction = path.join(systemRoot, "apparmor_restrict_unprivileged_userns");
    fs.unlinkSync(restriction);
    fs.symlinkSync(procSysctl, restriction);
    const result = runHook(appRoot);
    expect(result.status, result.stderr).toBe(0);
    expect(result.stdout).toContain("profile operation: -r");
  });

  it.each(["0", "1"])("reads an unterminated restriction value %s", (value) => {
    const { appRoot, systemRoot } = fixture();
    fs.writeFileSync(path.join(systemRoot, "apparmor_restrict_unprivileged_userns"), value);
    const result = runHook(appRoot);
    expect(result.status, result.stderr).toBe(0);
    expect(result.stdout).toContain("profile operation: -r");
  });

  it.each(["", "2\n", "0 1\n", "disabled\n"])("fails closed for an invalid restriction value %j", (value) => {
    const { appRoot, systemRoot } = fixture();
    fs.writeFileSync(path.join(systemRoot, "apparmor_restrict_unprivileged_userns"), value);
    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("restriction is invalid");
    expect(result.stdout).not.toContain("profile operation");
  });

  it("stages but does not load policy when AppArmor is disabled on an unrestricted host", () => {
    const { appRoot, systemRoot, apparmorStatus, apparmorDir } = fixture();
    fs.writeFileSync(apparmorStatus, "#!/bin/sh\nexit 1\n");
    fs.writeFileSync(path.join(systemRoot, "apparmor_restrict_unprivileged_userns"), "0\n");
    const result = runHook(appRoot);
    expect(result.status, result.stderr).toBe(0);
    expect(result.stdout).not.toContain("profile operation");
    expect(fs.readFileSync(path.join(apparmorDir, "openmausbot-browser"), "utf8")).toBe(fs.readFileSync(browserPolicy, "utf8"));
  });

  it("does not claim sandbox setup when restriction is active but AppArmor is unavailable", () => {
    const { appRoot, apparmorStatus } = fixture();
    fs.writeFileSync(apparmorStatus, "#!/bin/sh\nexit 1\n");
    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("restrictions are active but AppArmor is unavailable");
    expect(result.stdout).not.toContain("profile operation");
  });

  it("refuses an AppArmor destination symlink without overwriting its target", () => {
    const { appRoot, apparmorDir } = fixture();
    const external = path.join(appRoot, "external-policy");
    fs.writeFileSync(external, "outside");
    fs.symlinkSync(external, path.join(apparmorDir, "openmausbot-browser"));
    const result = runHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("profile target is unsafe");
    expect(fs.readFileSync(external, "utf8")).toBe("outside");
  });

  it("keeps the profile on upgrade and removes only its own policy on uninstall", () => {
    const { appRoot, apparmorDir } = fixture();
    expect(runHook(appRoot).status).toBe(0);
    const profile = path.join(apparmorDir, "openmausbot-browser");
    const unrelated = path.join(apparmorDir, "another-app");
    fs.writeFileSync(unrelated, "unrelated policy");
    expect(runRemoveHook(appRoot, "upgrade").status).toBe(0);
    expect(fs.existsSync(profile)).toBe(true);
    const removed = runRemoveHook(appRoot);
    expect(removed.status, removed.stderr).toBe(0);
    expect(removed.stdout).toContain(`profile operation: -R ${profile}`);
    expect(fs.existsSync(profile)).toBe(false);
    expect(fs.readFileSync(unrelated, "utf8")).toBe("unrelated policy");
    expect(runRemoveHook(appRoot, "purge").status).toBe(0);
  });

  it("treats pacman's post_remove, which passes the old version, as an uninstall", () => {
    const { appRoot, apparmorDir } = fixture();
    expect(runHook(appRoot).status).toBe(0);
    const profile = path.join(apparmorDir, "openmausbot-browser");
    const removed = runRemoveHook(appRoot, "0.1.149-1");
    expect(removed.status, removed.stderr).toBe(0);
    expect(fs.existsSync(profile)).toBe(false);
  });

  it("removes an already unloaded profile without asking the kernel again", () => {
    const { appRoot, apparmorDir, systemRoot } = fixture();
    expect(runHook(appRoot).status).toBe(0);
    fs.writeFileSync(path.join(systemRoot, "apparmor-profiles"), "another-app (enforce)\n");
    const result = runRemoveHook(appRoot);
    expect(result.status).toBe(0);
    expect(result.stdout).not.toContain("profile operation");
    expect(fs.existsSync(path.join(apparmorDir, "openmausbot-browser"))).toBe(false);
  });

  it("removes staged policy without kernel operations when AppArmor is disabled", () => {
    const { appRoot, apparmorDir, apparmorStatus, parser } = fixture();
    expect(runHook(appRoot).status).toBe(0);
    fs.writeFileSync(apparmorStatus, "#!/bin/sh\nexit 1\n");
    fs.writeFileSync(parser, "#!/bin/sh\nexit 1\n");
    const result = runRemoveHook(appRoot);
    expect(result.status, result.stderr).toBe(0);
    expect(result.stdout).not.toContain("profile operation");
    expect(fs.existsSync(path.join(apparmorDir, "openmausbot-browser"))).toBe(false);
  });

  it("keeps the exact policy available for repair if the kernel refuses to unload it", () => {
    const { appRoot, apparmorDir, parser } = fixture();
    expect(runHook(appRoot).status).toBe(0);
    fs.writeFileSync(parser, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
    const result = runRemoveHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("could not unload");
    expect(fs.existsSync(path.join(apparmorDir, "openmausbot-browser"))).toBe(true);
  });

  it("does not delete a loaded policy when its AppArmor parser is missing", () => {
    const { appRoot, apparmorDir, parser } = fixture();
    expect(runHook(appRoot).status).toBe(0);
    fs.unlinkSync(parser);
    const result = runRemoveHook(appRoot);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("needs apparmor_parser to unload");
    expect(fs.existsSync(path.join(apparmorDir, "openmausbot-browser"))).toBe(true);
  });

  it("rejects a test override outside the private temporary root", () => {
    const result = runHook(root);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("must stay under /tmp");
  });

  it("resolves the test root before applying the temporary-directory boundary", () => {
    const escaped = path.join(fs.realpathSync(os.tmpdir()), "..", path.relative("/", root));
    const result = runHook(escaped);
    expect(result.status).not.toBe(0);
    expect(result.stderr).toContain("must stay under /tmp");
  });
});