engine: built-in fs module (Node.js-shaped filesystem API for rules) + import/export in .ts rule files - #231
Draft
evgeny-boger wants to merge 3 commits into
Draft
engine: built-in fs module (Node.js-shaped filesystem API for rules) + import/export in .ts rule files#231evgeny-boger wants to merge 3 commits into
evgeny-boger wants to merge 3 commits into
Conversation
require("fs") (also "fs/promises" and the node: spellings) resolves to a
module implemented by the engine: readFile, writeFile, appendFile,
stat/lstat, readdir, exists, mkdir, rm, rmdir, unlink, rename, copyFile,
access, realpath, readlink, symlink, chmod, mkdtemp, truncate, utimes -
each as a blocking ...Sync function and as a promise-returning one - plus
fs.watch over inotify and fs.constants.
Shape: files are UTF-8 strings (no Buffer, no other encoding); the
asynchronous functions return promises instead of taking callbacks
(fs.readFile === fs.promises.readFile); Stats and Dirent carry Node's
predicates and fields; failures are Errors with code/errno/syscall/path/
dest and Node's message; bad arguments are TypeErrors with
ERR_INVALID_ARG_TYPE / ERR_INVALID_ARG_VALUE. readFile refuses files over
10 MiB (one JS heap serves every rule file). Supersedes the global-object
proposal of #187.
Implementation: fsmodule.js is embedded and compiled once per rule file
(ModSearch serves it before the module directories, like Node's core
modules), so its promises and errors belong to the requiring realm.
Two realm-local builtins do the I/O - _wbFsSync on the engine loop,
_wbFsAsync on a goroutine with the spawn callback lifecycle (MaybeCallSync,
one-shot stash sweep, orphan sweep on a stopped engine) - over one table of
operations shared by both. fs.watch is inotify(7) through the x/sys/unix
package that is already a dependency (no fsnotify: the wbgo.so plugin's
package set stays untouched): one descriptor per watcher, a reader
goroutine, libuv's event mask; watchers close with their file's cleanup
scope, at Stop, from JS and when the kernel drops the watch.
Types: declare module "fs" / "fs/promises" in wb-rules.d.ts plus require()
overloads, pinned by the type-assertion fixture. Docs: README section,
arc42 5/8/9/12, ADR-017. Tests: rule_fs_test.go (every operation and error
code, per-file instances, shadowing of a user fs.js), watch events, close,
cleanup on reload, Stop.
Note for callers of ESContext.GetJSObject(idx): it converts the stack top
regardless of idx (getArray enumerates -1); fsOpArgs dups the argument to
the top first.
The transpiler left ESM syntax in place, and inside the async function
wrapper an import or export statement is a SyntaxError - every .ts rule
file with an import failed to load. transpileModule now runs with module
CommonJS and esModuleInterop: import * as fs from "fs", import fs from
"fs" (interop helper) and import { readFileSync } from "fs" become
require() calls, export const x becomes an exports assignment - exactly
what the wrapper's require/exports/module parameters provide. Top-level
await passes through untouched. The background check gets
--esModuleInterop so default imports type-check the same way; the strict
type-assertion run mirrors it.
The helpers TypeScript prepends shift generated lines; the source-map
line table already accounts for that, pinned by TestTsFsErrorLineNumbers.
…ce, cleanup and orphan lifecycle, Node fidelity, async tracebacks
Three from-scratch reviews (Go lifecycle, JS API and types, CommonJS emit);
every finding is addressed here.
copyFile onto the same inode (same path, a symlink or a hard link to the
source) truncated the source before copying. Now libuv's order: open the
destination without O_TRUNC, fstat both, a no-op when they are the same
file, only then truncate. Modes are carried with their setuid/setgid/
sticky bits (posixMode - a plain os.FileMode cast dropped them).
fs.watch used one inotify instance per watcher; instances are a per-user
resource (max_user_instances, typically 128, shared with every root
daemon) and closing one costs milliseconds of RCU grace period on the
engine loop. Now one instance per engine, watchers of an inode share the
watch descriptor, the descriptor is kept apart from the os.File because
(*os.File).Fd() switches it to blocking mode, and the instance is closed
off-loop with the last watcher. Watchers are tracked per realm like
timers and closed from runCleanups: the per-watcher cleanup-list closure
they used before would have raced the interpreter under -cleanup, where
RunAllCleanups runs from the Stop caller's goroutine before the sync loop
handshake - and it grew the list without bound on watch()/close() churn.
An async completion accepted by a stopping engine's queue and dropped in
its drain left its callback stash entry behind, because MaybeCallSync
cannot report the drop. The orphan note is now taken before the thunk is
queued and withdrawn by the thunk (orphanedCallbacks is a map); the fs
async path and the watcher reader use it. spawn keeps the old scheme -
noted as a follow-up.
Node fidelity: rmdir(path, {recursive: true}) refuses non-directories
(ENOTDIR) instead of unlinking files; readFile honours and validates
flag; throwIfNoEntry: false also covers ENOTDIR; symlink loops map to
ELOOP; utimes rejects an invalid Date; the rs/rs+/as/as+ flags; symlink
type validation (ERR_FS_INVALID_SYMLINK_TYPE); access/copyFile mode range
(ERR_OUT_OF_RANGE); ERR_FS_FILE_TOO_LARGE is a RangeError;
COPYFILE_FICLONE_FORCE reports ENOTSUP; mkdir recursive through a
dangling symlink reports EEXIST; a forged huge args array is refused
before the converter preallocates by length.
Types: fallback overloads so stat/readdir/mkdir accept boolean variables
(stat then yields Stats | undefined), the extra flags, the symlink type
union - pinned in the type-assertion fixture.
TypeScript: async rule errors reported by the promise-rejection tracker
never went through the source-map line translation (only the synchronous
path did); with the CommonJS interop preamble the shift became
systematic, so the job error handler now translates too
(TestTsFsAsyncErrorLineNumbers). The background check moves from
--module esnext to --module preserve: esnext reported the CommonJS forms
the transpiler emits and runs - import x = require("m"), export = - as
syntax errors (TS1202/TS1203) that dropped the file from the batch;
preserve accepts them with otherwise identical diagnostics. The strict
type-assertion run mirrors it.
Docs: README (exists wording, birthtime = ctime, bigint, FIFO and stuck
paths block, unbounded Promise.all, non-UTF-8 names, rmdir recursive,
readFile flag, relative and side-effect imports, namespace import of a
function module), arc42 command lines (3, 5, 6, ADR-010, ADR-012),
ADR-017 (shared instance, per-realm cleanup, orphan bookkeeping),
changelog date.
Tests: copy onto itself and through a symlink, rmdir recursive on a file,
readFile a+, stat through a file, invalid Date, symlink type, mode range,
ELOOP, oversized file (sync RangeError and async rejection), setgid bit,
mkdir over a dangling link; two watchers on one path sharing a descriptor,
instance released with the last watcher and recreated, watcher created
inside a rule closed with the file; 40 in-flight completions swept across
Stop (the callback stash is runtime-wide, measured against a baseline).
This was referenced Aug 24, 2026
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Security | 1 critical 14 high |
🟢 Metrics 420 complexity · 4 duplication
Metric Results Complexity 420 Duplication 4
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Что происходит; кому и зачем нужно
Встроенный модуль
fs— файловый API в стиле Node.js для сценариев правил: чтение sysfs, журналы, конфиги, временные файлы, наблюдение за изменениями — безrunShellCommand("cat …"). Поверх #224 (TypeScript), заменяет #187 (глобальный объектfsэпохи Duktape): модуль вместо глобала, промисы вместо колбэков, Node-точные формы данных, типы и документация.Что поменялось для пользователей
require("fs")/require("fs/promises")(иnode:-варианты):readFile,writeFile,appendFile,stat/lstat,readdir,exists,mkdir,rm,rmdir,unlink,rename,copyFile,access,realpath,readlink,symlink,chmod,mkdtemp,truncate,utimes— каждая как блокирующая…Syncи как возвращающаяPromise;fs.watch()(inotify);fs.constants.StatsсisFile()/isDirectory()/…, числовымmode,mtimeMs/mtime;readdir→ имена илиDirent(withFileTypes,recursive); ошибки —Errorсcode/errno/syscall/path/destи Node-сообщением; неверные аргументы —TypeErrorсERR_INVALID_ARG_TYPE/ERR_INVALID_ARG_VALUE.Buffer); только промисы (колбэк — ошибка с понятным сообщением);fs.exists()как промис;fs.watchвозвращает объект сclose(), без EventEmitter и безrecursive;readFileне читает файлы > 10 МиБ; нет дескрипторов/потоков.spawn(); относительные пути — от cwd процесса (README рекомендует абсолютные).import/exportв.ts-сценариях теперь работают — транспилятор выдаёт CommonJS (+esModuleInterop), фоновая проверка получила--esModuleInterop. Раньше любойimportронял загрузку файла сSyntaxError.declare module "fs"/"fs/promises"и перегрузкиrequire("fs")вtypes/wb-rules.d.ts— редактор и проверка на контроллере знают модуль (homeui: отдельный PR с синхронизацией d.ts).fs», правка проimport/export), arc42 §5/§8/§9/§12, ADR-017; changelog (в станце 2.47.0~quickjs2.1 — версия намеренно не поднята, чтобы сборка ветки не перекрыла сборкуquickjs-tsв публичном набореexperimental.quickjs2; поднять при слиянии).Как устроено
wbrules/fsmodule.js(встроен черезgo:embed) — тело модуля: валидация аргументов, опции, классыStats/Dirent/FSWatcher, промисы.ModSearchотдаёт его раньше каталогов модулей (как модули ядра Node), компилируется в realm каждого файла → промисы/ошибки принадлежат файлу, атрибуция послеawaitсохраняется.wbrules/fsmodule.go— realm-локальные builtin'ы_wbFsSync(op, args)/_wbFsAsync(op, args, cb)над одной таблицей операцийfsOps; асинхронный путь повторяет жизненный циклspawn(MaybeCallSync, однократный sweep stash-записи, orphan-sweep при остановленном движке).wbrules/fswatch.go—fs.watchнапрямую поверх inotify(7) черезgolang.org/x/sys/unix(уже зависимость той же версии v0.38.0, что уwbgo-private→ ABI плагинаwbgo.soне затронут;fsnotifyиз Добавить Node.js-style файловый API (глобальный объект fs) #187 не нужен): дескриптор на watcher, горутина-читатель, маска libuv; закрытие cleanup'ом файла,Stop(),close()из JS, приIN_IGNORED.wbrules/tsloader.go—module: CommonJS+esModuleInteropвtranspileModule,--esModuleInteropв проверке.Коммиты:
0d86856engine: built-in fs module;7574f38ts: CommonJS output so import/export work in .ts rule files;5202d47fs module: review round.Как проверял
rule_fs_test.go(все операции sync+promise, коды ошибок/errno/syscall/path/dest,throwIfNoEntry,withFileTypes/recursive, алиасыfs/promises/node:fs, перекрытие пользовательскогоfs.js),TestRuleFsWatchSuite(события dir/file,close(), авто-закрытие при удалении пути, закрытие при удалении файла правил и приStop),TestRuleTsFsSuite(три стиляimport,export, номера строк трейсбека с interop-преамбулой),testrules_ts_typeassert.ts(контракт типов,@ts-expect-errorв обе стороны,--strict true/false). Полныйgo test ./wbrules/зелёный..js-проба —readFileSync("/etc/hostname"),readdirSync("/sys/class/thermal"),existsSync; кнопка →await writeFile/appendFile/stat/readdir; ожидаемая ошибкаENOENT open ENOENT: no such file or directory, open '/nonexistent/file';fs.watchна каталоге:rename/changeпри создании и записи,renameпри удалении (счётчик событий 6/6); cron-правило читает sysfs каждые 10 с (на этом стенде thermal_zone0 отдаёт 0)..ts-проба:import * as fs,import fsd from "fs",import { readdirSync },export const— загрузилась и отработала (size=20 entries=13 same=true, async после TLA). Намеренно неправильный.ts: загрузка падаетTypeError: The "data" argument must be of type string. Received type number (42)с указаниемfs-probe-bad.ts:4:17, фоновая проверка отмечает все три строки (3:7, 4:28, 5:30). 0 ошибок/паник в журнале, RSS 42 МБ. Сборка после проверки снята со стенда (он занят другим агентом), бинарник лежит в/root/wb-rules.fs-new.Supersedes #187.
Ревью (три независимых агента, from scratch) и что исправлено
Все находки закрыты в коммите
5202d47(review round); ниже — по одной строке на находку.Go/lifecycle
copyFileв самого себя (тот же путь, symlink, hardlink) обнулял источник (O_TRUNCдо сравнения inode) → порядок libuv: открыть оба, сравнитьdev/ino, no-op если совпали, только потомftruncate; тест.-cleanupcleanup-список выполняется с горутиныStopдо остановки sync-loop — cleanup-замыкание watcher'а трогало JS-кучу → watcher'ы учитываются по realm'у (как таймеры) и закрываются изrunCleanups; замыканий в cleanup-списке нет (заодно ушёл рост списка при churnwatch()/close()).orphanedCallbacks— map); тест с 40 чтениями «в полёте» наStop.mkdir{recursive}поверх битого symlink →EEXIST; LOW setuid/setgid/sticky терялись вos.FileMode(uint32)→posixMode(); LOW поддельнаяargs.length=2**31→ guard.JS-API / типы / документация
rmdir(...,{recursive:true})удалял обычные файлы →ENOTDIRкак в Node ≥ 16; MEDreadFileигнорировалflag("a+"теперь создаёт файл) и не валидировал его; MED в d.ts не было fallback-перегрузок для булевых переменных (stat/readdir/mkdir) → добавлены и закреплены в фикстуре; MED один inotify-экземпляр на watcher упирался вmax_user_instances(128 на uid, общий с другими демонами) → общий экземпляр.throwIfNoEntry:falseтеперь и дляENOTDIR;ELOOPдля петли symlink'ов;utimesс невалиднойDate; флагиrs/rs+/as/as+; валидация типа symlink (ERR_FS_INVALID_SYMLINK_TYPE); диапазонmodeвaccess/copyFile(ERR_OUT_OF_RANGE);ERR_FS_FILE_TOO_LARGE—RangeError;COPYFILE_FICLONE_FORCE→ENOTSUP; README:exists,birthtime = ctime,bigint, FIFO/зависшие пути блокируют, неограниченныйPromise.all, не-UTF-8 имена.CommonJS-эмит
async rule errorникогда не проходили через source map (только синхронный путь) — с interop-преамбулой сдвиг стал систематическим →translateStackLinesв обработчике ошибок промис-джобов; тестTestTsFsAsyncErrorLineNumbers.--module esnextсчиталаimport x = require()/export =синтаксическими ошибками (TS1202/1203) и выкидывала файл из батча, хотя рантайм их исполняет →--module preserve(диагностики на всех фикстурах и системных правилах идентичны, TLA работает); homeui LS — так же.import "./x"проходят проверку по соседнему.ts, но рантайм ищет только.jsв каталогах модулей → задокументировано; LOW неиспользуемый импорт вырезается,import * as hмодуля-функции не вызываем,import.meta— задокументировано/оставлено; docs drift командной строки проверки (arc42 §3/§6, ADR-010/012) исправлен.Сознательно не сделано: bounded pool для асинхронных операций (как threadpool в Node) — задокументировано как ограничение;
spawnостаётся на старой схеме orphan-учёта (известный долг, отдельный PR); удаление частично записанногоdestпри сбоеcopyFile.