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 new file mode 100644 index 0000000..4b150fc --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +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) + node test/test.mjs + +dev: $(TARGET) + node test/dev.mjs $(ARGS) + +clean: + rm -rf $(BUILD_DIR) 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); 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 diff --git a/test/PLAN.md b/test/PLAN.md new file mode 100644 index 0000000..a94e1b9 --- /dev/null +++ b/test/PLAN.md @@ -0,0 +1,123 @@ +# 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 /tmp/fa2json-test-XXXXXX.img`) +2. `truncate -s 10M` the image (sparse file, no need for `dd`) +3. `mkfs.ext4` the image +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 + +Runs unconditionally in a `finally` block: + +1. Kill the `fa2json` child process +2. `sudo umount ` +3. `rm` the image file +4. `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)` 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..5c4af83 --- /dev/null +++ b/test/lib/setup.mjs @@ -0,0 +1,31 @@ +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 = 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 = execFileSync('mktemp', ['-d', '/tmp/fa2json-mnt-XXXXXX']).toString().trim(); + execFileSync('sudo', ['mount', img, mnt]); + + // Hand ownership to current user, then sync before fa2json starts + execFileSync('sudo', ['chown', String(process.getuid()), mnt]); + execFileSync('sync'); + + async function teardown() { + 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) { + return spawn('sudo', [FA2JSON, mnt], { stdio: ['ignore', 'pipe', 'inherit'] }); +} 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(); +}