diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a184d72..830ffe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,8 +269,10 @@ jobs: set -euo pipefail mkdir -p /tmp/clean-root/smoke cp -r tests /tmp/clean-root/smoke/tests + # the scan an add runs reads the format tables and the recorded field dumps beside the tests + cp -r data /tmp/clean-root/smoke/data # the tests read the declared protocol from the installed package's JSON - chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd FAKE_HOST=/smoke/tests/fake-host \ + chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=/smoke/tests/fake-host \ PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 /smoke/tests/daemon_test.py # the installed fake worker prints its layout and serves the daemon, as plugin-qualify drives it @@ -279,7 +281,7 @@ jobs: set -euo pipefail chroot /tmp/clean-root /usr/libexec/plugin-hostd/tests/fake-host -L | tee layout.txt test -s layout.txt - chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd FAKE_HOST=/usr/libexec/plugin-hostd/tests/fake-host \ + chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=/usr/libexec/plugin-hostd/tests/fake-host \ PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 /smoke/tests/daemon_test.py - uses: actions/upload-artifact@v4 @@ -333,6 +335,7 @@ jobs: make tests/fake-host mkdir -p smoke cp -r tests smoke/tests + cp -r data smoke/data - uses: actions/upload-artifact@v4 with: @@ -404,7 +407,7 @@ jobs: set -euo pipefail # an artifact does not keep the executable bit chmod +x smoke/tests/fake-host - PLUGIN_HOSTD=/usr/bin/plugin-hostd FAKE_HOST=$PWD/smoke/tests/fake-host \ + PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=$PWD/smoke/tests/fake-host \ PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 smoke/tests/daemon_test.py # docs/own-host.md, command by command, as a reader would type it: a clean Debian with nothing but what the page diff --git a/Makefile b/Makefile index bcd0173..1ce81c1 100644 --- a/Makefile +++ b/Makefile @@ -56,14 +56,15 @@ INCS = -Iinclude $(PROTOCOL_CFLAGS) LDFLAGS += -Wl,--no-undefined # source and object files -SRC = src/main.c src/conf.c src/proc.c src/supervisor.c src/verbs.c src/pins.c src/relay.c src/formats.c src/lv2.c +SRC = src/main.c src/conf.c src/proc.c src/supervisor.c src/verbs.c src/pins.c src/relay.c src/formats.c src/lv2.c \ + src/descriptors.c src/describe.c src/transforms.c src/json.c OBJ = $(SRC:.c=.o) # default build all: $(PROG) $(PROBE) $(PROG): $(OBJ) $(PROTOCOL_LIB) - $(CC) $(OBJ) $(PROTOCOL_LIBS) $(LDFLAGS) -pthread -o $@ + $(CC) $(OBJ) $(PROTOCOL_LIBS) $(LDFLAGS) -pthread -lm -o $@ # the probe runner: it loads no plugin and links nothing of the daemon or of mod-host # describe reads the installed table of a format by default (docs/formats.md) @@ -80,6 +81,8 @@ endif # the declared protocol is compiled into every object $(OBJ): include/plugin-hostd/protocol.h include/plugin-hostd/pin.h +src/descriptors.o: src/descriptors.h src/describe.h src/json.h src/formats.h src/supervisor.h + # meta-rule to generate the object files %.o: %.c $(CC) $(INCS) $(CFLAGS) -c -o $@ $< diff --git a/README.md b/README.md index 426134f..4e41410 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ The settings live in a file, `-c`, `$PLUGIN_HOSTD_CONF` or | `pin_store` | `` | the file first-use pins are kept in; empty is $XDG_STATE_HOME/plugin-hostd/pins, else $HOME/.local/state/plugin-hostd/pins | | `pin_file` | `` | a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none | | `pins_dir` | `/usr/share/plugin-hostd/pins.d` | the read-only pins of packages: every file in it whose name ends in .pins, read at start in name order, under the store, the pin_file and pin_set, except that a pin of verdict=refused there always holds; empty is none | +| `descriptor_dir` | `$OMX_DESCRIPTOR_DIR`, else `` | the scanned descriptors, one file per plugin binary (@-.json, the hash the binary's SHA-256), written by the scan of an add and read by descriptor_get; empty is $XDG_STATE_HOME/omx/descriptors, else /var/lib/omx/descriptors run as root (a system service), else $HOME/.local/state/omx/descriptors; its variable is for tests only | +| `qualification_dir` | `$OMX_QUALIFICATION_DIR`, else `` | plugin-qualify's qualification records (verdict, cost, version tested), kept apart from the descriptors; empty is $XDG_STATE_HOME/omx/qualification, else /var/lib/omx/qualification run as root (a system service), else $HOME/.local/state/omx/qualification; its variable is for tests only | +| `probe` | `plugin-hostd-probe` | the scanner an add runs, as describe , in a child of its own once per plugin binary; its stdout is the descriptor | | `ready_timeout_ms` | 5000 ms | a worker that does not accept in this long is a failed spawn | | `rpc_timeout_ms` | 5000 ms | a worker that does not answer in this long is killed | | `backoff_base_ms` | 250 ms | respawn backoff, doubling per death in the window | @@ -169,6 +172,7 @@ Verbs the daemon adds | `pin_expect` | ` :` | `resp 0` | sent by the daemon to a worker of a format that checks layouts, just before the instance's add: the layout pin that add checks after init and before activate; any other reply than resp 0 refuses the add with that code, and the add is never forwarded | | `chain` | ` [ ...]` | `resp 0` | the instances, in this order, are one chain, run in one worker of place chain:: each member's state is saved, it is removed from the worker it was in and replayed in the chain's. A chain already declared takes the new order, and a member left out of it goes back to a worker of its own. A worker that runs chains in one client (clap, lv2-clap) is sent this line, with the members it holds, after the adds of its replay; one that does not (mod-host) never is, and a refusal of it is written to stderr and is no failure. Every instance verb still names one member by its own number. A member that crashes the worker is quarantined to a worker of its own and the chain goes on without it; quarantine_clear puts it back. -518 for an instance no add made, -519 for one named twice, -520 for one in another chain, -521 for members of more than one format, -501 for a plugin that may not share a worker while require_pins is on (a CLAP plugin whose first-use pin is not in-process; an LV2 member whose bundle is not found, since a chain's worker sees a world narrowed to the union of its members' bundles), -902 for a chain or an instance that is not a number, or fewer than chain_members_min (1) instances | | `chain_clear` | `` | `resp 0` | forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared | +| `descriptor_get` | `` | `resp 0 ""` | the scanned descriptor of the plugin, answered from descriptor_dir: the file named for its binary as it is now (@-.json), written by the scan the plugin's add ran. -529 when there is none, -530 while its scan runs, -531 when its last scan wrote nothing, -528 when the directory could not be written | A `` is `up` (accepting commands), `starting` (spawned, not yet accepting), `backoff` (dead, waiting to be respawned), `given-up` (the storm bound is spent for its placement). @@ -301,6 +305,7 @@ mechanism, and they stay optional. The preset verbs and the preset file are [doc | `lv2_worker_set` | daemon | required | required | required | required | | `chain` | daemon | required | required | required | required | | `chain_clear` | daemon | required | required | required | required | +| `descriptor_get` | daemon | required | required | required | required | The profiles: `lv2-worker`, an LV2 plugin's worker: mod-host, or omx-clap-host's LV2 adapter; `clap-worker`, a CLAP plugin's worker, omx-clap-host; `console`, the openmixer console as a controller of the daemon; `zynthian`, Zynthian's mod-ui and its plugind engine as a controller of the daemon. `required` is answered as its grammar says; `optional` is answered, or refused with -902, and never misread; `absent` is refused on purpose, and no client of the profile may need it. @@ -337,6 +342,10 @@ Error codes the daemon adds to mod-host's: | `-525` | `PHD_ERR_PRESET_NEEDS_STATE` | the worker: a preset file that carries state for a worker that cannot take state yet | | `-526` | `PHD_ERR_PRESET_PLUGIN_CHANGED` | the worker: the preset file's plugin id, version or fingerprint is not the instance's; nothing of it is applied | | `-527` | `PHD_ERR_LOAD_REFUSED` | the worker: the plugin refused the preset or the state its file carries | +| `-528` | `PHD_ERR_DIR_NOT_WRITABLE` | descriptor_dir or qualification_dir cannot be made or written: the daemon does not start, and says which key and which path on stderr with this code; a directory that turns read-only later makes the scan of an add write nothing, and descriptor_get of that plugin answers this code | +| `-529` | `PHD_ERR_DESCRIPTOR_ABSENT` | descriptor_get of a plugin that has no descriptor in descriptor_dir for its binary as it is now, and none being written: no add since it changed, or a binary that cannot be found | +| `-530` | `PHD_ERR_DESCRIPTOR_PENDING` | descriptor_get of a plugin whose scan, started by its add, has not ended: ask again | +| `-531` | `PHD_ERR_DESCRIPTOR_SCAN_FAILED` | descriptor_get of a plugin whose last scan wrote no descriptor: the scanner refused the plugin, made no dump, or is not installed; the add it ran for stands, and stderr and the scan's log say why. The next add scans again | A worker's own refusal is returned as it said it. An `add` that killed its worker answers mod-host's `-102`. diff --git a/doc/plugin-hostd.1 b/doc/plugin-hostd.1 index 91ab5fd..59cac04 100644 --- a/doc/plugin-hostd.1 +++ b/doc/plugin-hostd.1 @@ -72,8 +72,40 @@ or in one .\" BEGIN GENERATED protocol:man-keys .RI \(dq key " value" \(dq -per line: mod_host, clap_host, lv2_worker, lv2_path, state_root, pin_mode, pin_store, pin_file, pins_dir, ready_timeout_ms, rpc_timeout_ms, backoff_base_ms, backoff_max_ms, storm_deaths, storm_window_ms, suspect_window_ms, checkpoint_ms, idle_ms, pool_max, require_pins. +per line: mod_host, clap_host, lv2_worker, lv2_path, state_root, pin_mode, pin_store, pin_file, pins_dir, descriptor_dir, qualification_dir, probe, ready_timeout_ms, rpc_timeout_ms, backoff_base_ms, backoff_max_ms, storm_deaths, storm_window_ms, suspect_window_ms, checkpoint_ms, idle_ms, pool_max, require_pins. .\" END GENERATED protocol:man-keys +.SH DIRECTORIES +What the scan of an +.B add +gathers is state, kept across a reboot, in two directories of their own: +.\" BEGIN GENERATED protocol:man-dirs +.TP +.B descriptor_dir +the scanned descriptors, one file per plugin binary (@\-.json, the hash the binary's SHA\-256), written by the scan of an add and read by descriptor_get; empty is $XDG_STATE_HOME/omx/descriptors, else /var/lib/omx/descriptors run as root (a system service), else $HOME/.local/state/omx/descriptors; its variable is for tests only. +The variable +.B $OMX_DESCRIPTOR_DIR +stands in for the empty default; the key in the settings file holds over it. +.TP +.B qualification_dir +plugin\-qualify's qualification records (verdict, cost, version tested), kept apart from the descriptors; empty is $XDG_STATE_HOME/omx/qualification, else /var/lib/omx/qualification run as root (a system service), else $HOME/.local/state/omx/qualification; its variable is for tests only. +The variable +.B $OMX_QUALIFICATION_DIR +stands in for the empty default; the key in the settings file holds over it. +.PP +A missing directory is made, mode 0700, with its parents. One that cannot be made or written stops the daemon before it is ready: stderr names the key and the path with resp \-528. +.PP +The answered +.B add +of a plugin whose files have no descriptor yet runs +.I probe +.B describe +in a child of its own and files its stdout as +.IR "@\-.json" , +written once; the add never waits for it. +.B descriptor_get +.I uri +answers the file's path, or \-529 (none), \-530 (the scan runs), \-531 (the scan wrote nothing). +.\" END GENERATED protocol:man-dirs .SH PINS With require_pins on, .B add diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index d12f650..0d4dbb0 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -280,6 +280,10 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_ERR_PRESET_NEEDS_STATE (-525) #define PHD_ERR_PRESET_PLUGIN_CHANGED (-526) #define PHD_ERR_LOAD_REFUSED (-527) +#define PHD_ERR_DIR_NOT_WRITABLE (-528) +#define PHD_ERR_DESCRIPTOR_ABSENT (-529) +#define PHD_ERR_DESCRIPTOR_PENDING (-530) +#define PHD_ERR_DESCRIPTOR_SCAN_FAILED (-531) /* X(id, meaning) */ #define PHD_ERRORS(X) \ @@ -339,7 +343,17 @@ static inline int phd_pool_name_valid(const char *name) X(PRESET_NEEDS_STATE, "the worker: a preset file that carries state for a worker that cannot take state yet") \ X(PRESET_PLUGIN_CHANGED, "the worker: the preset file's plugin id, version or fingerprint is not the instance's; " \ "nothing of it is applied") \ - X(LOAD_REFUSED, "the worker: the plugin refused the preset or the state its file carries") + X(LOAD_REFUSED, "the worker: the plugin refused the preset or the state its file carries") \ + X(DIR_NOT_WRITABLE, "descriptor_dir or qualification_dir cannot be made or written: the daemon does not start, and " \ + "says which key and which path on stderr with this code; a directory that turns read-only " \ + "later makes the scan of an add write nothing, and " PHD_VERB_DESCRIPTOR_GET " of that plugin " \ + "answers this code") \ + X(DESCRIPTOR_ABSENT, PHD_VERB_DESCRIPTOR_GET " of a plugin that has no descriptor in descriptor_dir for its binary " \ + "as it is now, and none being written: no add since it changed, or a binary that cannot be found") \ + X(DESCRIPTOR_PENDING, PHD_VERB_DESCRIPTOR_GET " of a plugin whose scan, started by its add, has not ended: ask again") \ + X(DESCRIPTOR_SCAN_FAILED, PHD_VERB_DESCRIPTOR_GET " of a plugin whose last scan wrote no descriptor: the scanner " \ + "refused the plugin, made no dump, or is not installed; the add it ran for stands, and " \ + "stderr and the scan's log say why. The next add scans again") /* ---------------------------------------------------------------- verbs */ @@ -359,6 +373,7 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_VERB_LV2_WORKER_SET "lv2_worker_set" #define PHD_VERB_CHAIN "chain" #define PHD_VERB_CHAIN_CLEAR "chain_clear" +#define PHD_VERB_DESCRIPTOR_GET "descriptor_get" /* the words of a pin: =, joined by ',', and : */ #define PHD_PIN_FILE_SEPARATOR "," @@ -466,7 +481,11 @@ static inline int phd_pool_name_valid(const char *name) "a world narrowed to the union of its members' bundles), -902 for a chain " \ "or an instance that is not a number, or fewer than chain_members_min (" PHD_STR(PHD_CHAIN_MEMBERS_MIN) ") instances") \ X(CHAIN_CLEAR, PHD_VERB_CHAIN_CLEAR, "", "resp 0", \ - "forget the chain: each member goes to a worker of its own; -522 for a chain no " PHD_VERB_CHAIN " declared") + "forget the chain: each member goes to a worker of its own; -522 for a chain no " PHD_VERB_CHAIN " declared") \ + X(DESCRIPTOR_GET, PHD_VERB_DESCRIPTOR_GET, "", "resp 0 \"\"", \ + "the scanned descriptor of the plugin, answered from descriptor_dir: the file named for its binary as it is now " \ + "(" PHD_DESCRIPTOR_NAME_SYNTAX "), written by the scan the plugin's add ran. -529 when there is none, -530 while " \ + "its scan runs, -531 when its last scan wrote nothing, -528 when the directory could not be written") /* ---------------------------------------------------------------- instance verbs */ @@ -794,7 +813,8 @@ static inline int phd_is_note_verb(const char *verb) X(PHD_VERB_PIN_ACCEPT, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ X(PHD_VERB_LV2_WORKER_SET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ X(PHD_VERB_CHAIN, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ - X(PHD_VERB_CHAIN_CLEAR, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) + X(PHD_VERB_CHAIN_CLEAR, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_DESCRIPTOR_GET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) /* ---------------------------------------------------------------- host settings */ @@ -938,6 +958,30 @@ static inline int phd_is_note_verb(const char *verb) #define PHD_DEFAULT_REQUIRE_PINS 1 #define PHD_DEFAULT_PIN_MODE PHD_PIN_MODE_FIRST_USE #define PHD_DEFAULT_PINS_DIR "/usr/share/plugin-hostd/pins.d" +#define PHD_DEFAULT_PROBE "plugin-hostd-probe" + +/* The directories of what the scan of an add gathers: state, kept across a reboot. An empty setting is the default: + * /, where the state home is $XDG_STATE_HOME, else /var/lib for a daemon run as root (a system + * service), else $HOME/.local/state. A missing directory is made, mode 0700; one that cannot be written stops the daemon with PHD_ERR_DIR_NOT_WRITABLE. */ +#define PHD_DIR_DESCRIPTORS_SUBDIR "omx/descriptors" +#define PHD_DIR_QUALIFICATION_SUBDIR "omx/qualification" +#define PHD_DIR_SYSTEM_STATE_HOME "/var/lib" +#define PHD_DIR_USER_STATE_HOME ".local/state" +#define PHD_DIR_MODE 0700 +#define PHD_DIR_DEFAULT_TEXT(subdir) \ + "empty is $XDG_STATE_HOME/" subdir ", else " PHD_DIR_SYSTEM_STATE_HOME "/" subdir " run as root (a system " \ + "service), else $HOME/" PHD_DIR_USER_STATE_HOME "/" subdir + +/* A scanned descriptor's file in descriptor_dir: @-.json, the rule plugin-qualify's scanners name + * theirs by. The id is the CLAP plugin id or the LV2 URI, the version the one the descriptor states + * (PHD_DESCRIPTOR_NO_VERSION when it states none), each character outside [A-Za-z0-9._-] of both written + * PHD_DESCRIPTOR_SAFE_CHAR; the hash is the first PHD_DESCRIPTOR_HASH_LEN hex digits of the SHA-256 of the plugin's + * binary (the .clap, or the file the LV2 bundle's manifest names with lv2:binary). Written once, atomically; a changed + * binary gets a new name and the old file stays. */ +#define PHD_DESCRIPTOR_NAME_SYNTAX "@-.json" +#define PHD_DESCRIPTOR_NO_VERSION "unversioned" +#define PHD_DESCRIPTOR_SAFE_CHAR '_' +#define PHD_DESCRIPTOR_HASH_LEN 12 /* the settings file: one "key value" per line, '#' starts a comment. * A string setting: X(key, size, default, env, meaning); env names the variable that stands in for the default when @@ -958,7 +1002,15 @@ static inline int phd_is_note_verb(const char *verb) X(pin_file, path, "", "", "a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none") \ X(pins_dir, path, PHD_DEFAULT_PINS_DIR, "", "the read-only pins of packages: every file in it whose name ends in .pins, read at start " \ "in name order, under the store, the pin_file and pin_set, except that a pin of verdict=refused there always holds; " \ - "empty is none") + "empty is none") \ + X(descriptor_dir, path, "", "OMX_DESCRIPTOR_DIR", "the scanned descriptors, one file per plugin binary (" \ + PHD_DESCRIPTOR_NAME_SYNTAX ", the hash the binary's SHA-256), written by the scan of an add and read by " PHD_VERB_DESCRIPTOR_GET "; " \ + PHD_DIR_DEFAULT_TEXT(PHD_DIR_DESCRIPTORS_SUBDIR) "; its variable is for tests only") \ + X(qualification_dir, path, "", "OMX_QUALIFICATION_DIR", "plugin-qualify's qualification records (verdict, cost, " \ + "version tested), kept apart from the descriptors; " PHD_DIR_DEFAULT_TEXT(PHD_DIR_QUALIFICATION_SUBDIR) \ + "; its variable is for tests only") \ + X(probe, path, PHD_DEFAULT_PROBE, "", "the scanner an add runs, as describe , in a child of its " \ + "own once per plugin binary; its stdout is the descriptor") /* A whole-number setting: X(key, default, unit, meaning); 0 turns off where the meaning says so */ #define PHD_CONF_INTS(X) \ diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index b9e4cea..3b7c43a 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -52,7 +52,11 @@ {"name": "PRESET_INVALID", "macro": "PHD_ERR_PRESET_INVALID", "code": -524, "meaning": "preset_save with a file path that is not absolute or with no name, or preset_load or preset_show of a file that cannot be read, is no JSON, is of a format version other than org.openmixer.preset/1 and org.openmixer.preset/2, or breaks the format (docs/presets.md): the daemon answers it and no worker sees the file"}, {"name": "PRESET_NEEDS_STATE", "macro": "PHD_ERR_PRESET_NEEDS_STATE", "code": -525, "meaning": "the worker: a preset file that carries state for a worker that cannot take state yet"}, {"name": "PRESET_PLUGIN_CHANGED", "macro": "PHD_ERR_PRESET_PLUGIN_CHANGED", "code": -526, "meaning": "the worker: the preset file's plugin id, version or fingerprint is not the instance's; nothing of it is applied"}, - {"name": "LOAD_REFUSED", "macro": "PHD_ERR_LOAD_REFUSED", "code": -527, "meaning": "the worker: the plugin refused the preset or the state its file carries"} + {"name": "LOAD_REFUSED", "macro": "PHD_ERR_LOAD_REFUSED", "code": -527, "meaning": "the worker: the plugin refused the preset or the state its file carries"}, + {"name": "DIR_NOT_WRITABLE", "macro": "PHD_ERR_DIR_NOT_WRITABLE", "code": -528, "meaning": "descriptor_dir or qualification_dir cannot be made or written: the daemon does not start, and says which key and which path on stderr with this code; a directory that turns read-only later makes the scan of an add write nothing, and descriptor_get of that plugin answers this code"}, + {"name": "DESCRIPTOR_ABSENT", "macro": "PHD_ERR_DESCRIPTOR_ABSENT", "code": -529, "meaning": "descriptor_get of a plugin that has no descriptor in descriptor_dir for its binary as it is now, and none being written: no add since it changed, or a binary that cannot be found"}, + {"name": "DESCRIPTOR_PENDING", "macro": "PHD_ERR_DESCRIPTOR_PENDING", "code": -530, "meaning": "descriptor_get of a plugin whose scan, started by its add, has not ended: ask again"}, + {"name": "DESCRIPTOR_SCAN_FAILED", "macro": "PHD_ERR_DESCRIPTOR_SCAN_FAILED", "code": -531, "meaning": "descriptor_get of a plugin whose last scan wrote no descriptor: the scanner refused the plugin, made no dump, or is not installed; the add it ran for stands, and stderr and the scan's log say why. The next add scans again"} ], "verbs": [ {"name": "add", "arguments": " [own | pool: | default] [client_name]", "reply": "resp ", "meaning": "mod-host's add with a placement; a token after the instance that is not a placement is the jack client name"}, @@ -70,7 +74,8 @@ {"name": "pin_layout", "arguments": "", "reply": "resp 0 :", "meaning": "sent by the daemon to a worker of a format that checks layouts, after it accepted the add of a plugin whose layout pin is still unknown: the layout the worker found after init, which the daemon records in the pin"}, {"name": "pin_expect", "arguments": " :", "reply": "resp 0", "meaning": "sent by the daemon to a worker of a format that checks layouts, just before the instance's add: the layout pin that add checks after init and before activate; any other reply than resp 0 refuses the add with that code, and the add is never forwarded"}, {"name": "chain", "arguments": " [ ...]", "reply": "resp 0", "meaning": "the instances, in this order, are one chain, run in one worker of place chain:: each member's state is saved, it is removed from the worker it was in and replayed in the chain's. A chain already declared takes the new order, and a member left out of it goes back to a worker of its own. A worker that runs chains in one client (clap, lv2-clap) is sent this line, with the members it holds, after the adds of its replay; one that does not (mod-host) never is, and a refusal of it is written to stderr and is no failure. Every instance verb still names one member by its own number. A member that crashes the worker is quarantined to a worker of its own and the chain goes on without it; quarantine_clear puts it back. -518 for an instance no add made, -519 for one named twice, -520 for one in another chain, -521 for members of more than one format, -501 for a plugin that may not share a worker while require_pins is on (a CLAP plugin whose first-use pin is not in-process; an LV2 member whose bundle is not found, since a chain's worker sees a world narrowed to the union of its members' bundles), -902 for a chain or an instance that is not a number, or fewer than chain_members_min (1) instances"}, - {"name": "chain_clear", "arguments": "", "reply": "resp 0", "meaning": "forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared"} + {"name": "chain_clear", "arguments": "", "reply": "resp 0", "meaning": "forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared"}, + {"name": "descriptor_get", "arguments": "", "reply": "resp 0 \"\"", "meaning": "the scanned descriptor of the plugin, answered from descriptor_dir: the file named for its binary as it is now (@-.json), written by the scan the plugin's add ran. -529 when there is none, -530 while its scan runs, -531 when its last scan wrote nothing, -528 when the directory could not be written"} ], "instance_verbs": [ {"name": "track_info", "arguments": " <#RRGGBB | -> [bus | return | master]", "reply": "resp 0", "ledger": "replace", "meaning": "the strip's name (\"\" for none), colour and kind, absent for an input channel; a worker whose plugin format has a track-info extension passes them on; any other word is -902"}, @@ -159,7 +164,8 @@ {"name": "pin_accept", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, {"name": "lv2_worker_set", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, {"name": "chain", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, - {"name": "chain_clear", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}} + {"name": "chain_clear", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "descriptor_get", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}} ], "track_kinds": ["bus", "return", "master"], "param_scales": [ @@ -218,6 +224,9 @@ {"key": "pin_store", "type": "string", "default": "", "env": "", "meaning": "the file first-use pins are kept in; empty is $XDG_STATE_HOME/plugin-hostd/pins, else $HOME/.local/state/plugin-hostd/pins"}, {"key": "pin_file", "type": "string", "default": "", "env": "", "meaning": "a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none"}, {"key": "pins_dir", "type": "string", "default": "/usr/share/plugin-hostd/pins.d", "env": "", "meaning": "the read-only pins of packages: every file in it whose name ends in .pins, read at start in name order, under the store, the pin_file and pin_set, except that a pin of verdict=refused there always holds; empty is none"}, + {"key": "descriptor_dir", "type": "string", "default": "", "env": "OMX_DESCRIPTOR_DIR", "meaning": "the scanned descriptors, one file per plugin binary (@-.json, the hash the binary's SHA-256), written by the scan of an add and read by descriptor_get; empty is $XDG_STATE_HOME/omx/descriptors, else /var/lib/omx/descriptors run as root (a system service), else $HOME/.local/state/omx/descriptors; its variable is for tests only"}, + {"key": "qualification_dir", "type": "string", "default": "", "env": "OMX_QUALIFICATION_DIR", "meaning": "plugin-qualify's qualification records (verdict, cost, version tested), kept apart from the descriptors; empty is $XDG_STATE_HOME/omx/qualification, else /var/lib/omx/qualification run as root (a system service), else $HOME/.local/state/omx/qualification; its variable is for tests only"}, + {"key": "probe", "type": "string", "default": "plugin-hostd-probe", "env": "", "meaning": "the scanner an add runs, as describe , in a child of its own once per plugin binary; its stdout is the descriptor"}, {"key": "ready_timeout_ms", "type": "int", "default": 5000, "unit": "ms", "meaning": "a worker that does not accept in this long is a failed spawn"}, {"key": "rpc_timeout_ms", "type": "int", "default": 5000, "unit": "ms", "meaning": "a worker that does not answer in this long is killed"}, {"key": "backoff_base_ms", "type": "int", "default": 250, "unit": "ms", "meaning": "respawn backoff, doubling per death in the window"}, diff --git a/src/describe.c b/src/describe.c index eb49c3c..039a57a 100644 --- a/src/describe.c +++ b/src/describe.c @@ -884,6 +884,15 @@ const char *describe_format(const char *target) return NULL; } +const char *describe_id(const char *target) +{ + const char *format = describe_format(target); + + if (format == NULL) + return NULL; + return !strcmp(format, "clap") ? strrchr(target, '#') + 1 : target + 4; +} + int describe_dump(const char *table_text, const char *dump_text, const char *target, sb_t *out, char *why, size_t why_size) { diff --git a/src/describe.h b/src/describe.h index e856807..3ab2a8e 100644 --- a/src/describe.h +++ b/src/describe.h @@ -18,6 +18,9 @@ /* the format of a target, clap:# or lv2:: "clap", "lv2", or NULL */ const char *describe_format(const char *target); +/* the plugin id a target names: the CLAP id after its last '#', or the LV2 URI; a pointer into target, or NULL */ +const char *describe_id(const char *target); + /* the scanned descriptor of a dump: the format table's text, the dump's text and the target asked for; 0 with the * descriptor in out, -1 refused (a table it cannot read, a dump it refuses) with why */ int describe_dump(const char *table_text, const char *dump_text, const char *target, sb_t *out, char *why, diff --git a/src/descriptors.c b/src/descriptors.c new file mode 100644 index 0000000..ff1faf4 --- /dev/null +++ b/src/descriptors.c @@ -0,0 +1,446 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* + * This file is part of plugin-hostd. + * + * plugin-hostd is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * plugin-hostd is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with plugin-hostd. If not, see . + * + * Copyright (C) 2026 Pau Aliagas + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "describe.h" +#include "descriptors.h" +#include "formats.h" +#include "json.h" +#include "proc.h" +#include "supervisor.h" + +/* one scan, by the files it describes: running while pid > 0, then SUCCESS or the code it ended with */ +typedef struct SCAN_T { + char format[16]; + char *id; + char hash[PHD_DESCRIPTOR_HASH_LEN + 1]; + char *uri; + char tmp[PATH_MAX]; + char log[PATH_MAX]; + pid_t pid; + int code; +} scan_t; + +static conf_t *g_conf; +static scan_t *g_scans; +static int g_nscans; +static unsigned g_seq; + +/* ---------------------------------------------------------------- the directories */ + +static void state_default(char *dir, size_t size, const char *subdir) +{ + const char *state = getenv("XDG_STATE_HOME"), *home = getenv("HOME"); + + if (state && *state) + snprintf(dir, size, "%s/%s", state, subdir); + else if (geteuid() == 0 || !home || !*home) + snprintf(dir, size, PHD_DIR_SYSTEM_STATE_HOME "/%s", subdir); + else + snprintf(dir, size, "%s/" PHD_DIR_USER_STATE_HOME "/%s", home, subdir); +} + +/* the directory and its parents, each made PHD_DIR_MODE when missing; 0, or -1 with errno */ +static int make_dirs(const char *path) +{ + char buf[PATH_MAX], *slash; + struct stat st; + + if (snprintf(buf, sizeof(buf), "%s", path) >= (int)sizeof(buf)) + { + errno = ENAMETOOLONG; + return -1; + } + for (slash = buf + 1; (slash = strchr(slash, '/')); slash++) + { + *slash = '\0'; + if (mkdir(buf, PHD_DIR_MODE) != 0 && errno != EEXIST) + return -1; + *slash = '/'; + } + if (mkdir(buf, PHD_DIR_MODE) != 0 && errno != EEXIST) + return -1; + if (stat(buf, &st) != 0) + return -1; + if (!S_ISDIR(st.st_mode)) + { + errno = ENOTDIR; + return -1; + } + return 0; +} + +/* whether a file can be made in the directory: a write is tried, which a read-only mount refuses even to root */ +static int writable(const char *dir) +{ + char probe[PATH_MAX]; + int fd; + + snprintf(probe, sizeof(probe), "%s/.plugin-hostd-write.%d", dir, (int)getpid()); + fd = open(probe, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (fd < 0) + return -1; + close(fd); + unlink(probe); + return 0; +} + +static int dir_ready(const char *key, char *dir, size_t size, const char *subdir) +{ + if (!*dir) + state_default(dir, size, subdir); + if (make_dirs(dir) != 0 || writable(dir) != 0) + { + fprintf(stderr, "plugin-hostd: %s %s: %s: resp %d\n", key, dir, strerror(errno), PHD_ERR_DIR_NOT_WRITABLE); + return PHD_ERR_DIR_NOT_WRITABLE; + } + return SUCCESS; +} + +int descriptors_init(conf_t *conf) +{ + g_conf = conf; + if (dir_ready("descriptor_dir", conf->descriptor_dir, sizeof(conf->descriptor_dir), PHD_DIR_DESCRIPTORS_SUBDIR)) + return PHD_ERR_DIR_NOT_WRITABLE; + if (dir_ready("qualification_dir", conf->qualification_dir, sizeof(conf->qualification_dir), + PHD_DIR_QUALIFICATION_SUBDIR)) + return PHD_ERR_DIR_NOT_WRITABLE; + return SUCCESS; +} + +/* ---------------------------------------------------------------- what a plugin's files are */ + +/* every character outside [A-Za-z0-9._-] as PHD_DESCRIPTOR_SAFE_CHAR, as plugin-qualify's scanners write it */ +static void encode(const char *in, char *out, size_t size) +{ + size_t o = 0; + + for (; *in && o + 1 < size; in++) + { + unsigned char c = (unsigned char)*in; + + out[o++] = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || + c == '_' || c == '-' ? (char)c : PHD_DESCRIPTOR_SAFE_CHAR; + } + out[o] = '\0'; +} + +/* The scan's target (describe's clap:# or lv2:), its format, id and the hash of its binary as it is + * now. 0, or -1 for a plugin whose binary cannot be found or read, or a format with no binary or that describe does + * not know. */ +static int identify(const char *uri, char *target, size_t tsize, const char **format, const char **id, + char hash[PHD_DESCRIPTOR_HASH_LEN + 1]) +{ + const char *fwd; + int fmt = format_of_uri(uri, &fwd), fd, rc = -1; + const format_t *f = fmt >= 0 ? format_at(fmt) : NULL; + char binary[PATH_MAX], hex[PHD_SHA256_HEX_LEN + 1]; + struct stat st; + + if (!f || !f->binary) + return -1; + if (f->prefix && strncmp(fwd, f->prefix, strlen(f->prefix))) + snprintf(target, tsize, "%s%s", f->prefix, fwd); + else + snprintf(target, tsize, "%s", fwd); + if ((*format = describe_format(target)) == NULL || (*id = describe_id(target)) == NULL) + return -1; + if (f->binary(fwd, f->search ? f->search(g_conf) : NULL, binary, sizeof(binary)) != 0) + return -1; + if ((fd = open(binary, O_RDONLY | O_CLOEXEC)) < 0) + return -1; + if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && phd_pin_fd_digest(fd, hex) == 0) + { + snprintf(hash, PHD_DESCRIPTOR_HASH_LEN + 1, "%s", hex); + rc = 0; + } + close(fd); + return rc; +} + +/* the descriptor of the binary, by its name's id and hash, into path; 0, or -1 when there is none */ +static int lookup(const char *id, const char *hash, char *path, size_t size) +{ + const char *dir = g_conf->descriptor_dir; + char prefix[PATH_MAX], suffix[PHD_DESCRIPTOR_HASH_LEN + 8]; + size_t plen, slen; + struct dirent *e; + DIR *d; + int found = -1; + + encode(id, prefix, sizeof(prefix) - 1); + strcat(prefix, "@"); + snprintf(suffix, sizeof(suffix), "-%s.json", hash); + plen = strlen(prefix); + slen = strlen(suffix); + if ((d = opendir(dir)) == NULL) + return -1; + while (found && (e = readdir(d)) != NULL) + { + size_t len = strlen(e->d_name); + + if (len > plen + slen && !strncmp(e->d_name, prefix, plen) && !strcmp(e->d_name + len - slen, suffix)) + { + snprintf(path, size, "%s/%s", dir, e->d_name); + found = 0; + } + } + closedir(d); + return found; +} + +static scan_t *scan_of(const char *format, const char *id, const char *hash) +{ + int n; + + for (n = 0; n < g_nscans; n++) + if (!strcmp(g_scans[n].format, format) && !strcmp(g_scans[n].id, id) && !strcmp(g_scans[n].hash, hash)) + return &g_scans[n]; + return NULL; +} + +/* ---------------------------------------------------------------- the scan */ + +static void scan_failed(scan_t *s, int code, const char *why) +{ + fprintf(stderr, "plugin-hostd: %s: the scan wrote no descriptor (%s), its log %s: resp %d\n", s->uri, why, + s->log, code); + if (*s->tmp) + unlink(s->tmp); + s->tmp[0] = '\0'; + s->pid = 0; + s->code = code; +} + +static void scan_start(scan_t *s, const char *target) +{ + const char *dir = g_conf->descriptor_dir; + int out, log, null; + pid_t pid; + + s->code = PHD_ERR_DESCRIPTOR_PENDING; + s->tmp[0] = '\0'; + snprintf(s->log, sizeof(s->log), "%s/scan-%s-%s.log", sup_root(), s->format, s->hash); + if (!proc_have_binary(g_conf->probe)) + { + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, "the probe is not installed"); + return; + } + snprintf(s->tmp, sizeof(s->tmp), "%s/.%s.%d.%u.tmp", dir, s->hash, (int)getpid(), g_seq++); + if (make_dirs(dir) != 0 || (out = open(s->tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600)) < 0) + { + char why[PATH_MAX + 64]; + + snprintf(why, sizeof(why), "descriptor_dir %s: %s", dir, strerror(errno)); + s->tmp[0] = '\0'; + scan_failed(s, PHD_ERR_DIR_NOT_WRITABLE, why); + return; + } + log = open(s->log, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + null = open("/dev/null", O_RDONLY | O_CLOEXEC); + pid = fork(); + if (pid == 0) + { + prctl(PR_SET_PDEATHSIG, SIGTERM); + if (getppid() == 1) + _exit(127); + dup2(null >= 0 ? null : out, STDIN_FILENO); + dup2(out, STDOUT_FILENO); + dup2(log >= 0 ? log : out, STDERR_FILENO); + signal(SIGPIPE, SIG_DFL); + execlp(g_conf->probe, g_conf->probe, "describe", target, (char *)NULL); + _exit(127); + } + close(out); + if (log >= 0) + close(log); + if (null >= 0) + close(null); + if (pid < 0) + { + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, strerror(errno)); + return; + } + s->pid = pid; +} + +void descriptors_add(const char *uri) +{ + char target[PATH_MAX + 16], hash[PHD_DESCRIPTOR_HASH_LEN + 1], path[PATH_MAX]; + const char *format, *id; + scan_t *s; + + if (!g_conf || identify(uri, target, sizeof(target), &format, &id, hash) != 0) + return; + if (lookup(id, hash, path, sizeof(path)) == 0) + return; + s = scan_of(format, id, hash); + if (s && s->pid > 0) + return; + if (!s) + { + g_scans = realloc(g_scans, (size_t)(g_nscans + 1) * sizeof(scan_t)); + s = &g_scans[g_nscans++]; + memset(s, 0, sizeof(*s)); + snprintf(s->format, sizeof(s->format), "%s", format); + snprintf(s->hash, sizeof(s->hash), "%s", hash); + s->id = strdup(id); + } + free(s->uri); + s->uri = strdup(uri); + scan_start(s, target); +} + +/* the scan's stdout is a descriptor: named for its version and moved into place, written once */ +static void scan_ended(scan_t *s, int status) +{ + char final[PATH_MAX], enc_id[PATH_MAX / 2], enc_version[256], why[64]; + const char *dir = g_conf->descriptor_dir; + sb_t text = { 0 }; + jnode_t *doc; + const char *version; + int fd; + + s->pid = 0; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + { + if (WIFSIGNALED(status)) + snprintf(why, sizeof(why), "killed by signal %d", WTERMSIG(status)); + else + snprintf(why, sizeof(why), "the probe exited %d", WEXITSTATUS(status)); + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, why); + return; + } + if ((fd = open(s->tmp, O_RDONLY | O_CLOEXEC)) >= 0) + { + char buf[65536]; + ssize_t n; + + while ((n = read(fd, buf, sizeof(buf))) > 0) + sb_put(&text, buf, (size_t)n); + if (fsync(fd) != 0) + text.len = 0; + close(fd); + } + doc = text.len ? jparse(text.s) : NULL; + free(text.s); + if (!doc || doc->type != J_OBJ || !jget(doc, "plugin")) + { + jfree(doc); + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, "its output is no descriptor"); + return; + } + version = jstr(jget(doc, "plugin"), "version"); + encode(s->id, enc_id, sizeof(enc_id)); + encode(version && *version ? version : PHD_DESCRIPTOR_NO_VERSION, enc_version, sizeof(enc_version)); + jfree(doc); + snprintf(final, sizeof(final), "%s/%s@%s-%s.json", dir, enc_id, enc_version, s->hash); + if (access(final, F_OK) == 0) + unlink(s->tmp); + else if (rename(s->tmp, final) != 0) + { + snprintf(why, sizeof(why), "%s", strerror(errno)); + scan_failed(s, PHD_ERR_DIR_NOT_WRITABLE, why); + return; + } + else if ((fd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC)) >= 0) + { + fsync(fd); + close(fd); + } + s->tmp[0] = '\0'; + s->code = SUCCESS; + unlink(s->log); +} + +void descriptors_tick(void) +{ + int n, status; + + for (n = 0; n < g_nscans; n++) + if (g_scans[n].pid > 0 && waitpid(g_scans[n].pid, &status, WNOHANG) == g_scans[n].pid) + scan_ended(&g_scans[n], status); +} + +/* ---------------------------------------------------------------- descriptor_get */ + +char *descriptors_get(const char *uri) +{ + char target[PATH_MAX + 16], hash[PHD_DESCRIPTOR_HASH_LEN + 1], path[PATH_MAX], *r, *o; + const char *format, *id, *p; + scan_t *s; + + if (!g_conf || identify(uri, target, sizeof(target), &format, &id, hash) != 0) + return sup_resp(PHD_ERR_DESCRIPTOR_ABSENT); + if (lookup(id, hash, path, sizeof(path)) != 0) + { + s = scan_of(format, id, hash); + return sup_resp(s ? s->code : PHD_ERR_DESCRIPTOR_ABSENT); + } + /* the path is one quoted word, as mod-host's tokenizer reads it */ + r = malloc(strlen(path) * 2 + 16); + if (!r) + return NULL; + o = r + sprintf(r, "resp 0 \""); + for (p = path; *p; p++) + { + if (*p == '"' || *p == '\\') + *o++ = '\\'; + *o++ = *p; + } + strcpy(o, "\""); + return r; +} + +void descriptors_finish(void) +{ + int n; + + for (n = 0; n < g_nscans; n++) + { + if (g_scans[n].pid > 0) + { + kill(g_scans[n].pid, SIGTERM); + waitpid(g_scans[n].pid, NULL, 0); + } + if (*g_scans[n].tmp) + unlink(g_scans[n].tmp); + free(g_scans[n].id); + free(g_scans[n].uri); + } + free(g_scans); + g_scans = NULL; + g_nscans = 0; +} diff --git a/src/descriptors.h b/src/descriptors.h new file mode 100644 index 0000000..e3edb00 --- /dev/null +++ b/src/descriptors.h @@ -0,0 +1,33 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* + * This file is part of plugin-hostd. + * + * Copyright (C) 2026 Pau Aliagas + */ + +/* The scanned descriptors of the plugins a controller adds: descriptor_dir and qualification_dir, the scan an add runs + * in a child of its own, and descriptor_get. A descriptor describes a plugin; it gates no load, so no add waits for its scan or is refused by it. */ + +#ifndef PLUGIN_HOSTD_DESCRIPTORS_H +#define PLUGIN_HOSTD_DESCRIPTORS_H + +#include "conf.h" + +/* descriptor_dir and qualification_dir resolved (an empty setting is the default of protocol.h), made when missing + * and tried for a write; SUCCESS, or PHD_ERR_DIR_NOT_WRITABLE with the key, the path and why on stderr. The resolved + * paths are written back into conf, which is kept for the scans. */ +int descriptors_init(conf_t *conf); + +/* the add of uri was answered: unless its files already have a descriptor or a scan of them runs, start one */ +void descriptors_add(const char *uri); + +/* reap the scans that ended: a descriptor is named and moved into place, a failure is recorded */ +void descriptors_tick(void); + +/* descriptor_get : resp 0 "", or the code of protocol.h; malloc'd */ +char *descriptors_get(const char *uri); + +/* stop the scans still running and remove what they were writing */ +void descriptors_finish(void); + +#endif diff --git a/src/formats.c b/src/formats.c index 513c9e2..b4d3ed5 100644 --- a/src/formats.c +++ b/src/formats.c @@ -62,6 +62,18 @@ int clap_pin_collect(const char *uri, const char *search, char *root, size_t siz return SUCCESS; } +int clap_binary(const char *uri, const char *search, char *path, size_t size) +{ + const char *file = uri + strlen(CLAP_PREFIX), *hash = strrchr(file, '#'); + size_t len = hash ? (size_t)(hash - file) : strlen(file); + + (void)search; + if (strncmp(uri, CLAP_PREFIX, strlen(CLAP_PREFIX)) || !len || file[0] != '/' || len >= size) + return -1; + snprintf(path, size, "%.*s", (int)len, file); + return 0; +} + /* The built-in rows, in the order of PHD_FORMATS. */ static const format_t g_builtin[] = { { @@ -74,6 +86,7 @@ static const format_t g_builtin[] = { .world_env = "LV2_PATH", .narrow_world = lv2_narrow_world, .pin_collect = lv2_pin_collect, + .binary = lv2_binary, .route = lv2_route, .layout_check = 0, .pinned_own_only = 1, @@ -87,6 +100,7 @@ static const format_t g_builtin[] = { .fallback = 0, .worker = clap_worker, .pin_collect = clap_pin_collect, + .binary = clap_binary, .layout_check = 1, .pinned_own_only = 0, .native_instance_verbs = 1, @@ -108,6 +122,7 @@ static const format_t g_builtin[] = { .world_env = "LV2_PATH", .narrow_world = lv2_narrow_world, .pin_collect = lv2_pin_collect, + .binary = lv2_binary, .layout_check = 0, .pinned_own_only = 1, .native_instance_verbs = 1, diff --git a/src/formats.h b/src/formats.h index 7dd5ad6..941b6db 100644 --- a/src/formats.h +++ b/src/formats.h @@ -52,6 +52,9 @@ typedef struct FORMAT_T { /* the root and the relative files a host loads for the uri: what a pin holds. SUCCESS, or * PHD_ERR_PIN_BINARY_MISMATCH with the reason on stderr for what cannot be pinned */ int (*pin_collect)(const char *uri, const char *search, char *root, size_t size, names_t *files); + /* the one file the host loads the plugin's code from, as a path: what the name of its scanned descriptor hashes. 0, + * or -1 when it cannot be told; NULL for a format whose plugins get no descriptor */ + int (*binary)(const char *uri, const char *search, char *path, size_t size); int layout_check; /* the worker takes pin_expect before the add and pin_layout after it */ int pinned_own_only; /* a pinned plugin runs in a worker of its own: the pin holds in no shared world */ @@ -79,7 +82,11 @@ int format_route(const conf_t *conf, int format, const char *uri); /* the pin root and files of a CLAP: the directory of the .clap and the file */ int clap_pin_collect(const char *uri, const char *search, char *root, size_t size, names_t *files); +/* the .clap file of clap:# */ +int clap_binary(const char *uri, const char *search, char *path, size_t size); + /* the LV2 format's parts, in lv2.c */ +int lv2_binary(const char *uri, const char *search, char *path, size_t size); int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size, names_t *files); char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *const *uris, int n); char *lv2_param_info(int id, const char *symbol); diff --git a/src/json.c b/src/json.c index ee24dd2..9e85755 100644 --- a/src/json.c +++ b/src/json.c @@ -163,6 +163,24 @@ static jnode_t *jnew(jtype_t t) return n; } +void jfree(jnode_t *n) +{ + size_t i; + + if (n == NULL) + return; + for (i = 0; i < n->n; i++) + { + jfree(n->kid[i]); + if (n->key) + free(n->key[i]); + } + free(n->kid); + free(n->key); + free(n->str); + free(n); +} + static jnode_t *jparse_value(const char **p, int depth) { jnode_t *n; diff --git a/src/json.h b/src/json.h index 7ef5695..a940d45 100644 --- a/src/json.h +++ b/src/json.h @@ -39,6 +39,8 @@ typedef struct jnode /* the whole text is one value, or NULL */ jnode_t *jparse(const char *text); +/* a value jparse returned, and everything under it */ +void jfree(jnode_t *n); jnode_t *jget(const jnode_t *o, const char *key); const char *jstr(const jnode_t *o, const char *key); int jnum(const jnode_t *o, const char *key, double *out); diff --git a/src/lv2.c b/src/lv2.c index 5866154..afa9c37 100644 --- a/src/lv2.c +++ b/src/lv2.c @@ -240,8 +240,10 @@ static void bundle_path(const scan_t *s, const char *bundle, const char *iri, ch enum { WANT_SUBJECT, WANT_PREDICATE, WANT_OBJECT, AFTER_OBJECT }; -/* the files the manifest names with lv2:binary and rdfs:seeAlso for the plugin; whether it sets a base */ -static int manifest_files(const char *text, size_t size, const char *bundle, const char *uri, names_t *names) +/* the files the manifest names with lv2:binary and rdfs:seeAlso for the plugin, or with lv2:binary alone when + * binary_only; whether it sets a base */ +static int manifest_files(const char *text, size_t size, const char *bundle, const char *uri, names_t *names, + int binary_only) { scan_t *s = calloc(1, sizeof(scan_t)); token_t t; @@ -284,7 +286,7 @@ static int manifest_files(const char *text, size_t size, const char *bundle, con } else if (state == WANT_PREDICATE) { - pred = t.kind == T_IRI && (!strcmp(t.text, LV2_BINARY) || !strcmp(t.text, RDFS_SEEALSO)); + pred = t.kind == T_IRI && (!strcmp(t.text, LV2_BINARY) || (!binary_only && !strcmp(t.text, RDFS_SEEALSO))); state = WANT_OBJECT; } else if (state == WANT_OBJECT) @@ -351,7 +353,7 @@ int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size return pin_refuse(uri, MANIFEST, "can't be read as a manifest"); /* the host resolves a base and decodes a percent-encoded name; the daemon does neither, so it cannot know the file * the host would load */ - if (manifest_files(manifest, manifest_size, root, bare(uri), &names)) + if (manifest_files(manifest, manifest_size, root, bare(uri), &names, 0)) rc = pin_refuse(uri, MANIFEST, "sets a base the daemon does not resolve"); names_add(files, MANIFEST); for (n = 0; n < names.n && rc == SUCCESS; n++) @@ -366,6 +368,29 @@ int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size return rc; } +/* the one lv2:binary the manifest of the plugin's bundle names for it */ +int lv2_binary(const char *uri, const char *search, char *path, size_t size) +{ + names_t names = { NULL, 0 }; + char *bundle = proc_find_lv2_bundle(search, bare(uri)), *manifest = NULL; + size_t manifest_size = 0; + int rc = -1; + + if (!bundle) + return -1; + if (read_manifest(bundle, &manifest, &manifest_size) == 0 && + !manifest_files(manifest, manifest_size, bundle, bare(uri), &names, 1) && names.n == 1 && + !strstr(names.name[0], PHD_PIN_PERCENT)) + { + snprintf(path, size, "%s/%s", bundle, names.name[0]); + rc = 0; + } + names_free(&names); + free(manifest); + free(bundle); + return rc; +} + /* an own LV2 worker sees a world of one bundle, a chain's worker the union of its members': a directory holding a link * to each bundle that names one of the URIs, once; NULL when a URI's bundle is not found */ char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *const *uris, int n) diff --git a/src/main.c b/src/main.c index e7022de..8cd9ed7 100644 --- a/src/main.c +++ b/src/main.c @@ -28,6 +28,7 @@ #include #include "conf.h" +#include "descriptors.h" #include "pins.h" #include "relay.h" #include "supervisor.h" @@ -46,6 +47,7 @@ static conf_t g_conf; static void idle_cb(void) { sup_tick(); + descriptors_tick(); if (g_quitting) { running = 0; @@ -138,6 +140,9 @@ int main(int argc, char **argv) if (conf_check(&g_conf) != 0) return EXIT_FAILURE; + /* the directories of what an add gathers are made, or the daemon does not start: never a silent fallback */ + if (descriptors_init(&g_conf) != 0) + return EXIT_FAILURE; if (g_conf.require_pins && pins_init(g_conf.pin_store, g_conf.pin_file, g_conf.pins_dir) != 0) return EXIT_FAILURE; @@ -168,6 +173,7 @@ int main(int argc, char **argv) } socket_finish(); + descriptors_finish(); sup_finish(); relay_finish(); return 0; diff --git a/src/supervisor.c b/src/supervisor.c index 1509009..6bf6e9c 100644 --- a/src/supervisor.c +++ b/src/supervisor.c @@ -1104,6 +1104,11 @@ void sup_init(const conf_t *conf) } } +const char *sup_root(void) +{ + return g_root; +} + void sup_finish(void) { int n; diff --git a/src/supervisor.h b/src/supervisor.h index dc44401..e5a1b44 100644 --- a/src/supervisor.h +++ b/src/supervisor.h @@ -107,6 +107,8 @@ typedef struct CHAIN_T { } chain_t; void sup_init(const conf_t *conf); +/* this daemon's own directory under state_root: its checkpoints and the logs of its workers and scans */ +const char *sup_root(void); void sup_finish(void); /* mod-host's own "resp " text for a refusal; malloc'd */ diff --git a/src/verbs.c b/src/verbs.c index 1ad9a26..cede3bc 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -27,6 +27,7 @@ #include #include +#include "descriptors.h" #include "formats.h" #include "pins.h" #include "supervisor.h" @@ -138,8 +139,14 @@ static char *handle(char *line, char **tok, int ntok) client = tok[next++]; if (ntok > next) return sup_resp(ERR_INVALID_OPERATION); - return sup_add(tok[1], atoi(tok[2]), placement, client); + reply_text = sup_add(tok[1], atoi(tok[2]), placement, client); + /* the plugin is running: its files are scanned after the reply, never before it */ + if (reply_text && sup_reply_code(reply_text) >= 0) + descriptors_add(tok[1]); + return reply_text; } + if (!strcmp(verb, PHD_VERB_DESCRIPTOR_GET)) + return ntok == 2 ? descriptors_get(tok[1]) : sup_resp(ERR_INVALID_OPERATION); if (verb_is(verb, EFFECT_REMOVE)) { if (ntok != 2) diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 6fa241d..d94923a 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -2794,4 +2794,288 @@ def a_worker_that_runs_no_chain_refuses_the_line_and_the_members_still_share_it( d.close() + +# ---------------------------------------------------------------- descriptors + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROBE = os.path.abspath(os.environ.get("PROBE", os.path.join(ROOT, "plugin-hostd-probe"))) +FIELDS = os.path.join(ROOT, "tests", "fixtures", "fields") +CLAP_ID = "org.freemixer.plugin-qualify.fixture.controls" +LV2_COMPRESSOR = "urn:example:compressor" +SCANNER = """#!/bin/sh +# a stand-in for the probe: the real describe, over the recorded dump of the target's format +echo "$2" >> "$SCAN_LOG" +if [ -n "$SCAN_HOLD" ]; then while [ ! -e "$SCAN_HOLD" ]; do sleep 0.02; done; fi +if [ -n "$SCAN_EXIT" ]; then exit "$SCAN_EXIT"; fi +case "$2" in + clap:*) exec %(probe)s describe --table %(tables)s/clap.json --fields-from %(fields)s/clap-controls.tsv "$2" ;; + *) exec %(probe)s describe --table %(tables)s/lv2.json --fields-from %(fields)s/lv2-compressor.tsv "$2" ;; +esac +""" + + +def config_meaning(key): + return [c for c in harness.DECLARED["config"] if c["key"] == key][0] + + +def safe(text): + """protocol.h's PHD_DESCRIPTOR_NAME_SYNTAX, plugin-qualify's rule: every character outside [A-Za-z0-9._-] as _""" + return "".join(c if c.isascii() and (c.isalnum() or c in "._-") else "_" for c in text) + + +def descriptor_name(root, plugin_id, version, binary): + """@-.json""" + return os.path.join(root, "%s@%s-%s.json" % (safe(plugin_id), safe(version), sha(binary)[:12])) + + +class Scan: + """a CLAP file, an LV2 bundle of the compressor, and a scanner that logs every target it is run for""" + + def __init__(self): + self.dir = tempfile.mkdtemp(prefix="plugin-hostd-scan.") + self.clap = os.path.join(self.dir, "clap", "controls.clap") + os.makedirs(os.path.dirname(self.clap)) + write(self.clap, b"a clap build") + self.clap_uri = "clap:%s#%s" % (self.clap, CLAP_ID) + self.lv2_dir = os.path.join(self.dir, "lv2") + self.bundle = os.path.join(self.lv2_dir, "compressor.lv2") + os.makedirs(self.bundle) + write(os.path.join(self.bundle, "manifest.ttl"), (MANIFEST % LV2_COMPRESSOR).encode()) + write(os.path.join(self.bundle, "pinned.so"), b"lv2 binary") + write(os.path.join(self.bundle, "pinned.ttl"), b"lv2:port [ lv2:index 0 ] .") + self.scanner = os.path.join(self.dir, "scanner") + write(self.scanner, (SCANNER % {"probe": PROBE, "tables": os.path.join(ROOT, "data", "formats"), + "fields": FIELDS}).encode()) + os.chmod(self.scanner, 0o755) + self.log = os.path.join(self.dir, "scans.log") + self.descriptors = os.path.join(self.dir, "descriptors") + self.qualification = os.path.join(self.dir, "qualification") + + def daemon(self, conf=None, env=None): + c = {"probe": self.scanner, "lv2_path": self.lv2_dir, "descriptor_dir": self.descriptors, + "qualification_dir": self.qualification} + c.update(conf or {}) + e = {"SCAN_LOG": self.log} + e.update(env or {}) + return daemon(conf=c, env=e) + + def scans(self): + if not os.path.exists(self.log): + return [] + with open(self.log) as f: + return [l.strip() for l in f if l.strip()] + + def clap_name(self, root=None): + return descriptor_name(root or self.descriptors, CLAP_ID, "1", self.clap) + + def lv2_name(self): + # the compressor states no version; its binary is the file its manifest names with lv2:binary + return descriptor_name(self.descriptors, LV2_COMPRESSOR, "unversioned", os.path.join(self.bundle, "pinned.so")) + + def close(self): + shutil.rmtree(self.dir, ignore_errors=True) + + +def answered(d, uri, path): + return d.send("descriptor_get %s" % uri) == 'resp 0 "%s"' % path + + +def same_bytes(a, b): + with open(a, "rb") as f, open(b, "rb") as g: + return f.read() == g.read() + + +@test +def an_add_writes_the_descriptor_by_id_version_and_hash_and_descriptor_get_answers_from_it(): + s = Scan() + d = s.daemon() + try: + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_ABSENT")) + d.expect("add %s 0" % s.clap_uri, "resp 0") + name = s.clap_name() + wait_for(lambda: answered(d, s.clap_uri, name), "descriptor_get answers %s" % name) + check(same_bytes(name, os.path.join(FIELDS, "clap-controls.json")), + "the file is what describe writes for the plugin") + check(os.listdir(os.path.dirname(name)) == [os.path.basename(name)], "nothing else is left beside it: %s" + % os.listdir(os.path.dirname(name))) + for path in (s.descriptors, s.qualification): + check(os.stat(path).st_mode & 0o777 == 0o700, "%s is made 0700" % path) + # written once: a second instance of the same files reads the file and runs no scan + d.expect("add %s 1" % s.clap_uri, "resp 1") + d.expect("add lv2:%s 2" % LV2_COMPRESSOR, "resp 2") + wait_for(lambda: answered(d, "lv2:" + LV2_COMPRESSOR, s.lv2_name()), "the LV2 descriptor %s" % s.lv2_name()) + check(same_bytes(s.lv2_name(), os.path.join(FIELDS, "lv2-compressor.json")), "the LV2 file is describe's") + check(s.scans() == [s.clap_uri, "lv2:" + LV2_COMPRESSOR], "one scan per plugin: %s" % s.scans()) + # the name hashes the binary alone: a changed data file of the bundle keeps it + write(os.path.join(s.bundle, "pinned.ttl"), b"lv2:port [ lv2:index 1 ] .") + check(answered(d, "lv2:" + LV2_COMPRESSOR, s.lv2_name()), "a changed .ttl keeps the LV2 binary's descriptor") + # a changed binary is no longer described: its next add scans again under a new name; the old file stays + write(s.clap, b"another clap build") + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_ABSENT")) + d.expect("add %s 3" % s.clap_uri, "resp 3") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name()), "the new binary's descriptor") + check(s.clap_name() != name and os.path.exists(name), "a new file, and the old one kept") + check(len(s.scans()) == 3, "the changed binary was scanned: %s" % s.scans()) + finally: + d.close() + s.close() + + +@test +def a_scan_that_runs_answers_pending_and_one_that_fails_answers_its_code_while_the_add_stands(): + s = Scan() + hold = os.path.join(s.dir, "go") + d = s.daemon(env={"SCAN_HOLD": hold}) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: s.scans(), "the scan starts") + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_PENDING")) + d.expect("add %s 1" % s.clap_uri, "resp 1") + write(hold, b"") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name()), "the held scan ends") + check(len(s.scans()) == 1, "an add while the scan runs starts no second one: %s" % s.scans()) + finally: + d.close() + d = s.daemon(conf={"descriptor_dir": os.path.join(s.dir, "other")}, env={"SCAN_EXIT": "2"}) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: d.send("descriptor_get %s" % s.clap_uri) == resp("DESCRIPTOR_SCAN_FAILED"), + "the failed scan's code") + check(d.workers() and list(d.workers().values())[0]["inst"] == [0], "the instance runs") + check("the scan wrote no descriptor" in d.log() and "resp %d" % harness.CODE["DESCRIPTOR_SCAN_FAILED"] in d.log(), + "stderr says so with the code: %s" % d.log()[-400:]) + check(not [f for r, _, fs in os.walk(os.path.join(s.dir, "other")) for f in fs], "no file is left behind") + finally: + d.close() + d = s.daemon(conf={"descriptor_dir": os.path.join(s.dir, "third"), "probe": os.path.join(s.dir, "absent")}) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_SCAN_FAILED")) + d.expect("descriptor_get clap:fake#x", resp("DESCRIPTOR_ABSENT")) + d.expect("descriptor_get", "resp -902") + finally: + d.close() + s.close() + + +def start_refused(conf, env=None): + """the daemon run with a settings file that must stop it before it is ready: (exit status, output)""" + tmp = tempfile.mkdtemp(prefix="plugin-hostd-refused.") + path = os.path.join(tmp, "plugin-hostd.conf") + settings = {"state_root": tmp, "require_pins": 0, "pins_dir": "", "mod_host": FAKE, "clap_host": FAKE} + settings.update(conf) + with open(path, "w") as f: + for k, v in settings.items(): + if v is not None: + f.write("%s %s\n" % (k, v)) + e = dict(os.environ) + e.update(env or {}) + cmd, fb = harness.port_pair() + try: + p = subprocess.run([EXE, "-n", "-p", str(cmd), "-f", str(fb), "-c", path], capture_output=True, text=True, + timeout=10, env=e) + return p.returncode, p.stdout + p.stderr + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +@test +def a_read_only_directory_stops_the_daemon_with_its_code_and_names_the_key(): + if os.geteuid() == 0: + print(" skip: root writes a 0500 directory") + return + s = Scan() + try: + for key, other in (("descriptor_dir", "qualification_dir"), ("qualification_dir", "descriptor_dir")): + ro = os.path.join(s.dir, "ro-" + key) + os.makedirs(ro) + os.chmod(ro, 0o500) + for path in (ro, os.path.join(ro, "below")): + rc, out = start_refused({key: path, other: os.path.join(s.dir, "fine")}) + check(rc != 0 and harness.READY_LINE not in out, "%s %s: the daemon does not start: %d %r" % (key, path, rc, out)) + check("%s %s" % (key, path) in out and "resp %d" % harness.CODE["DIR_NOT_WRITABLE"] in out, + "stderr names the key, the path and the code: %r" % out) + os.chmod(ro, 0o700) + # a path that is a file is no directory either + write(os.path.join(s.dir, "plain"), b"") + rc, out = start_refused({"descriptor_dir": os.path.join(s.dir, "plain"), + "qualification_dir": os.path.join(s.dir, "fine")}) + check(rc != 0 and "resp %d" % harness.CODE["DIR_NOT_WRITABLE"] in out, "a file in its place: %r" % out) + finally: + s.close() + + +@test +def a_directory_that_turns_read_only_makes_the_scan_answer_its_code(): + if os.geteuid() == 0: + print(" skip: root writes a 0500 directory") + return + s = Scan() + d = s.daemon() + try: + os.chmod(s.descriptors, 0o500) + d.expect("add %s 0" % s.clap_uri, "resp 0") + d.expect("descriptor_get %s" % s.clap_uri, resp("DIR_NOT_WRITABLE")) + check(s.scans() == [], "no scan ran: %s" % s.scans()) + os.chmod(s.descriptors, 0o700) + d.expect("add %s 1" % s.clap_uri, "resp 1") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name()), "the next add scans again") + finally: + os.chmod(s.descriptors, 0o700) + d.close() + s.close() + + +@test +def a_missing_key_falls_to_the_documented_default_and_the_key_holds_over_the_variable(): + s = Scan() + try: + # the defaults as protocol.json documents them, the same text the man page and the README carry + declared = {} + for key in ("descriptor_dir", "qualification_dir"): + meaning = config_meaning(key)["meaning"] + check(config_meaning(key)["default"] == "", "%s's declared default is empty, resolved at start" % key) + xdg = meaning.split("empty is $XDG_STATE_HOME/", 1)[1].split(",", 1)[0] + home = meaning.split("else $HOME/", 1)[1].split(";", 1)[0] + declared[key] = (xdg, home) + cases = [ + ({"XDG_STATE_HOME": os.path.join(s.dir, "xdg"), "HOME": os.path.join(s.dir, "home")}, 0), + ({"XDG_STATE_HOME": "", "HOME": os.path.join(s.dir, "home")}, 1), + ] + for env, which in cases: + if which == 1 and os.geteuid() == 0: + # a root daemon without $XDG_STATE_HOME keeps its state in /var/lib, which a test must not write; + # the $HOME branch is exercised by the non-root runs + print("skip: the $HOME default, the euid is 0 and the daemon then picks /var/lib") + continue + env = dict(env, OMX_DESCRIPTOR_DIR="", OMX_QUALIFICATION_DIR="") + base = env["XDG_STATE_HOME"] or env["HOME"] + d = s.daemon(conf={"descriptor_dir": None, "qualification_dir": None}, env=env) + try: + want = {k: os.path.join(base, v[which]) for k, v in declared.items()} + check(os.path.isdir(want["qualification_dir"]), "qualification_dir is made at %s" % want["qualification_dir"]) + d.expect("add %s 0" % s.clap_uri, "resp 0") + name = s.clap_name(want["descriptor_dir"]) + wait_for(lambda: answered(d, s.clap_uri, name), "the descriptor at the default %s" % name) + finally: + d.close() + # the variable stands in for the default; the key in the settings file holds over it + env = {"OMX_DESCRIPTOR_DIR": os.path.join(s.dir, "by-env"), "OMX_QUALIFICATION_DIR": os.path.join(s.dir, "q-env")} + d = s.daemon(conf={"descriptor_dir": None, "qualification_dir": None}, env=env) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name(env["OMX_DESCRIPTOR_DIR"])), "the variable's directory") + check(os.path.isdir(env["OMX_QUALIFICATION_DIR"]), "the qualification variable's directory") + finally: + d.close() + d = s.daemon(conf={"descriptor_dir": os.path.join(s.dir, "by-key")}, env=env) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name(os.path.join(s.dir, "by-key"))), "the key's directory") + finally: + d.close() + finally: + s.close() + + sys.exit(run_tests(TESTS)) diff --git a/tests/harness.py b/tests/harness.py index a25586e..83fe4d5 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -83,6 +83,11 @@ def __init__(self, exe, worker, clap_worker=None, conf=None, env=None, feedback= "lv2_worker": "mod-host", # no package's pins: the system's pins_dir is not the test's "pins_dir": "", + # what an add gathers goes to the test's own directories, never the user's state; no scanner runs unless + # a test names one + "descriptor_dir": os.path.join(self.tmp, "descriptors"), + "qualification_dir": os.path.join(self.tmp, "qualification"), + "probe": os.path.join(self.tmp, "no-probe"), } settings.update(conf or {}) self.conf = os.path.join(self.tmp, "plugin-hostd.conf") diff --git a/tests/sabotage.py b/tests/sabotage.py index 3ea074a..9d609e5 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -205,8 +205,8 @@ " .layout_check = 0,\n .pinned_own_only = 1,\n .native_instance_verbs = 1,", " .layout_check = 0,\n .pinned_own_only = 0,\n .native_instance_verbs = 1,", "the_adapter_row_keeps_the_pin_rules_of_the_lv2_row_and_checks_no_layout"), ("the adapter row's worker sees every bundle", "src/formats.c", - " .narrow_world = lv2_narrow_world,\n .pin_collect = lv2_pin_collect,\n .layout_check = 0,", - " .pin_collect = lv2_pin_collect,\n .layout_check = 0,", + " .narrow_world = lv2_narrow_world,\n .pin_collect = lv2_pin_collect,\n .binary = lv2_binary,\n .layout_check = 0,", + " .pin_collect = lv2_pin_collect,\n .binary = lv2_binary,\n .layout_check = 0,", "the_adapter_row_keeps_the_pin_rules_of_the_lv2_row_and_checks_no_layout"), ("the adapter's refusal answers the worker's own error", "src/supervisor.c", " if (!automatic)\n return sup_resp(PHD_ERR_LV2_ADAPTER_REFUSED);", " if (!automatic)\n return sup_resp(ERR_HOST_INSTANTIATION);", @@ -661,6 +661,26 @@ 'chain_line(w, msg, sizeof(msg)) && replay_line(w, msg) != RPC_OK)', 'chain_line(w, msg, sizeof(msg)) && (replay_line_code(w, msg, &code) != RPC_OK || code < 0))', "a_worker_that_runs_no_chain_refuses_the_line_and_the_members_still_share_it"), + ("a descriptor_dir or qualification_dir that cannot be written is let through", "src/descriptors.c", + " if (make_dirs(dir) != 0 || writable(dir) != 0)\n", " if (make_dirs(dir) != 0 && 0)\n", + "a_read_only_directory_stops_the_daemon_with_its_code_and_names_the_key"), + ("an empty descriptor_dir ignores $XDG_STATE_HOME", "src/descriptors.c", + " if (state && *state)\n snprintf(dir, size, \"%s/%s\", state, subdir);", + " if (0)\n snprintf(dir, size, \"%s/%s\", state, subdir);", + "a_missing_key_falls_to_the_documented_default_and_the_key_holds_over_the_variable"), + ("the binary of an LV2 plugin is looked for among its data files too", "src/lv2.c", + "(!binary_only && !strcmp(t.text, RDFS_SEEALSO))", "!strcmp(t.text, RDFS_SEEALSO)", + "an_add_writes_the_descriptor_by_id_version_and_hash_and_descriptor_get_answers_from_it"), + ("an add scans a binary that already has a descriptor", "src/descriptors.c", + " if (lookup(id, hash, path, sizeof(path)) == 0)\n return;\n", "", + "an_add_writes_the_descriptor_by_id_version_and_hash_and_descriptor_get_answers_from_it"), + ("a scan that runs is answered absent", "src/descriptors.c", + "return sup_resp(s ? s->code : PHD_ERR_DESCRIPTOR_ABSENT);", "return sup_resp(PHD_ERR_DESCRIPTOR_ABSENT);", + "a_scan_that_runs_answers_pending_and_one_that_fails_answers_its_code_while_the_add_stands"), + ("a directory that turns read-only is answered absent", "src/descriptors.c", + " scan_failed(s, PHD_ERR_DIR_NOT_WRITABLE, why);\n return;\n }\n log =", + " scan_failed(s, PHD_ERR_DESCRIPTOR_ABSENT, why);\n return;\n }\n log =", + "a_directory_that_turns_read_only_makes_the_scan_answer_its_code"), ("the preset schema takes a format version it does not know", "protocol/preset.schema.json", '"enum": ["org.openmixer.preset/1", "org.openmixer.preset/2"]', '"type": "string"', "an unknown format version is refused", "presetschema"), @@ -789,6 +809,9 @@ def run_test(tree, name, jack=False): failed = 0 +# the tests that need a directory root cannot be refused, and so skip themselves as root +ROOT_WRITES_READ_ONLY = ("a_read_only_directory_stops_the_daemon_with_its_code_and_names_the_key", + "a_directory_that_turns_read_only_makes_the_scan_answer_its_code") work = tempfile.mkdtemp(prefix="plugin-hostd-sabotage.") try: for entry in SABOTAGE: @@ -797,6 +820,9 @@ def run_test(tree, name, jack=False): if os.environ.get("SABOTAGE_ONLY") and os.environ["SABOTAGE_ONLY"] not in name: continue jack = entry[5] if len(entry) > 5 else False + if os.geteuid() == 0 and name in ROOT_WRITES_READ_ONLY: + print("skip sabotage (root writes a 0500 directory, so the test skips itself): %s" % what) + continue if jack == "meters" and not (os.environ.get("FAKE_COMPRESSOR_CLAP") and os.environ.get("JACK_METER_SOURCE")): print("skip sabotage (no FAKE_COMPRESSOR_CLAP and JACK_METER_SOURCE of omx-clap-host): %s" % what) continue diff --git a/tests/socket_e2e.sh b/tests/socket_e2e.sh index f3b24d2..89b2cb9 100755 --- a/tests/socket_e2e.sh +++ b/tests/socket_e2e.sh @@ -23,9 +23,11 @@ for f in "$daemon" "$script"; do done log=$(mktemp) -"$daemon" -n -p "$port" >"$log" 2>&1 & +# what an add gathers goes to a directory of the test's own, never the user's state +state=$(mktemp -d) +OMX_DESCRIPTOR_DIR=$state/descriptors OMX_QUALIFICATION_DIR=$state/qualification "$daemon" -n -p "$port" >"$log" 2>&1 & pid=$! -trap 'kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; rm -f "$log"' EXIT +trap 'kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; rm -rf "$log" "$state"' EXIT for _ in $(seq 50); do grep -q "ready" "$log" && break; sleep 0.1; done grep -q "ready" "$log" || { echo "plugin-hostd did not come up" >&2; cat "$log" >&2; exit 2; } echo "plugin-hostd on port $port, protocol library: $(grep -o '/[^ ]*libmod-host-protocol[^ ]*' /proc/$pid/maps | sort -u)" diff --git a/tools/protocol-gen.c b/tools/protocol-gen.c index 7f1dd9a..423b12a 100644 --- a/tools/protocol-gen.c +++ b/tools/protocol-gen.c @@ -525,6 +525,37 @@ static void region_man_keys(void) printf(".\n"); } +/* the directories of what an add gathers: each key with its default, and what the daemon does with them */ +static void region_man_dirs(void) +{ + char buffer[256]; + +#define X(key, size, def, env, meaning) \ + if (!strcmp(#key, "descriptor_dir") || !strcmp(#key, "qualification_dir")) \ + { \ + printf(".TP\n.B " #key "\n"); \ + roff_escape(meaning); \ + printf(".\n"); \ + if (*(env)) \ + printf("The variable\n.B $" env "\nstands in for the empty default; the key in the settings file holds over it.\n"); \ + } + PHD_CONF_STRINGS(X) +#undef X + printf(".PP\nA missing directory is made, mode %04o, with its parents. One that cannot be made or written stops the " + "daemon before it is ready: stderr names the key and the path with resp ", PHD_DIR_MODE); + snprintf(buffer, sizeof(buffer), "%d", PHD_ERR_DIR_NOT_WRITABLE); + roff_escape(buffer); + printf(".\n.PP\nThe answered\n.B add\nof a plugin whose files have no descriptor yet runs\n.I probe\n.B describe\n" + "in a child of its own and files its stdout as\n.IR \""); + roff_escape(PHD_DESCRIPTOR_NAME_SYNTAX); + printf("\" ,\nwritten once; the add never waits for it.\n.B " PHD_VERB_DESCRIPTOR_GET "\n.I uri\n" + "answers the file's path, or "); + snprintf(buffer, sizeof(buffer), "%d (none), %d (the scan runs), %d (the scan wrote nothing)", + PHD_ERR_DESCRIPTOR_ABSENT, PHD_ERR_DESCRIPTOR_PENDING, PHD_ERR_DESCRIPTOR_SCAN_FAILED); + roff_escape(buffer); + printf(".\n"); +} + static void region_man_ready(void) { printf(".B "); @@ -552,7 +583,7 @@ static const struct { { "verbs", region_verbs }, { "instance-verbs", region_instance_verbs }, { "host-verbs", region_host_verbs }, { "errors", region_errors }, { "events", region_events }, { "pin-limits", region_pin_limits }, { "man-pin-limits", region_man_pin_limits }, { "readiness", region_readiness }, { "worker-ready", region_worker_ready }, - { "man-keys", region_man_keys }, { "man-ready", region_man_ready }, { "man-port", region_man_port }, + { "man-keys", region_man_keys }, { "man-dirs", region_man_dirs }, { "man-ready", region_man_ready }, { "man-port", region_man_port }, { "relay", region_relay }, { "man-relay", region_man_relay }, { "host-settings", region_host_settings }, { "man-host-settings", region_man_host_settings }, };