From 850d2ceb75ea04998d2b49b1bbcfb4a59e62b975 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Wed, 4 Mar 2026 19:49:24 +0000 Subject: [PATCH 1/7] Add test plan document --- test/PLAN.md | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 test/PLAN.md diff --git a/test/PLAN.md b/test/PLAN.md new file mode 100644 index 0000000..4fdbf9d --- /dev/null +++ b/test/PLAN.md @@ -0,0 +1,119 @@ +# fa2json Test Plan + +## Overview + +A Node.js test runner (`test/test.mjs`) that exercises `fa2json` against a +temporary ext4 filesystem on a loop device. The test produces a single +pass/fail result and cleans up after itself unconditionally. + +Requires root (`fanotify` FID reporting and `mount` both need `CAP_SYS_ADMIN`). + +--- + +## Files + +| File | Purpose | +|---|---| +| `test/test.mjs` | Test runner | +| `Makefile` | `make test` target calls `sudo node test/test.mjs` | + +--- + +## Setup + +1. Create a temporary image file (`mktemp`) +2. `dd` 10M of zeros into it +3. `mkfs.ext4` the image +4. `losetup --find --show` to attach it as a loop device +5. `mount` the loop device to a temporary directory (`mktemp -d`) +6. Spawn `fa2json ` as a child process +7. Attach a `readline` interface to its stdout; parse each line as JSON and + push into an event buffer + +--- + +## Teardown + +Runs unconditionally in a `finally` block: + +1. Kill the `fa2json` child process +2. `umount ` +3. `losetup -d ` +4. `rm` the image file +5. `rmdir` the mount directory + +--- + +## Event Collection and the Marker Pattern + +`fa2json` runs continuously for the entire test. To associate events with +specific operations, a marker file is used as a synchronisation barrier: + +1. Perform a filesystem operation +2. Immediately `touch /.marker_N` (where N is a counter) +3. Wait until the event stream contains a CREATE event for `.marker_N` +4. Collect all events since the previous marker — this batch belongs to the + current operation +5. Assert on the batch, then advance the counter + +If a marker event never arrives the test hangs, which indicates a failure at +the fa2json level itself. + +--- + +## Path Handling + +`fa2json` emits full paths including the mount prefix +(e.g. `/tmp/fa2json-mnt-XXXXX/dir_a/file.txt`). The runner strips this prefix +so assertions work against a virtual root: + +``` +/tmp/fa2json-mnt-XXXXX/dir_a/file.txt → /dir_a/file.txt +``` + +--- + +## Fanotify Mask Constants + +Relevant flags (bitwise, check with `mask & FLAG`): + +| Constant | Value | Meaning | +|---|---|---| +| `FAN_ATTRIB` | `0x4` | Metadata/attribute change | +| `FAN_CLOSE_WRITE` | `0x8` | File closed after writing | +| `FAN_CREATE` | `0x100` | File or directory created | +| `FAN_DELETE` | `0x200` | File or directory deleted | +| `FAN_RENAME` | `0x10000000` | Rename (has `old` and `new` fields) | +| `FAN_ONDIR` | `0x40000000` | Event subject is a directory | + +--- + +## Operations and Expected Events + +Each row is one `doOp()` call. Events are matched by presence (not exact list) +— extra events from ext4 internals are ignored. + +| Operation | Expected event(s) | +|---|---| +| `mkdir /dir_a` | CREATE \| ONDIR, name `/dir_a` | +| `touch /file_a.txt` | CREATE, name `/file_a.txt` | +| `echo "content" >> /file_a.txt` | CLOSE_WRITE, name `/file_a.txt` | +| `mkdir /dir_b` | CREATE \| ONDIR, name `/dir_b` | +| `touch /dir_b/nested.txt` | CREATE, name `/dir_b/nested.txt` | +| `mv /file_a.txt /file_b.txt` | RENAME, old `/file_a.txt`, new `/file_b.txt` | +| `mv /dir_b /dir_a/dir_b_moved` | RENAME \| ONDIR, old `/dir_b`, new `/dir_a/dir_b_moved` | +| `chmod 600 /file_b.txt` | ATTRIB, name `/file_b.txt` | +| `touch -m /file_b.txt` | ATTRIB, name `/file_b.txt` | +| `chmod 755 /dir_a` | ATTRIB \| ONDIR, name `/dir_a` | +| `rm /file_b.txt` | DELETE, name `/file_b.txt` | +| `rm /dir_a/dir_b_moved/nested.txt` | DELETE, name `/dir_a/dir_b_moved/nested.txt` | +| `rmdir /dir_a/dir_b_moved` | DELETE \| ONDIR, name `/dir_a/dir_b_moved` | +| `rmdir /dir_a` | DELETE \| ONDIR, name `/dir_a` | + +--- + +## Pass / Fail + +- All assertions pass → print summary, `process.exit(0)` +- Any assertion throws → print the failing operation, the expected event, and + the actual batch received, then `process.exit(1)` From c7c46dc15da980fb36731abdce4600408e4a9eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20L=C3=B6vqvist?= Date: Wed, 4 Mar 2026 22:18:11 +0100 Subject: [PATCH 2/7] Added manual experiment file --- test/Manual experiment.md | 82 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/Manual experiment.md diff --git a/test/Manual experiment.md b/test/Manual experiment.md new file mode 100644 index 0000000..2116220 --- /dev/null +++ b/test/Manual experiment.md @@ -0,0 +1,82 @@ +## Manual experiment + +> [!NOTE] +> This manual experiment shows how we can do the testing (teardown not included). Note that we don't need the `losetup`-stuff, we know where everything is. + +### Compile +```sh +gcc fs-watcher.c json-writer.c -o fa2json +``` +### Create image file +```sh +mktemp /tmp/fa2json-test-XXXXXX.img +``` + +```text +/tmp/fa2json-test-UrwpOb.img +``` + +```sh +truncate -s 10M /tmp/fa2json-test-UrwpOb.img +``` + +```sh +mkfs.ext4 /tmp/fa2json-test-UrwpOb.img +``` + +```text +mke2fs 1.47.3 (8-Jul-2025) +Discarding device blocks: done +Creating filesystem with 10240 1k blocks and 2560 inodes +Filesystem UUID: 035c508e-dec0-4a21-a4d1-1efb6fa72415 +Superblock backups stored on blocks: + 8193 + +Allocating group tables: done +Writing inode tables: done +Creating journal (1024 blocks): done +Writing superblocks and filesystem accounting information: done +``` + + +### Create mount point + +```sh +mktemp -d /tmp/fa2json-mnt-XXXXXX +``` + +```text +/tmp/fa2json-mnt-ts2Dik +``` + +### Mount loop device +```sh +sudo mount /tmp/fa2json-test-UrwpOb.img /tmp/fa2json-mnt-ts2Dik/ +``` + +> [!NOTE] +> In a different terminal I now ran - but we could do this after `chown` or possibly `chown` + `sync`? +> ```sh +> fa2json /tmp/fa2json-mnt-ts2Dik +> ``` + +### Let current user own file system +```sh +sudo chown $(id -u) /tmp/fa2json-mnt-ts2Dik/ +``` + +#### `fa2json` output +```json +{"ts": [1772658052, 704412412, 386988, 865842867], "name": "/tmp/fa2json-mnt-ts2Dik/.", "mask": 1073741828} +``` + +### Touch marker +```sh +touch /tmp/fa2json-mnt-ts2Dik/MARKER +``` + +#### `fa2json` output +```json +{"ts": [1772658064, 151070715, 387000, 312501190], "name": "/tmp/fa2json-mnt-ts2Dik/MARKER", "mask": 256} +{"ts": [1772658064, 151099105, 387000, 312529600], "name": "/tmp/fa2json-mnt-ts2Dik/MARKER", "mask": 12} +``` \ No newline at end of file From f8aed56faf9c700549dc0fb7e12cbf111bc558db Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Wed, 4 Mar 2026 21:22:44 +0000 Subject: [PATCH 3/7] Update plan: truncate over dd, direct mount, chown+sync before fa2json, drop losetup --- test/PLAN.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/test/PLAN.md b/test/PLAN.md index 4fdbf9d..a94e1b9 100644 --- a/test/PLAN.md +++ b/test/PLAN.md @@ -21,15 +21,20 @@ Requires root (`fanotify` FID reporting and `mount` both need `CAP_SYS_ADMIN`). ## Setup -1. Create a temporary image file (`mktemp`) -2. `dd` 10M of zeros into it +1. Create a temporary image file (`mktemp /tmp/fa2json-test-XXXXXX.img`) +2. `truncate -s 10M` the image (sparse file, no need for `dd`) 3. `mkfs.ext4` the image -4. `losetup --find --show` to attach it as a loop device -5. `mount` the loop device to a temporary directory (`mktemp -d`) -6. Spawn `fa2json ` as a child process -7. Attach a `readline` interface to its stdout; parse each line as JSON and +4. Create a temporary mount directory (`mktemp -d /tmp/fa2json-mnt-XXXXXX`) +5. `sudo mount ` (no `losetup` needed — `mount` accepts image files directly) +6. `sudo chown $(id -u) ` to hand ownership to the current user +7. `sync` to flush before fa2json starts listening +8. `sudo` spawn `fa2json ` as a child process (needs `CAP_SYS_ADMIN`) +9. Attach a `readline` interface to its stdout; parse each line as JSON and push into an event buffer +Steps 6 and 7 ensure the `chown` event never enters the fa2json stream, and +all subsequent FS operations run unprivileged. + --- ## Teardown @@ -37,10 +42,9 @@ Requires root (`fanotify` FID reporting and `mount` both need `CAP_SYS_ADMIN`). Runs unconditionally in a `finally` block: 1. Kill the `fa2json` child process -2. `umount ` -3. `losetup -d ` -4. `rm` the image file -5. `rmdir` the mount directory +2. `sudo umount ` +3. `rm` the image file +4. `rmdir` the mount directory --- From 448e27ed8ef12999eb1e0efc00e14f92bb92179d Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Wed, 4 Mar 2026 22:00:59 +0000 Subject: [PATCH 4/7] Add test harness: setup lib, dev mode, automated test runner --- Makefile | 21 ++++++++ test/dev.mjs | 43 ++++++++++++++++ test/lib/setup.mjs | 34 +++++++++++++ test/test.mjs | 119 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 Makefile create mode 100644 test/dev.mjs create mode 100644 test/lib/setup.mjs create mode 100644 test/test.mjs diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c5d95f8 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +SHELL := /bin/bash +CC := cc +CFLAGS := -Wall -O2 +TARGET := fa2json +SRCS := fs-watcher.c json-writer.c + +.PHONY: all test dev clean + +all: $(TARGET) + +$(TARGET): $(SRCS) + $(CC) $(CFLAGS) -o $@ $^ + +test: $(TARGET) + node test/test.mjs + +dev: $(TARGET) + node test/dev.mjs $(ARGS) + +clean: + rm -f $(TARGET) diff --git a/test/dev.mjs b/test/dev.mjs new file mode 100644 index 0000000..1cb6f80 --- /dev/null +++ b/test/dev.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Developer mode: set up loop device, stream fa2json output through jq, +// tear down on exit. Optionally launch a terminal at the mount point. +// +// Usage: +// sudo node test/dev.mjs +// sudo node test/dev.mjs --terminal konsole +// sudo node test/dev.mjs --terminal "konsole -e bash" + +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { setup, spawnFa2json } from './lib/setup.mjs'; + +const terminalArg = (() => { + const i = process.argv.indexOf('--terminal'); + return i !== -1 ? process.argv.slice(i + 1).join(' ') : null; +})(); + +const { mnt, teardown } = await setup(); +console.error(`Mount point: ${mnt}`); + +// Pipe fa2json stdout through jq for pretty coloured output +const fa2json = spawnFa2json(mnt); +const jq = spawn('jq', ['-C', '--unbuffered', '.'], { stdio: ['pipe', 'inherit', 'inherit'] }); +fa2json.stdout.pipe(jq.stdin); + +fa2json.on('exit', async () => { + jq.stdin.end(); + await teardown(); + process.exit(0); +}); + +// Launch optional terminal at mount point +if (terminalArg) { + const [cmd, ...args] = terminalArg.split(' '); + spawn(cmd, [...args, '--workdir', mnt], { detached: true, stdio: 'ignore' }).unref(); + console.error(`Launched: ${terminalArg} --workdir ${mnt}`); +} + +// Clean teardown on Ctrl+C +process.on('SIGINT', async () => { + fa2json.kill('SIGTERM'); +}); diff --git a/test/lib/setup.mjs b/test/lib/setup.mjs new file mode 100644 index 0000000..2888f62 --- /dev/null +++ b/test/lib/setup.mjs @@ -0,0 +1,34 @@ +import { execSync, spawn } from 'node:child_process'; +import { mkdtempSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const FA2JSON = new URL('../../fa2json', import.meta.url).pathname; + +export async function setup() { + // Create image file and format + const img = execSync('mktemp /tmp/fa2json-test-XXXXXX.img').toString().trim(); + execSync(`truncate -s 10M ${img}`); + execSync(`mkfs.ext4 -q ${img}`); + + // Create mount point and mount + const mnt = execSync('mktemp -d /tmp/fa2json-mnt-XXXXXX').toString().trim(); + execSync(`sudo mount ${img} ${mnt}`); + + // Hand ownership to current user, then sync before fa2json starts + execSync(`sudo chown ${process.getuid()} ${mnt}`); + execSync('sync'); + + async function teardown() { + try { execSync(`sudo umount ${mnt}`); } catch {} + try { execSync(`rm -f ${img}`); } catch {} + try { execSync(`rmdir ${mnt}`); } catch {} + } + + return { img, mnt, teardown }; +} + +export function spawnFa2json(mnt) { + const proc = spawn('sudo', [FA2JSON, mnt], { stdio: ['ignore', 'pipe', 'inherit'] }); + return proc; +} diff --git a/test/test.mjs b/test/test.mjs new file mode 100644 index 0000000..ed9a69b --- /dev/null +++ b/test/test.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Automated test runner. Exit 0 = pass, exit 1 = fail. +// Requires root (sudo) for mount and fa2json. + +import { createInterface } from 'node:readline'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { setup, spawnFa2json } from './lib/setup.mjs'; + +// Fanotify mask flags +const FAN_ATTRIB = 0x4; +const FAN_CLOSE_WRITE = 0x8; +const FAN_CREATE = 0x100; +const FAN_DELETE = 0x200; +const FAN_RENAME = 0x10000000; +const FAN_ONDIR = 0x40000000; + +const { mnt, teardown } = await setup(); +const fa2json = spawnFa2json(mnt); + +// Event buffer and marker machinery +const events = []; +let markerResolve = null; +let markerName = null; +let markerCounter = 0; + +const rl = createInterface({ input: fa2json.stdout }); +rl.on('line', line => { + const event = JSON.parse(line); + // Strip mount prefix from all path fields + for (const key of ['name', 'old', 'new']) { + if (event[key]) event[key] = event[key].slice(mnt.length); + } + events.push(event); + if (markerResolve && event.name === `/.marker_${markerName}` && (event.mask & FAN_CREATE)) { + markerResolve(); + markerResolve = null; + } +}); + +// Perform a FS operation, drop a marker, collect events up to that marker +async function doOp(label, fn) { + const id = markerCounter++; + await fn(); + await fs.writeFile(join(mnt, `.marker_${id}`), ''); + await new Promise(resolve => { + markerName = id; + markerResolve = resolve; + }); + // Collect all events since previous marker (excluding marker events themselves) + const batch = events.splice(0).filter(e => !e.name?.startsWith('/.marker_')); + return { label, batch }; +} + +function assert(label, batch, check) { + if (!check(batch)) { + console.error(`FAIL: ${label}`); + console.error('Batch:', JSON.stringify(batch, null, 2)); + throw new Error(`Assertion failed: ${label}`); + } +} + +function hasEvent(batch, flags, path, field = 'name') { + return batch.some(e => (e.mask & flags) === flags && e[field] === path); +} + +try { + let op; + + op = await doOp('mkdir /dir_a', () => fs.mkdir(join(mnt, 'dir_a'))); + assert(op.label, op.batch, b => hasEvent(b, FAN_CREATE | FAN_ONDIR, '/dir_a')); + + op = await doOp('touch /file_a.txt', () => fs.writeFile(join(mnt, 'file_a.txt'), '')); + assert(op.label, op.batch, b => hasEvent(b, FAN_CREATE, '/file_a.txt')); + + op = await doOp('write /file_a.txt', () => fs.writeFile(join(mnt, 'file_a.txt'), 'content')); + assert(op.label, op.batch, b => hasEvent(b, FAN_CLOSE_WRITE, '/file_a.txt')); + + op = await doOp('mkdir /dir_b', () => fs.mkdir(join(mnt, 'dir_b'))); + assert(op.label, op.batch, b => hasEvent(b, FAN_CREATE | FAN_ONDIR, '/dir_b')); + + op = await doOp('touch /dir_b/nested.txt', () => fs.writeFile(join(mnt, 'dir_b', 'nested.txt'), '')); + assert(op.label, op.batch, b => hasEvent(b, FAN_CREATE, '/dir_b/nested.txt')); + + op = await doOp('mv /file_a.txt /file_b.txt', () => fs.rename(join(mnt, 'file_a.txt'), join(mnt, 'file_b.txt'))); + assert(op.label, op.batch, b => b.some(e => (e.mask & FAN_RENAME) && e.old === '/file_a.txt' && e.new === '/file_b.txt')); + + op = await doOp('mv /dir_b /dir_a/dir_b_moved', () => fs.rename(join(mnt, 'dir_b'), join(mnt, 'dir_a', 'dir_b_moved'))); + assert(op.label, op.batch, b => b.some(e => (e.mask & FAN_RENAME) && (e.mask & FAN_ONDIR) && e.old === '/dir_b' && e.new === '/dir_a/dir_b_moved')); + + op = await doOp('chmod 600 /file_b.txt', () => fs.chmod(join(mnt, 'file_b.txt'), 0o600)); + assert(op.label, op.batch, b => hasEvent(b, FAN_ATTRIB, '/file_b.txt')); + + op = await doOp('touch -m /file_b.txt', () => fs.utimes(join(mnt, 'file_b.txt'), new Date(), new Date())); + assert(op.label, op.batch, b => hasEvent(b, FAN_ATTRIB, '/file_b.txt')); + + op = await doOp('chmod 755 /dir_a', () => fs.chmod(join(mnt, 'dir_a'), 0o755)); + assert(op.label, op.batch, b => hasEvent(b, FAN_ATTRIB | FAN_ONDIR, '/dir_a')); + + op = await doOp('rm /file_b.txt', () => fs.unlink(join(mnt, 'file_b.txt'))); + assert(op.label, op.batch, b => hasEvent(b, FAN_DELETE, '/file_b.txt')); + + op = await doOp('rm /dir_a/dir_b_moved/nested.txt', () => fs.unlink(join(mnt, 'dir_a', 'dir_b_moved', 'nested.txt'))); + assert(op.label, op.batch, b => hasEvent(b, FAN_DELETE, '/dir_a/dir_b_moved/nested.txt')); + + op = await doOp('rmdir /dir_a/dir_b_moved', () => fs.rmdir(join(mnt, 'dir_a', 'dir_b_moved'))); + assert(op.label, op.batch, b => hasEvent(b, FAN_DELETE | FAN_ONDIR, '/dir_a/dir_b_moved')); + + op = await doOp('rmdir /dir_a', () => fs.rmdir(join(mnt, 'dir_a'))); + assert(op.label, op.batch, b => hasEvent(b, FAN_DELETE | FAN_ONDIR, '/dir_a')); + + console.log('PASS'); +} catch (e) { + console.error(e.message); + process.exitCode = 1; +} finally { + fa2json.kill(); + await teardown(); +} From d2171d59a6dde2e88a8285635150add31c02a1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20L=C3=B6vqvist?= Date: Thu, 5 Mar 2026 20:04:46 +0100 Subject: [PATCH 5/7] Fixed bug with formatting and large integers --- fs-watcher.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fs-watcher.c b/fs-watcher.c index 726ce7d..1fe9ce8 100644 --- a/fs-watcher.c +++ b/fs-watcher.c @@ -58,7 +58,7 @@ static void handle_events(int fafd, int mount_fd) { clock_gettime(CLOCK_MONOTONIC, &mono); clock_gettime(CLOCK_REALTIME, &wall); - fprintf(stdout, "{\"ts\": [%i, %i, %i, %i]", wall.tv_sec, wall.tv_nsec, mono.tv_sec, mono.tv_nsec); + fprintf(stdout, "{\"ts\": [%li, %li, %li, %li]", wall.tv_sec, wall.tv_nsec, mono.tv_sec, mono.tv_nsec); char *ptr = (char *)(metadata + 1); char *end = (char *)metadata + metadata->event_len; @@ -98,7 +98,7 @@ static void handle_events(int fafd, int mount_fd) { } if (entry_index++) { fprintf(stdout, ", "); } - fprintf(stdout, ", \"mask\": %i}\n", metadata->mask); + fprintf(stdout, ", \"mask\": %lli}\n", metadata->mask); metadata = FAN_EVENT_NEXT(metadata, size); From 068d6963a18bcbd7a641022e9f444e936ff5bc4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20L=C3=B6vqvist?= Date: Thu, 5 Mar 2026 20:09:45 +0100 Subject: [PATCH 6/7] Added build dir, and gitignore --- .gitignore | 1 + Makefile | 15 +++++++++------ test/lib/setup.mjs | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/Makefile b/Makefile index c5d95f8..4b150fc 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ -SHELL := /bin/bash -CC := cc -CFLAGS := -Wall -O2 -TARGET := fa2json -SRCS := fs-watcher.c json-writer.c +SHELL := /bin/bash +CC := cc +MKDIR := mkdir +CFLAGS := -Wall -Werror -Os +BUILD_DIR := build +TARGET := $(BUILD_DIR)/fa2json +SRCS := fs-watcher.c json-writer.c .PHONY: all test dev clean all: $(TARGET) $(TARGET): $(SRCS) + $(MKDIR) -p $(BUILD_DIR) $(CC) $(CFLAGS) -o $@ $^ test: $(TARGET) @@ -18,4 +21,4 @@ dev: $(TARGET) node test/dev.mjs $(ARGS) clean: - rm -f $(TARGET) + rm -rf $(BUILD_DIR) diff --git a/test/lib/setup.mjs b/test/lib/setup.mjs index 2888f62..4b622f8 100644 --- a/test/lib/setup.mjs +++ b/test/lib/setup.mjs @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -const FA2JSON = new URL('../../fa2json', import.meta.url).pathname; +const FA2JSON = new URL('../../build/fa2json', import.meta.url).pathname; export async function setup() { // Create image file and format From 331d9bd357527a206ba518b1e43af89365c86aae Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Thu, 5 Mar 2026 19:12:57 +0000 Subject: [PATCH 7/7] Fix shell injection: use execFileSync with arg arrays instead of execSync with interpolated strings --- test/lib/setup.mjs | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/test/lib/setup.mjs b/test/lib/setup.mjs index 4b622f8..5c4af83 100644 --- a/test/lib/setup.mjs +++ b/test/lib/setup.mjs @@ -1,34 +1,31 @@ -import { execSync, spawn } from 'node:child_process'; -import { mkdtempSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { execFileSync, spawn } from 'node:child_process'; import { join } from 'node:path'; const FA2JSON = new URL('../../build/fa2json', import.meta.url).pathname; export async function setup() { // Create image file and format - const img = execSync('mktemp /tmp/fa2json-test-XXXXXX.img').toString().trim(); - execSync(`truncate -s 10M ${img}`); - execSync(`mkfs.ext4 -q ${img}`); + const img = execFileSync('mktemp', ['/tmp/fa2json-test-XXXXXX.img']).toString().trim(); + execFileSync('truncate', ['-s', '10M', img]); + execFileSync('mkfs.ext4', ['-q', img]); // Create mount point and mount - const mnt = execSync('mktemp -d /tmp/fa2json-mnt-XXXXXX').toString().trim(); - execSync(`sudo mount ${img} ${mnt}`); + const mnt = execFileSync('mktemp', ['-d', '/tmp/fa2json-mnt-XXXXXX']).toString().trim(); + execFileSync('sudo', ['mount', img, mnt]); // Hand ownership to current user, then sync before fa2json starts - execSync(`sudo chown ${process.getuid()} ${mnt}`); - execSync('sync'); + execFileSync('sudo', ['chown', String(process.getuid()), mnt]); + execFileSync('sync'); async function teardown() { - try { execSync(`sudo umount ${mnt}`); } catch {} - try { execSync(`rm -f ${img}`); } catch {} - try { execSync(`rmdir ${mnt}`); } catch {} + try { execFileSync('sudo', ['umount', mnt]); } catch {} + try { execFileSync('rm', ['-f', img]); } catch {} + try { execFileSync('rmdir', [mnt]); } catch {} } return { img, mnt, teardown }; } export function spawnFa2json(mnt) { - const proc = spawn('sudo', [FA2JSON, mnt], { stdio: ['ignore', 'pipe', 'inherit'] }); - return proc; + return spawn('sudo', [FA2JSON, mnt], { stdio: ['ignore', 'pipe', 'inherit'] }); }