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
14 changes: 13 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,23 @@ jobs:
run: npm run lint

- name: Build Action
id: build
run: |
npm run build
# Check if any files were changed during build
if ! git diff --quiet; then
echo "Error: Build generated changes that aren't committed. Please run 'npm run build' locally and commit the changes."
echo "Error: Build generated changes that aren't committed. Please run 'npm run build' locally and commit the changes,"
echo "or download the 'dist' artifact from this run (gh run download ${{ github.run_id }} -n dist -D dist) and commit it."
git diff
exit 1
fi

# Building needs BUF_TOKEN for the private @buf packages, which contributors
# without registry access (and automated fixers) do not have. Publish the
# bundle CI just built so it can be committed without a local build.
- name: Upload built dist
if: failure() && steps.build.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v23.2.0
v24.21.0
119 changes: 72 additions & 47 deletions dist/post/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 81 additions & 0 deletions src/__tests__/mount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { tmpdir } from "os";
import {
CommandRunner,
findMountedDevice,
getFilesystemUsedField,
parseMountSource,
unmount,
} from "../mount";

// A path a workflow could feed through the `path` input; every character is
// meaningful to a shell and must reach the child process as plain filename
// bytes.
const hostilePath = "/$(id>&2).*;`touch /tmp/pwned`|\"x'y";

function recordingRunner(
results: Record<string, { stdout: string } | Error> = {},
): { run: CommandRunner; calls: { file: string; args: string[] }[] } {
const calls: { file: string; args: string[] }[] = [];
const run: CommandRunner = async (file, args) => {
calls.push({ file, args });
const result = results[file];
if (result instanceof Error) throw result;
return { stdout: result?.stdout ?? "", stderr: "" };
};
return { run, calls };
}

describe("mount helpers", () => {
it("passes the mount path to findmnt, df, and umount as one argv element", async () => {
const { run, calls } = recordingRunner({
findmnt: { stdout: "/dev/vdb\n" },
df: { stdout: "Used\n123456\n" },
});

expect(await findMountedDevice(hostilePath, run)).toBe("/dev/vdb");
expect(await getFilesystemUsedField(hostilePath, run)).toBe("123456");
await unmount(hostilePath, run);

expect(calls).toEqual([
{
file: "findmnt",
args: ["-n", "-o", "SOURCE", "--mountpoint", hostilePath],
},
{ file: "df", args: ["-B1", "--output=used", "--", hostilePath] },
{ file: "sudo", args: ["umount", "--", hostilePath] },
]);
});

it("falls back to the mount listing and matches the target exactly", async () => {
const listing = [
"/dev/vda1 on / type ext4 (rw,relatime)",
"/dev/vdb on /mnt/cache type ext4 (rw,relatime)",
"/dev/vdc on /mnt/cache2 type ext4 (rw,relatime)",
].join("\n");

expect(parseMountSource(listing, "/mnt/cache")).toBe("/dev/vdb");
expect(parseMountSource(listing, "/mnt/cache2")).toBe("/dev/vdc");
expect(parseMountSource(listing, "/mnt/cach")).toBeNull();
expect(parseMountSource(listing, "/mnt")).toBeNull();

const { run } = recordingRunner({
findmnt: new Error("findmnt: not found"),
mount: { stdout: listing },
});
expect(await findMountedDevice("/mnt/cache", run)).toBe("/dev/vdb");
expect(await findMountedDevice("/mnt/cache/nested", run)).toBeNull();
});

const itOnLinux = process.platform === "linux" ? it : it.skip;

itOnLinux("runs the real binaries without a shell", async () => {
// With a shell in the way this path would be a glob plus a substitution;
// through execFile it is just a filename df cannot find.
await expect(getFilesystemUsedField(hostilePath)).rejects.toThrow();
expect(await findMountedDevice(hostilePath)).toBeNull();

const used = parseInt(await getFilesystemUsedField(tmpdir()), 10);
expect(used).toBeGreaterThan(0);
expect(await findMountedDevice("/")).not.toBeNull();
});
});
88 changes: 88 additions & 0 deletions src/mount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { execFile } from "child_process";
import { promisify } from "util";

const execFileAsync = promisify(execFile);

/**
* Runs a binary with an argv array and no shell, so a mount path is only ever
* a filename to the child process. `path` is an action input and can carry
* `$(...)`, backticks, quotes, or `;` from a workflow's untrusted data.
*/
export type CommandRunner = (
file: string,
args: string[],
) => Promise<{ stdout: string; stderr: string }>;

const runCommand: CommandRunner = async (file, args) => {
const { stdout, stderr } = await execFileAsync(file, args);
return { stdout: String(stdout), stderr: String(stderr) };
};

/**
* Source device of a `mount` listing line whose target is exactly
* `mountPoint`, e.g. `/dev/vdb on /mnt/cache type ext4 (rw)` → `/dev/vdb`.
*/
export function parseMountSource(
mountOutput: string,
mountPoint: string,
): string | null {
for (const line of mountOutput.split("\n")) {
const match = line.match(/^(\S+) on (.+) type \S+ /);
if (match && match[2] === mountPoint) {
return match[1];
}
}
return null;
}

/** Device mounted at `mountPoint`, or null when nothing is mounted there. */
export async function findMountedDevice(
mountPoint: string,
run: CommandRunner = runCommand,
): Promise<string | null> {
try {
const { stdout } = await run("findmnt", [
"-n",
"-o",
"SOURCE",
"--mountpoint",
mountPoint,
]);
const device = stdout.trim();
if (device) {
return device;
}
} catch {
// findmnt exits non-zero when the path is not a mount point; fall through
// to the `mount` listing in case findmnt itself is unavailable.
}

try {
const { stdout } = await run("mount", []);
return parseMountSource(stdout, mountPoint);
} catch {
return null;
}
}

/** The `used` column of `df -B1` for `mountPoint`, as printed. */
export async function getFilesystemUsedField(
mountPoint: string,
run: CommandRunner = runCommand,
): Promise<string> {
const { stdout } = await run("df", [
"-B1",
"--output=used",
"--",
mountPoint,
]);
const lines = stdout.trim().split("\n");
return lines[lines.length - 1].trim();
}

export async function unmount(
mountPoint: string,
run: CommandRunner = runCommand,
): Promise<void> {
await run("sudo", ["umount", "--", mountPoint]);
}
Loading
Loading