diff --git a/Makefile b/Makefile index 1f3571a..a13f077 100644 --- a/Makefile +++ b/Makefile @@ -89,13 +89,15 @@ install: $(PROG) install_man install_protocol install_tests install -m 755 $(PROBE) $(DESTDIR)$(BINDIR) # the declared protocol: the JSON for readers that are not C, which the package carries; the header, its pkg-config -# file and the schema of the JSON, which the development package carries +# file and the schema of the JSON, which the development package carries; and the pins_dir other packages drop their +# pins in install_protocol: install -d $(DESTDIR)$(DATADIR)/plugin-hostd install -m 644 protocol/plugin-hostd.json $(DESTDIR)$(DATADIR)/plugin-hostd/protocol.json install -m 644 protocol/mod-host.json $(DESTDIR)$(DATADIR)/plugin-hostd/mod-host.json install -m 644 protocol/plugin-hostd.schema.json $(DESTDIR)$(DATADIR)/plugin-hostd/protocol.schema.json install -m 644 protocol/probe-output.schema.json $(DESTDIR)$(DATADIR)/plugin-hostd/probe-output.schema.json + install -d $(DESTDIR)$(DATADIR)/plugin-hostd/pins.d install -d $(DESTDIR)$(INCLUDEDIR)/plugin-hostd install -m 644 include/plugin-hostd/protocol.h $(DESTDIR)$(INCLUDEDIR)/plugin-hostd/protocol.h install -m 644 include/plugin-hostd/pin.h $(DESTDIR)$(INCLUDEDIR)/plugin-hostd/pin.h diff --git a/README.md b/README.md index cd2386d..8d33f76 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,10 @@ The settings live in a file, `-c`, `$PLUGIN_HOSTD_CONF` or | `lv2_worker` | `mod-host` | who runs an LV2 plugin: mod-host (the mod_host program), omx-clap-host (the clap_host program, through its LV2 adapter, a plugin it cannot run refused with a code) or auto (the adapter first, mod-host for what it refuses, each such plugin in a worker of its own unless its add names a placement); lv2_worker_set overrides it for one plugin | | `lv2_path` | `$LV2_PATH`, else `/usr/lib64/lv2:/usr/lib/lv2:/usr/local/lib/lv2` | where bundles are searched | | `state_root` | `$XDG_RUNTIME_DIR`, else `/tmp` | the daemon's checkpoints and worker logs | -| `pin_mode` | `first-use` | strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller or a pin_file pinned | +| `pin_mode` | `first-use` | strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller, a pin_file or pins_dir pinned | | `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 | | `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 | @@ -158,10 +159,10 @@ Verbs the daemon adds | `policy_set` | ` own \| pool:` | `resp 0` | the placement of an add that says default | | `pool_config` | ` ` | `resp 0` | the instances a pool holds before a sibling opens | | `worker_env` | ` ` | `resp 0` | the cpu list and the nice value of the workers of a format, at spawn and on the ones running | -| `pin_set` | ` =[,=...] : [verdict=]` | `resp 0` | the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri. The pin store and a pin_file hold the same line, with or without the verb | -| `pin_clear` | ` \| all` | `resp 0` | forget the pin of one plugin, or of every one | +| `pin_set` | ` =[,=...] : [verdict=]` | `resp 0` | the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri; -516 for a plugin whose pins_dir pin carries verdict=refused, which nothing overrides. The pin store, a pin_file and a pins_dir file hold the same line, with or without the verb | +| `pin_clear` | ` \| all` | `resp 0` | forget the pin of one plugin, or of every one; a pins_dir pin is read-only and holds again | | `lv2_worker_set` | ` ` | `resp 0` | the worker of one LV2 plugin, for its adds from now on: it overrides the lv2_worker setting, - takes the override back; -902 for a word that is none of these. Like policy_set it is the controller's, not in the ledger, gone with the daemon | -| `pin_accept` | `` | `resp 0` | pin_mode first-use only: take the plugin's files as they are now as its pin, after a pin_changed event; the layout is learned again by the next add and the verdict is dropped; -508 when the plugin has no pin, -509 when its files cannot be collected, -902 under pin_mode strict | +| `pin_accept` | `` | `resp 0` | pin_mode first-use only: take the plugin's files as they are now as its pin, after a pin_changed event; the layout is learned again by the next add and the verdict is dropped; -508 when the plugin has no pin, -509 when its files cannot be collected, -516 when its pins_dir pin carries verdict=refused, -902 under pin_mode strict | | `pin_layout` | `` | `resp 0 :` | 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 | | `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 | @@ -237,6 +238,7 @@ Error codes the daemon adds to mod-host's: | `-513` | `PHD_ERR_NO_NOTE_INPUT` | the instance's plugin takes no notes (an effect): its worker answers note_on, note_off and all_notes_off with this code | | `-514` | `PHD_ERR_NOTES_BY_JACK` | the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect | | `-515` | `PHD_ERR_NO_PRESET_LIST` | the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows | +| `-516` | `PHD_ERR_PIN_REFUSED` | the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first. A pins_dir pin of that verdict is never overridden: pin_set and pin_accept of its plugin answer this code | A worker's own refusal is returned as it said it. An `add` that killed its worker answers mod-host's `-102`. @@ -362,9 +364,30 @@ files, keeps the pin in the pin store (`$XDG_STATE_HOME/plugin-hostd/pins`) and asked of the worker after the add (`pin_layout`) and recorded too. A host that pins by itself sets `pin_mode strict`. The store, a `pin_file` and `pin_set` carry one line format, `pin_set [verdict=]` with the verb optional in a file. In `first-use` only a pin with `verdict=in-process`, which plugin-qualify writes, lets a plugin -share a worker. A plugin that changed since it was pinned is refused (-509, or -510 from the worker), the +share a worker. A pin with `verdict=refused` names files that failed qualification. Their `add` answers `-516` +(`PIN_REFUSED`), no worker sees it, and a replay is refused with the same code. A pin that changes to `refused` while +the instance runs takes effect at its next replay. Files that differ from the pin answer `-509` first, because the +verdict was given for the pinned files only. A plugin that changed since it was pinned is refused (-509, or -510 from the worker), the `pin_changed files|layout` event names what changed, and `pin_accept ` takes the new pin. +Pins that ship with packages live in a read-only layer of their own, under the host's. At start the daemon reads +every `*.pins` file of `pins_dir` (`/usr/share/plugin-hostd/pins.d` by default; empty is none), in byte order of +their names, in the same line format. A package drops its own file there, so its plugins and their pins are replaced +in one transaction and removed together; plugin-hostd owns the directory and another package may co-own it. The +daemon never writes it. A pin from this layer is trusted as it is, in both pin modes. The rules: + +- A pins.d pin with `verdict=refused` always holds. The pin store, a `pin_file` and `pin_set` never override it: + `pin_set` and `pin_accept` of its uri answer `-516` (`PIN_REFUSED`), and a line of the store or of the `pin_file` + for it is kept but has no effect, which stderr says at start. +- Any other pins.d pin is under the host's layer. A pin of the same uri in the store, the `pin_file` or from + `pin_set` holds over it, and `pin_accept` of a plugin whose files differ from its package pin records the new files + in the store, which then hold. `pin_clear` forgets the host's pin only: the package pin holds again. +- When two pins.d files hold the same uri, a refusal wins; otherwise the file last in name order wins. Two lines of + one file follow the same rule, the later line in place of the later file. +- A line outside the grammar, or longer than a pin line can be, is skipped and named on stderr; the rest of the file + is read. A file that cannot be read is named on stderr and skipped. A directory that is missing or empty holds no + pins and is no error. + What cannot be pinned: diff --git a/debian/plugin-hostd.dirs b/debian/plugin-hostd.dirs new file mode 100644 index 0000000..b075d71 --- /dev/null +++ b/debian/plugin-hostd.dirs @@ -0,0 +1 @@ +usr/share/plugin-hostd/pins.d diff --git a/doc/plugin-hostd.1 b/doc/plugin-hostd.1 index 51818b9..ee60e02 100644 --- a/doc/plugin-hostd.1 +++ b/doc/plugin-hostd.1 @@ -72,7 +72,7 @@ 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, 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, 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 PINS With require_pins on, @@ -90,6 +90,33 @@ A change after that is refused and announced by the event until .B pin_accept takes the new pin. +.PP +Packages ship pins in a read\-only layer: every +.I *.pins +file of +.B pins_dir +(by default +.IR /usr/share/plugin\-hostd/pins.d ) +is read at start, in name order, in the same line format. +A pin there with verdict=refused always holds: neither the pin store, the +.B pin_file +nor +.B pin_set +overrides it, and +.B pin_set +and +.B pin_accept +of its uri answer \-516. +Any other package pin is under the host's: a pin of the same uri in the store, the +.B pin_file +or from +.B pin_set +holds over it, and +.B pin_clear +lets the package pin hold again. +When two files hold the same uri a refusal wins, otherwise the last file by name. +A bad line is skipped and named on stderr; a missing or empty directory holds no pins. +.PP What cannot be pinned: .\" BEGIN GENERATED protocol:man-pin-limits .IP \(bu 2 diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index 33f2f37..4ed7e50 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -260,6 +260,7 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_ERR_NO_NOTE_INPUT (-513) #define PHD_ERR_NOTES_BY_JACK (-514) #define PHD_ERR_NO_PRESET_LIST (-515) +#define PHD_ERR_PIN_REFUSED (-516) /* X(id, meaning) */ #define PHD_ERRORS(X) \ @@ -294,7 +295,12 @@ static inline int phd_pool_name_valid(const char *name) "with connect") \ X(NO_PRESET_LIST, "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers " \ PHD_VERB_PRESET_LIST " and " PHD_VERB_PRESET_GET " with this code; preset_load still takes a preset " \ - "the controller knows") + "the controller knows") \ + X(PIN_REFUSED, "the plugin's pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED ": its files are the ones that " \ + "failed qualification, so add is refused and no worker sees it, and a replayed add is refused the " \ + "same way; files that differ from the pin answer " PHD_STR(PHD_ERR_PIN_BINARY_MISMATCH) " first. A pins_dir " \ + "pin of that verdict is never overridden: " PHD_VERB_PIN_SET " and " PHD_VERB_PIN_ACCEPT " of its plugin " \ + "answer this code") /* ---------------------------------------------------------------- verbs */ @@ -342,7 +348,7 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_PIN_MODE_STRICT "strict" #define PHD_PIN_MODE_FIRST_USE "first-use" #define PHD_PIN_MODES(X) \ - X(STRICT, PHD_PIN_MODE_STRICT, "add admits only a plugin that has a pin; a pin comes from pin_set or pin_file") \ + X(STRICT, PHD_PIN_MODE_STRICT, "add admits only a plugin that has a pin; a pin comes from pin_set, pin_file or pins_dir") \ X(FIRST_USE, PHD_PIN_MODE_FIRST_USE, "add of a plugin with no pin records its pin, keeps it in the pin store and places " \ "the instance " PHD_PLACE_OWN "; a later change is refused until pin_accept") @@ -384,8 +390,11 @@ static inline int phd_pool_name_valid(const char *name) "]", "resp 0", \ "the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the " \ "directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; " \ - "it replaces an earlier pin of the uri. The pin store and a pin_file hold the same line, with or without the verb") \ - X(PIN_CLEAR, PHD_VERB_PIN_CLEAR, " | " PHD_WORD_ALL, "resp 0", "forget the pin of one plugin, or of every one") \ + "it replaces an earlier pin of the uri; -516 for a plugin whose pins_dir pin carries " PHD_PIN_VERDICT_KEY \ + PHD_PIN_VERDICT_REFUSED ", which nothing overrides. The pin store, a pin_file and a pins_dir file hold the same " \ + "line, with or without the verb") \ + X(PIN_CLEAR, PHD_VERB_PIN_CLEAR, " | " PHD_WORD_ALL, "resp 0", "forget the pin of one plugin, or of every one; a pins_dir " \ + "pin is read-only and holds again") \ X(LV2_WORKER_SET, PHD_VERB_LV2_WORKER_SET, " <" PHD_LV2_WORKER_MOD_HOST " | " PHD_LV2_WORKER_CLAP_HOST " | " \ PHD_LV2_WORKER_AUTO " | " PHD_WORD_NONE ">", "resp 0", \ "the worker of one LV2 plugin, for its adds from now on: it overrides the lv2_worker setting, " PHD_WORD_NONE " takes the " \ @@ -393,7 +402,8 @@ static inline int phd_pool_name_valid(const char *name) X(PIN_ACCEPT, PHD_VERB_PIN_ACCEPT, "", "resp 0", \ "pin_mode " PHD_PIN_MODE_FIRST_USE " only: take the plugin's files as they are now as its pin, after a " \ PHD_EVENT_PIN_CHANGED " event; the layout is learned again by the next add and the verdict is dropped; -508 when " \ - "the plugin has no pin, -509 when its files cannot be collected, -902 under pin_mode strict") \ + "the plugin has no pin, -509 when its files cannot be collected, -516 when its pins_dir pin carries " \ + PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED ", -902 under pin_mode strict") \ X(PIN_LAYOUT, PHD_VERB_PIN_LAYOUT, "", "resp 0 " PHD_PIN_SCHEME_SEPARATOR "", \ "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") \ @@ -746,6 +756,7 @@ static inline int phd_is_note_verb(const char *verb) #define PHD_DEFAULT_POOL_MAX 8 #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" /* 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 @@ -760,10 +771,13 @@ static inline int phd_is_note_verb(const char *verb) X(lv2_path, pathlist, PHD_DEFAULT_LV2_PATH, "LV2_PATH", "where bundles are searched") \ X(state_root, path, PHD_DEFAULT_STATE_ROOT, "XDG_RUNTIME_DIR", "the daemon's checkpoints and worker logs") \ X(pin_mode, word, PHD_DEFAULT_PIN_MODE, "", "strict or first-use, while require_pins is 1: first-use records the pin " \ - "of a plugin on its first add, strict admits only a plugin a controller or a pin_file pinned") \ + "of a plugin on its first add, strict admits only a plugin a controller, a pin_file or pins_dir pinned") \ X(pin_store, path, "", "", "the file first-use pins are kept in; empty is $XDG_STATE_HOME/plugin-hostd/pins, " \ "else $HOME/.local/state/plugin-hostd/pins") \ - X(pin_file, path, "", "", "a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none") + 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") /* 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/packaging/plugin-hostd.spec b/packaging/plugin-hostd.spec index 08a51a0..a1d325b 100644 --- a/packaging/plugin-hostd.spec +++ b/packaging/plugin-hostd.spec @@ -90,6 +90,8 @@ make test-daemon check-generated %{_mandir}/man1/plugin-hostd-probe.1* %{_datadir}/plugin-hostd/protocol.json %{_datadir}/plugin-hostd/mod-host.json +# the read-only pins of packages: a package that ships pins co-owns it with %dir +%dir %{_datadir}/plugin-hostd/pins.d %files tests %license COPYING diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index fabe69b..2249d41 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -38,7 +38,8 @@ {"name": "LV2_ADAPTER_REFUSED", "macro": "PHD_ERR_LV2_ADAPTER_REFUSED", "code": -512, "meaning": "the lv2-clap worker's LV2 adapter cannot run this plugin (MIDI it does not feed, a required feature it lacks, a layout it does not take): the worker answered the add -102, the lv2_adapter_refused event carries the hosting.* code of that reply, and under lv2_worker auto the plugin is run by the lv2 row instead and this code is never answered; a plugin no bundle of the worker's world holds is no refusal: the worker's -101 is answered"}, {"name": "NO_NOTE_INPUT", "macro": "PHD_ERR_NO_NOTE_INPUT", "code": -513, "meaning": "the instance's plugin takes no notes (an effect): its worker answers note_on, note_off and all_notes_off with this code"}, {"name": "NOTES_BY_JACK", "macro": "PHD_ERR_NOTES_BY_JACK", "code": -514, "meaning": "the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect"}, - {"name": "NO_PRESET_LIST", "macro": "PHD_ERR_NO_PRESET_LIST", "code": -515, "meaning": "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows"} + {"name": "NO_PRESET_LIST", "macro": "PHD_ERR_NO_PRESET_LIST", "code": -515, "meaning": "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows"}, + {"name": "PIN_REFUSED", "macro": "PHD_ERR_PIN_REFUSED", "code": -516, "meaning": "the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first. A pins_dir pin of that verdict is never overridden: pin_set and pin_accept of its plugin answer this code"} ], "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"}, @@ -49,10 +50,10 @@ {"name": "policy_set", "arguments": " own | pool:", "reply": "resp 0", "meaning": "the placement of an add that says default"}, {"name": "pool_config", "arguments": " ", "reply": "resp 0", "meaning": "the instances a pool holds before a sibling opens"}, {"name": "worker_env", "arguments": " ", "reply": "resp 0", "meaning": "the cpu list and the nice value of the workers of a format, at spawn and on the ones running"}, - {"name": "pin_set", "arguments": " =[,=...] : [verdict=]", "reply": "resp 0", "meaning": "the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri. The pin store and a pin_file hold the same line, with or without the verb"}, - {"name": "pin_clear", "arguments": " | all", "reply": "resp 0", "meaning": "forget the pin of one plugin, or of every one"}, + {"name": "pin_set", "arguments": " =[,=...] : [verdict=]", "reply": "resp 0", "meaning": "the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri; -516 for a plugin whose pins_dir pin carries verdict=refused, which nothing overrides. The pin store, a pin_file and a pins_dir file hold the same line, with or without the verb"}, + {"name": "pin_clear", "arguments": " | all", "reply": "resp 0", "meaning": "forget the pin of one plugin, or of every one; a pins_dir pin is read-only and holds again"}, {"name": "lv2_worker_set", "arguments": " ", "reply": "resp 0", "meaning": "the worker of one LV2 plugin, for its adds from now on: it overrides the lv2_worker setting, - takes the override back; -902 for a word that is none of these. Like policy_set it is the controller's, not in the ledger, gone with the daemon"}, - {"name": "pin_accept", "arguments": "", "reply": "resp 0", "meaning": "pin_mode first-use only: take the plugin's files as they are now as its pin, after a pin_changed event; the layout is learned again by the next add and the verdict is dropped; -508 when the plugin has no pin, -509 when its files cannot be collected, -902 under pin_mode strict"}, + {"name": "pin_accept", "arguments": "", "reply": "resp 0", "meaning": "pin_mode first-use only: take the plugin's files as they are now as its pin, after a pin_changed event; the layout is learned again by the next add and the verdict is dropped; -508 when the plugin has no pin, -509 when its files cannot be collected, -516 when its pins_dir pin carries verdict=refused, -902 under pin_mode strict"}, {"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"} ], @@ -119,9 +120,10 @@ {"key": "lv2_worker", "type": "string", "default": "mod-host", "env": "", "meaning": "who runs an LV2 plugin: mod-host (the mod_host program), omx-clap-host (the clap_host program, through its LV2 adapter, a plugin it cannot run refused with a code) or auto (the adapter first, mod-host for what it refuses, each such plugin in a worker of its own unless its add names a placement); lv2_worker_set overrides it for one plugin"}, {"key": "lv2_path", "type": "string", "default": "/usr/lib64/lv2:/usr/lib/lv2:/usr/local/lib/lv2", "env": "LV2_PATH", "meaning": "where bundles are searched"}, {"key": "state_root", "type": "string", "default": "/tmp", "env": "XDG_RUNTIME_DIR", "meaning": "the daemon's checkpoints and worker logs"}, - {"key": "pin_mode", "type": "string", "default": "first-use", "env": "", "meaning": "strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller or a pin_file pinned"}, + {"key": "pin_mode", "type": "string", "default": "first-use", "env": "", "meaning": "strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller, a pin_file or pins_dir pinned"}, {"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": "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/main.c b/src/main.c index e2c3e1c..309c2b4 100644 --- a/src/main.c +++ b/src/main.c @@ -138,7 +138,7 @@ int main(int argc, char **argv) if (conf_check(&g_conf) != 0) return EXIT_FAILURE; - if (g_conf.require_pins && pins_init(g_conf.pin_store, g_conf.pin_file) != 0) + if (g_conf.require_pins && pins_init(g_conf.pin_store, g_conf.pin_file, g_conf.pins_dir) != 0) return EXIT_FAILURE; signal(SIGPIPE, SIG_IGN); diff --git a/src/pins.c b/src/pins.c index d3484db..87fe4c4 100644 --- a/src/pins.c +++ b/src/pins.c @@ -18,6 +18,7 @@ * Copyright (C) 2026 Pau Aliagas */ +#include #include #include #include @@ -41,6 +42,7 @@ #define LINE_MAX_PIN (PHD_LINE_MAX * 2) /* what a line grows by when its layout (omx-layout/1:) and a verdict are added */ #define LINE_RESERVE 128 +#define PINS_SUFFIX ".pins" typedef struct PIN_T { char *uri; @@ -53,10 +55,15 @@ typedef struct PIN_T { int learned; /* the daemon recorded it: the store keeps it */ } pin_t; +/* The host's pins: the store, the pin_file and pin_set, the one table the daemon writes and saves from */ static pin_t *g_pins; static int g_npins; static char g_store[PATH_MAX]; +/* The packages' pins: the *.pins files of pins_dir, read at start and never written */ +static pin_t *g_pkg; +static int g_npkg; + /* the uri as the worker is given it: the key of a pin, whichever way a controller wrote it */ static const char *bare(const char *uri) { @@ -78,16 +85,39 @@ static void pin_free(pin_t *p) free(p->layout); } -static pin_t *find(const char *uri) +static pin_t *find_in(pin_t *table, int count, const char *uri) { int n; - for (n = 0; n < g_npins; n++) - if (!strcmp(g_pins[n].uri, uri)) - return &g_pins[n]; + for (n = 0; n < count; n++) + if (!strcmp(table[n].uri, uri)) + return &table[n]; return NULL; } +static int refused(const pin_t *p) +{ + return !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED); +} + +/* the package pin of the uri when it carries verdict=refused, which nothing overrides */ +static pin_t *pkg_refusal(const char *uri) +{ + pin_t *p = find_in(g_pkg, g_npkg, uri); + + return p && refused(p) ? p : NULL; +} + +/* the pin that holds for a uri: a package refusal, else the host's pin, else the package's */ +static pin_t *find(const char *uri) +{ + pin_t *p = pkg_refusal(uri); + + if (!p) + p = find_in(g_pins, g_npins, uri); + return p ? p : find_in(g_pkg, g_npkg, uri); +} + /* a path of a pin: relative to the bundle, never out of it, and none of the separators of pin_set's words */ static int path_valid(const char *path) { @@ -171,10 +201,10 @@ static int pin_build(pin_t *pin, const char *uri, const char *files, const char return ERR_INVALID_OPERATION; } -/* the pin in the table, replacing the one of its uri */ +/* the pin in the host's table, replacing the one of its uri */ static void pin_put(pin_t *pin) { - pin_t *old = find(pin->uri); + pin_t *old = find_in(g_pins, g_npins, pin->uri); if (old) { @@ -188,6 +218,30 @@ static void pin_put(pin_t *pin) } } +/* the pin in the packages' table: of two pins of one uri a refusal wins, else the later one, read from the file later in + * name order or the later line of one file */ +static void pkg_put(pin_t *pin, const char *path, int lineno) +{ + pin_t *old = find_in(g_pkg, g_npkg, pin->uri); + + if (old && refused(old) && !refused(pin)) + { + fprintf(stderr, "plugin-hostd: %s:%d: %s keeps the " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED " of an earlier " + "pins.d line\n", path, lineno, pin->uri); + pin_free(pin); + } + else if (old) + { + pin_free(old); + *old = *pin; + } + else + { + g_pkg = realloc(g_pkg, (g_npkg + 1) * sizeof(pin_t)); + g_pkg[g_npkg++] = *pin; + } +} + /* ---------------------------------------------------------------- the store and the pin lines */ static const char *store_path(const char *configured) @@ -372,8 +426,11 @@ static void store_save(void) sync_dir(g_store); } +/* where a file of pin lines goes, and what a line outside the grammar costs there */ +typedef enum { LAYER_STORE, LAYER_FILE, LAYER_PKG } layer_t; + /* one line: the words of pin_set, the verb in front or not; a blank or comment line is SUCCESS and records nothing */ -static int line_read(char *line, int learned) +static int line_read(char *line, layer_t layer, const char *path, int lineno) { char *tok[8], *save = NULL, *t; int ntok = 0, first = 0, rc; @@ -393,13 +450,22 @@ static int line_read(char *line, int learned) ntok - first == 4 ? tok[first + 3] + strlen(PHD_PIN_VERDICT_KEY) : NULL, 1); if (rc != SUCCESS) return rc; - pin.learned = learned; + if (layer == LAYER_PKG) + { + pkg_put(&pin, path, lineno); + return SUCCESS; + } + pin.learned = layer == LAYER_STORE; pin_put(&pin); return SUCCESS; } -static int file_read(const char *path, int learned, int strict) +/* a pin_file is strict: a bad line ends the start. The store keeps a bad line verbatim and blocks its uri; a pins.d file + * skips it. Either names it on stderr */ +static int file_read(const char *path, layer_t layer) { + int strict = layer == LAYER_FILE, learned = layer == LAYER_STORE; + char *line = NULL; size_t cap = 0; ssize_t len; @@ -410,6 +476,9 @@ static int file_read(const char *path, int learned, int strict) { if (strict) fprintf(stderr, "plugin-hostd: can't open the pin file %s: %s\n", path, strerror(errno)); + else if (layer == LAYER_PKG) + fprintf(stderr, "plugin-hostd: can't open the pins.d file %s: %s; its pins are skipped\n", path, + strerror(errno)); else if (errno != ENOENT && errno != ENOTDIR) { /* a store that exists and can't be opened is not a first run */ @@ -428,11 +497,12 @@ static int file_read(const char *path, int learned, int strict) /* a line longer than the buffer is one line: never cut into pieces that parse on their own */ if (!too_long) memcpy(buf, line, len + 1); - if (too_long || line_read(buf, learned) != SUCCESS) + if (too_long || line_read(buf, layer, path, lineno) != SUCCESS) { fprintf(stderr, "plugin-hostd: %s:%d: not a pin%s%s\n", path, lineno, too_long ? " (the line is too long)" : "", - strict ? "" : "; the line is kept as it is and its plugin is not learned again until the operator " - "repairs or removes it"); + strict ? "" : layer == LAYER_PKG ? "; the line is skipped" + : "; the line is kept as it is and its plugin is not learned again until the operator repairs or " + "removes it"); if (strict) { free(line); @@ -444,7 +514,10 @@ static int file_read(const char *path, int learned, int strict) } } free(line); - if (ferror(file) && !strict) + if (ferror(file) && layer == LAYER_PKG) + fprintf(stderr, "plugin-hostd: can't read the pins.d file %s: %s; the pins after line %d are skipped\n", path, + strerror(errno), lineno); + else if (ferror(file) && !strict) { fprintf(stderr, "plugin-hostd: can't read the pin store %s: %s; learning and saving are blocked until it is " "repaired\n", path, strerror(errno)); @@ -454,12 +527,67 @@ static int file_read(const char *path, int learned, int strict) return 0; } -int pins_init(const char *store, const char *file) +/* a *.pins file, by its name: a hidden one (an editor's or a package manager's temporary) is not one */ +static int pins_name(const struct dirent *entry) +{ + size_t n = strlen(entry->d_name), suffix = strlen(PINS_SUFFIX); + + return entry->d_name[0] != '.' && n > suffix && !strcmp(entry->d_name + n - suffix, PINS_SUFFIX); +} + +/* byte order, whatever the locale */ +static int name_order(const struct dirent **a, const struct dirent **b) { + return strcmp((*a)->d_name, (*b)->d_name); +} + +static void pkg_read(const char *dir) +{ + struct dirent **names; + char path[PATH_MAX * 2]; + int n, count = scandir(dir, &names, pins_name, name_order); + + if (count < 0) + { + if (errno != ENOENT && errno != ENOTDIR) + fprintf(stderr, "plugin-hostd: can't read the pins directory %s: %s; its pins are skipped\n", dir, + strerror(errno)); + return; + } + for (n = 0; n < count; n++) + { + snprintf(path, sizeof(path), "%s/%s", dir, names[n]->d_name); + file_read(path, LAYER_PKG); + free(names[n]); + } + free(names); +} + +static void pkg_free(void) +{ + while (g_npkg) + pin_free(&g_pkg[--g_npkg]); + free(g_pkg); + g_pkg = NULL; +} + +int pins_init(const char *store, const char *file, const char *dir) +{ + int n, rc; + held_free(); + pkg_free(); + if (*dir) + pkg_read(dir); if (*store_path(store)) - file_read(g_store, 1, 0); - return *file ? file_read(file, 0, 1) : 0; + file_read(g_store, LAYER_STORE); + rc = *file ? file_read(file, LAYER_FILE) : 0; + for (n = 0; n < g_npins; n++) + if (pkg_refusal(g_pins[n].uri)) + fprintf(stderr, "plugin-hostd: %s: its pins.d pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED + ", which the pin of the %s does not override\n", g_pins[n].uri, + g_pins[n].learned ? "pin store" : "pin_file"); + return rc; } int pins_set(const char *uri, const char *files, const char *layout, const char *verdict) @@ -469,10 +597,25 @@ int pins_set(const char *uri, const char *files, const char *layout, const char if (rc != SUCCESS) return rc; + if (pkg_refusal(pin.uri)) + { + fprintf(stderr, "plugin-hostd: %s: its pins.d pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED + ", which pin_set does not override\n", pin.uri); + pin_free(&pin); + return PHD_ERR_PIN_REFUSED; + } pin_put(&pin); return SUCCESS; } +static void own_free(void) +{ + while (g_npins) + pin_free(&g_pins[--g_npins]); + free(g_pins); + g_pins = NULL; +} + int pins_known(const char *uri) { return find(bare(uri)) != NULL; @@ -494,12 +637,13 @@ int pins_clear(const char *uri) { for (p = g_pins; p < g_pins + g_npins; p++) learned |= p->learned; - pins_finish(); + own_free(); if (learned) store_save(); return SUCCESS; } - p = find(bare(uri)); + /* the host's pin: a package pin is read-only and holds again */ + p = find_in(g_pins, g_npins, bare(uri)); if (p) { learned = p->learned; @@ -513,10 +657,8 @@ int pins_clear(const char *uri) void pins_finish(void) { - while (g_npins) - pin_free(&g_pins[--g_npins]); - free(g_pins); - g_pins = NULL; + own_free(); + pkg_free(); } /* ---------------------------------------------------------------- the check */ @@ -624,6 +766,12 @@ int pins_accept(const format_t *format, const char *uri, const char *search) { if (!find(bare(uri))) return PHD_ERR_PIN_ABSENT; + if (pkg_refusal(bare(uri))) + { + fprintf(stderr, "plugin-hostd: %s: its pins.d pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED + ", which pin_accept does not override\n", uri); + return PHD_ERR_PIN_REFUSED; + } return record(format, uri, search); } @@ -663,6 +811,12 @@ int pins_check(const format_t *format, const char *uri, const char *search, cons names_free(&files); for (n = 0; n < p->nfiles && rc == SUCCESS; n++) rc = hash_file(root, p->path[n], p->digest[n], uri); + /* the verdict is about these very files: only once they are the pinned ones does a refused one refuse the add */ + if (rc == SUCCESS && !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED)) + { + fprintf(stderr, "plugin-hostd: %s: its pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED "\n", uri); + rc = PHD_ERR_PIN_REFUSED; + } if (rc == SUCCESS) *layout = strcmp(p->layout, PHD_PIN_LAYOUT_UNKNOWN) ? p->layout : NULL; return rc; diff --git a/src/pins.h b/src/pins.h index 65027df..b18acba 100644 --- a/src/pins.h +++ b/src/pins.h @@ -23,20 +23,26 @@ #include "formats.h" -/* The pins by URI: policy, like placement, and not in the ledger. They come from pin_set, from a pin file at start, and - * (pin_mode first-use) from the daemon itself, which keeps what it learned in the pin store. One line format for the - * three: =[,...] : [verdict=], the verb pin_set in front or not. */ +/* The pins by URI: policy, like placement, and not in the ledger. Two layers. The host's: pin_set, a pin file at start, + * and (pin_mode first-use) the daemon itself, which keeps what it learned in the pin store. The packages': the *.pins + * files of a directory, read at start and never written. The host's pin of a uri holds over the package's, except a + * package pin of verdict=refused, which always holds. One line format for all: =[,...] + * : [verdict=], the verb pin_set in front or not. */ -/* start: the store (empty is the XDG default) is read, then `file` (empty is none), which wins. -1 with the line named on - * stderr when `file` has a line outside the grammar; a bad line of the store is kept verbatim by every save, announced on - * stderr, and its uri is not learned again; a store that can't be read blocks learning and saving */ -int pins_init(const char *store, const char *file); +/* start: the *.pins files of `dir` (empty is none) in byte order of their names, of two pins of one uri a refusal + * winning, else the later; then the store (empty is the XDG default), then `file` (empty is none), which wins over the + * store. -1 with the line named on stderr when `file` has a line outside the grammar; a bad line of the store is kept + * verbatim by every save, announced on stderr, and its uri is not learned again; a store that can't be read blocks + * learning and saving; a bad line or an unreadable file of `dir` is announced and skipped, and a missing `dir` holds + * no pins */ +int pins_init(const char *store, const char *file, const char *dir); -/* pin_set: SUCCESS, or ERR_INVALID_OPERATION for a word outside the grammar; it replaces an earlier pin of the uri. - * `verdict` is NULL, or one of PHD_PIN_VERDICTS */ +/* pin_set: SUCCESS, or ERR_INVALID_OPERATION for a word outside the grammar, or PHD_ERR_PIN_REFUSED when the package pin + * of the uri carries verdict=refused; it replaces an earlier pin of the uri. `verdict` is NULL, or one of + * PHD_PIN_VERDICTS */ int pins_set(const char *uri, const char *files, const char *layout, const char *verdict); -/* pin_clear of one uri, or of every one for "all"; SUCCESS */ +/* pin_clear of the host's pin of one uri, or of every one for "all"; a package pin holds again. SUCCESS */ int pins_clear(const char *uri); /* whether the uri has a pin, and whether it is one a verdict of in-process qualified */ @@ -48,7 +54,8 @@ int pins_in_process(const char *uri); int pins_learn(const format_t *format, const char *uri, const char *search); /* pin_accept: the same collection for a uri that has a pin, which it replaces; the layout is unknown again and the - * verdict gone. PHD_ERR_PIN_ABSENT when there is no pin to replace */ + * verdict gone; over a package pin it is the host's that then holds. PHD_ERR_PIN_ABSENT when there is no pin to replace, + * PHD_ERR_PIN_REFUSED when the package pin carries verdict=refused */ int pins_accept(const format_t *format, const char *uri, const char *search); /* the layout a worker found after init, for a pin that has none; false for a layout that is not omx-layout/1 */ @@ -58,7 +65,8 @@ int pins_set_layout(const char *uri, const char *layout); * loads for it (format->pin_collect, searched in `search`) all held by the pin, then every file the pin names hashed * over one open descriptor. SUCCESS, with *layout the * layout pin ":" (the table's, valid until the next pin verb) or NULL while the pin's layout is - * unknown, or PHD_ERR_PIN_ABSENT or PHD_ERR_PIN_BINARY_MISMATCH. */ + * unknown, or PHD_ERR_PIN_ABSENT or PHD_ERR_PIN_BINARY_MISMATCH, or PHD_ERR_PIN_REFUSED for the pinned files under a + * verdict of refused. */ int pins_check(const format_t *format, const char *uri, const char *search, const char **layout); /* a refusal of a pin, with the reason on stderr: PHD_ERR_PIN_BINARY_MISMATCH */ diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 3c425dc..8f4eb46 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -1164,6 +1164,7 @@ def __init__(self): write(os.path.join(self.bundle, "pinned.ttl"), b"lv2:port [ lv2:index 0 ; lv2:symbol \"gain\" ] .") self.log = os.path.join(self.dir, "commands.log") self.store = os.path.join(self.dir, "state", "plugin-hostd", "pins") + self.pins_dir = os.path.join(self.dir, "pins.d") def clap_pin(self, layout=None): return "pin_set %s omx-fault.clap=%s %s" % (self.clap_uri, sha(self.clap), layout or fake_layout()) @@ -1173,8 +1174,10 @@ def lv2_pin(self, files=("manifest.ttl", "pinned.so", "pinned.ttl")): fake_layout()) def daemon(self, conf=None, env=None): - # strict unless a test asks for first-use; the store is the test's own, never the user's - c = {"require_pins": 1, "pin_mode": "strict", "lv2_path": self.lv2_dir, "pin_store": self.store} + # strict unless a test asks for first-use; the store and the pins_dir are the test's own, never the user's or + # the system's + c = {"require_pins": 1, "pin_mode": "strict", "lv2_path": self.lv2_dir, "pin_store": self.store, + "pins_dir": self.pins_dir} c.update(conf or {}) e = {"FAKE_LOG": self.log} e.update(env or {}) @@ -1464,6 +1467,258 @@ def a_layout_pinned_again_while_its_worker_was_down_is_refused_by_the_replayed_a p.close() +@test +def a_pin_whose_verdict_is_refused_refuses_the_add_with_its_own_code(): + """issue 71: verdict=refused names bytes that failed qualification; an add of them is refused by code, no worker + sees it, and a replay of an instance whose pin turned refused is refused the same way""" + p = Plugins() + d = p.daemon() + refused = harness.CODE.get("PIN_REFUSED") + try: + check(refused is not None, "the protocol declares PIN_REFUSED: %s" % sorted(harness.CODE)) + d.expect(p.clap_pin() + " verdict=refused", "resp 0") + r = d.send("add %s 0" % p.clap_uri) + check(r == "resp %d" % refused, "the add of a refused plugin answers PIN_REFUSED: %r" % r) + check(d.workers() == {} and p.received() == [], "no worker started, none was told: %s %s" % (d.workers(), p.received())) + # positive control: the same bytes qualified again run + d.expect(p.clap_pin() + " verdict=isolated", "resp 0") + d.expect("add %s 0" % p.clap_uri, "resp 0") + # a pin turned refused while the instance runs: its replay is refused with the same code + _, w = d.holder(0) + d.expect(p.clap_pin() + " verdict=refused", "resp 0") + mark = d.mark() + os.kill(w["pid"], signal.SIGKILL) + d.wait_event("instance_replay_refused 0 %d" % refused, since=mark) + check(not [e for e in d.events[mark:] if e.startswith("pin_changed")], "a refusal is no change of the files") + finally: + d.close() + # bytes that changed are a mismatch first: a verdict is about the bytes it was given for + d = p.daemon() + try: + d.expect(p.clap_pin().replace(sha(p.clap)[:8], "00000000") + " verdict=refused", "resp 0") + d.expect("add %s 1" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + finally: + d.close() + # a pin_file carries the verdict as pin_set does + pins = os.path.join(p.dir, "pins.txt") + write(pins, ("%s verdict=refused\n" % p.clap_pin().split(" ", 1)[1]).encode()) + d = p.daemon(conf={"pin_mode": "first-use", "pin_file": pins}) + try: + r = d.send("add %s 2" % p.clap_uri) + check(r == "resp %d" % refused, "a pin_file's refused verdict refuses the add: %r" % r) + check(store_lines(p) == [], "and the refused plugin is not learned into the store") + finally: + d.close() + p.close() + + +def pin_line(p, verdict=None, good=True, uri=None, clap=None): + """a pin line as a pins.d file holds it, no verb: the CLAP's real digest, or one that is not its own""" + clap = clap or p.clap + digest = sha(clap) if good else "0" * 64 + line = "%s %s=%s %s" % (uri or p.clap_uri, os.path.basename(clap), digest, fake_layout()) + return line + (" verdict=%s" % verdict if verdict else "") + + +def pins_d(p, files): + """the test's pins_dir holding exactly these files, written in reverse name order so the directory's own order is + not the order of their names""" + shutil.rmtree(p.pins_dir, ignore_errors=True) + os.makedirs(p.pins_dir) + for name in sorted(files, reverse=True): + write(os.path.join(p.pins_dir, name), "".join(l + "\n" for l in files[name]).encode()) + + +def other_clap(p): + """a second CLAP beside the first, for a uri that is not the one under test""" + path = os.path.join(os.path.dirname(p.clap), "omx-other.clap") + write(path, b"another clean build") + return path, "clap:%s#org.openmixer.other" % path + + +def add_answer(p, conf, inst=0, uri=None): + """the reply to one add of a fresh daemon over the test's pins_dir""" + d = p.daemon(conf=conf) + try: + return d.send("add %s %d" % (uri or p.clap_uri, inst)) + finally: + d.close() + + +@test +def a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins(): + """issue 42: the *.pins files of pins_dir are read at start in name order; a pin there admits its plugin under + strict, and of two pins of one uri that are not refusals the file last by name, or the later line, holds""" + p = Plugins() + try: + pins_d(p, {"10-a.pins": [pin_line(p, good=False)], "20-b.pins": [pin_line(p)]}) + r = add_answer(p, {}) + check(r == "resp 0", "the later file's good pin holds over the earlier bad one: %r" % r) + pins_d(p, {"10-a.pins": [pin_line(p)], "20-b.pins": [pin_line(p, good=False)]}) + r = add_answer(p, {}) + check(r == resp("PIN_BINARY_MISMATCH"), "the later file's bad pin holds over the earlier good one: %r" % r) + pins_d(p, {"10-a.pins": [pin_line(p, good=False), pin_line(p)]}) + r = add_answer(p, {}) + check(r == "resp 0", "in one file the later line holds: %r" % r) + # only *.pins files that are not hidden are read: a package manager's leftovers and an editor's are not pins (the + # hidden one is a refusal, which would win whatever its place in name order) + pins_d(p, {"10-a.pins": [pin_line(p)], "20-b.pins.rpmnew": [pin_line(p, good=False)], + ".30-c.pins": [pin_line(p, "refused")], "README": [pin_line(p, good=False)]}) + r = add_answer(p, {}) + check(r == "resp 0", "a file that is not a .pins file holds no pin: %r" % r) + # first-use trusts a package pin as it is: nothing is learned into the store + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + check(store_lines(p) == [], "a package pin is not copied into the store: %s" % store_lines(p)) + finally: + d.close() + finally: + p.close() + + +@test +def a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host(): + """issue 42, decisions of 2026-10-18: a pins.d pin of verdict=refused wins over another pins.d pin of its uri in + either name order, and nothing of the host overrides it: not the store, not a pin_file, not pin_set, not + pin_accept, not pin_clear""" + p = Plugins() + refused = resp("PIN_REFUSED") + try: + other, other_uri = other_clap(p) + for files in ({"10-a.pins": [pin_line(p, "refused")], "20-b.pins": [pin_line(p, "isolated")]}, + {"10-a.pins": [pin_line(p, "isolated")], "20-b.pins": [pin_line(p, "refused")]}, + {"10-a.pins": [pin_line(p, "refused"), pin_line(p, "isolated")]}): + pins_d(p, files) + r = add_answer(p, {}) + check(r == refused, "a refusal wins whichever file or line comes last (%s): %r" % (sorted(files), r)) + pins_d(p, {"10-a.pins": [pin_line(p, "refused"), pin_line(p, "isolated", uri=other_uri, clap=other)]}) + # the store and a pin_file both hold the same files as isolated + os.makedirs(os.path.dirname(p.store), exist_ok=True) + write(p.store, ("pin_set %s\n" % pin_line(p, "isolated")).encode()) + pins = os.path.join(p.dir, "pins.txt") + write(pins, (pin_line(p, "isolated") + "\n").encode()) + d = p.daemon(conf={"pin_mode": "first-use", "pin_file": pins}) + try: + r = d.send("add %s 0" % p.clap_uri) + check(r == refused, "the store and the pin_file do not override a pins.d refusal: %r" % r) + r = d.send("pin_set %s" % pin_line(p, "isolated")) + check(r == refused, "pin_set of a uri a pins.d pin refuses answers PIN_REFUSED: %r" % r) + r = d.send("pin_accept %s" % p.clap_uri) + check(r == refused, "pin_accept of it answers PIN_REFUSED: %r" % r) + d.expect("pin_clear %s" % p.clap_uri, "resp 0") + d.expect("pin_clear all", "resp 0") + r = d.send("add %s 0" % p.clap_uri) + check(r == refused, "after pin_set, pin_accept and pin_clear the refusal still holds: %r" % r) + check(d.workers() == {} and p.received() == [], "no worker saw it: %s %s" % (d.workers(), p.received())) + # positive control: the other plugin of the same file runs + d.expect("add %s 1" % other_uri, "resp 1") + check("which the pin of the pin_file does not override" in d.log(), + "stderr names the pin_file line that has no effect: %s" % d.log()) + finally: + d.close() + # the store alone: its line has no effect and is not dropped from the store either + write(p.store, ("pin_set %s\n" % pin_line(p, "isolated")).encode()) + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + r = d.send("add %s 0" % p.clap_uri) + check(r == refused, "the store does not override a pins.d refusal: %r" % r) + check("which the pin of the pin store does not override" in d.log(), "stderr names the store line") + d.expect("add %s 1" % other_uri, "resp 1") + check([l[1] for l in store_lines(p)] == [p.clap_uri], "the store line is kept: %s" % store_lines(p)) + finally: + d.close() + finally: + p.close() + + +@test +def the_host_holds_over_a_pins_d_pin_that_is_not_a_refusal(): + """issue 42: a pins.d pin that is not a refusal is under the host's layer: pin_set holds over it, a change of the + files is announced by pin_changed until pin_accept records the new files in the store, which then hold across a + restart, and pin_clear lets the package pin hold again""" + p = Plugins() + try: + pins_d(p, {"50-pkg.pins": [pin_line(p, "isolated", good=False)]}) + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + mark = d.mark() + d.expect("add %s 0" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + d.wait_event("pin_changed %s files" % p.clap_uri, since=mark) + check(store_lines(p) == [], "a plugin with a package pin is not learned: %s" % store_lines(p)) + d.expect("pin_accept %s" % p.clap_uri, "resp 0") + d.expect("add %s 0" % p.clap_uri, "resp 0") + check([l for l in store_lines(p) if l[1] == p.clap_uri][0][2] == "omx-fault.clap=%s" % sha(p.clap), + "the accepted files are in the store: %s" % store_lines(p)) + finally: + d.close() + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + d.expect("remove 0", "resp 0") + d.expect("pin_clear %s" % p.clap_uri, "resp 0") + d.expect("add %s 0" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + finally: + d.close() + d = p.daemon() + try: + d.expect("add %s 0" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + d.expect("pin_set %s" % pin_line(p), "resp 0") + d.expect("add %s 0" % p.clap_uri, "resp 0") + finally: + d.close() + finally: + p.close() + + +@test +def a_bad_pins_d_line_or_file_is_skipped_and_the_rest_is_read(): + """issue 42: a line outside the grammar, a line longer than a pin line can be and a file that cannot be read are + named on stderr and skipped; the daemon starts and the good lines hold""" + p = Plugins() + try: + long_refusal = pin_line(p, "refused") + " " * 9000 + pins_d(p, {"10-a.pins": ["this is not a pin", long_refusal, pin_line(p)]}) + os.makedirs(os.path.join(p.pins_dir, "20-dir.pins")) + d = p.daemon() + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + log = d.log() + path = os.path.join(p.pins_dir, "10-a.pins") + check("%s:1: not a pin; the line is skipped" % path in log, "the bad line is named: %s" % log) + check("%s:2: not a pin (the line is too long); the line is skipped" % path in log, "the long line is named") + check("can't read the pins.d file %s" % os.path.join(p.pins_dir, "20-dir.pins") in log, + "the file that cannot be read is named: %s" % log) + finally: + d.close() + finally: + p.close() + + +@test +def an_empty_or_missing_pins_dir_holds_no_pins(): + """issue 42: a pins_dir that is missing, empty or not set holds no pins and is no error""" + p = Plugins() + try: + for what, conf in (("missing", {}), ("empty", {}), ("not set", {"pins_dir": ""})): + if what == "empty": + os.makedirs(p.pins_dir) + d = p.daemon(conf=conf) + try: + d.expect("add %s 0" % p.clap_uri, resp("PIN_ABSENT")) + check("pins directory" not in d.log(), "a %s pins_dir is no error: %s" % (what, d.log())) + finally: + d.close() + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + check(len(store_lines(p)) == 1, "first-use learns as before: %s" % store_lines(p)) + finally: + d.close() + finally: + p.close() + + def own_worker_of(d, inst): return d.holder(inst)[1]["place"] == "own" diff --git a/tests/format_test.c b/tests/format_test.c index 720fc1d..5ea8cd9 100644 --- a/tests/format_test.c +++ b/tests/format_test.c @@ -244,7 +244,7 @@ int main(int argc, char **argv) conf.require_pins = 1; conf.ready_timeout_ms = 4000; check(conf_check(&conf) == 0, "the settings are consistent"); - check(pins_init(conf.pin_store, "") == 0, "the pin store starts empty"); + check(pins_init(conf.pin_store, "", "") == 0, "the pin store starts empty"); sup_init(&conf); write_file(plugin, "third format, build one"); diff --git a/tests/harness.py b/tests/harness.py index 7f5c691..02b2e88 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -78,6 +78,8 @@ def __init__(self, exe, worker, clap_worker=None, conf=None, env=None, feedback= # the tests of placement, replay and attribution add plugins no one pinned; the pin tests turn it back on, # and a None leaves a setting out of the file, so the daemon's own default holds "require_pins": 0, + # no package's pins: the system's pins_dir is not the test's + "pins_dir": "", } 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 8bb7529..e86672d 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -266,6 +266,30 @@ ("an add the worker died on is not named by an event", "src/supervisor.c", " if (code != PHD_ERR_VERB_DROPPED)\n event(PHD_EVENT_VERB_DROPPED_FMT, id, line);\n", "", "under_auto_a_refused_plugin_that_kills_mod_host_on_add_costs_only_its_own_worker"), + ("a pin whose verdict is refused admits the add", "src/pins.c", + " if (rc == SUCCESS && !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED))\n", " if (0)\n", + "a_pin_whose_verdict_is_refused_refuses_the_add_with_its_own_code"), + ("the host's pin overrides a pins.d refusal", "src/pins.c", " pin_t *p = pkg_refusal(uri);\n\n if (!p)\n", + " pin_t *p = NULL;\n\n if (!p)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("pin_set overrides a pins.d refusal", "src/pins.c", " if (pkg_refusal(pin.uri))\n", " if (0)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("pin_accept overrides a pins.d refusal", "src/pins.c", " if (pkg_refusal(bare(uri)))\n", " if (0)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("of two pins.d pins of one uri the later wins over a refusal", "src/pins.c", + " if (old && refused(old) && !refused(pin))\n", " if (0)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("pins.d files are read in reverse name order", "src/pins.c", " return strcmp((*a)->d_name, (*b)->d_name);", + " return strcmp((*b)->d_name, (*a)->d_name);", "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), + ("a pins.d file need not end in .pins", "src/pins.c", + "n > suffix && !strcmp(entry->d_name + n - suffix, PINS_SUFFIX)", "n > 0", + "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), + ("a package pin holds over the host's", "src/pins.c", + " p = find_in(g_pins, g_npins, uri);\n return p ? p : find_in(g_pkg, g_npkg, uri);", + " p = find_in(g_pkg, g_npkg, uri);\n return p ? p : find_in(g_pins, g_npins, uri);", + "the_host_holds_over_a_pins_d_pin_that_is_not_a_refusal"), + ("a bad pins.d line ends the start", "src/pins.c", " int strict = layer == LAYER_FILE,", + " int strict = layer != LAYER_STORE,", "a_bad_pins_d_line_or_file_is_skipped_and_the_rest_is_read"), + ("a hidden pins.d file is read", "src/pins.c", "entry->d_name[0] != '.' && ", "", + "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), + ("pins_dir is not read", "src/pins.c", " if (*dir)\n pkg_read(dir);\n", "", + "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), ("the build steps are back on the README", "README.md", "Building from source: see [BUILDING.md](BUILDING.md).", "Building from source:\n\n make\n\nsee [BUILDING.md](BUILDING.md).", "README.md runs no build tool", "readme"), ("BUILDING.md lost its build command", "BUILDING.md", " make [MOD_HOST_DIR=]\n", "",