Revoke stale plugin facade capabilities
Reported-by: Roger Piñol <rogerpicar@gmail.com>
This commit is contained in:
1 parent
094c913065
commit
203e1639c3
3 files changed
+266
-18
No files matched your search
+95
-16
@@ -275,6 +275,7 @@ ShellRoot {
|
||||
|
||||
property var _services: ({})
|
||||
property var _pluginShellApis: ({})
|
||||
property var _pluginShellApiDescriptors: ({})
|
||||
property var _pluginBarEntryShellApis: ({})
|
||||
property var _pluginRegistryApis: ({})
|
||||
property var _pluginBarWidgetRegistryApis: ({})
|
||||
@@ -434,7 +435,9 @@ ShellRoot {
|
||||
var proxyKey = cacheKey + "::" + id
|
||||
if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey]
|
||||
|
||||
function service() { return shell.serviceFor(id) }
|
||||
function service() {
|
||||
return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(id))
|
||||
}
|
||||
var api = pluginFirstPartyServiceApiComponent.createObject(null, {
|
||||
ownerPluginId: pluginId,
|
||||
serviceId: id,
|
||||
@@ -491,10 +494,67 @@ ShellRoot {
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) {
|
||||
return [
|
||||
allowOwnService ? "own-service" : "no-own-service",
|
||||
barCapabilities ? "bar" : "no-bar",
|
||||
shell.manifestHasKind(manifest, "menu") ? "menu" : "no-menu"
|
||||
].join("|")
|
||||
}
|
||||
|
||||
function cacheWithoutKey(cache, key, destroyValue) {
|
||||
var next = ({})
|
||||
for (var existing in cache) {
|
||||
if (existing === key) {
|
||||
var value = cache[existing]
|
||||
if (destroyValue && value && typeof value.destroy === "function") value.destroy()
|
||||
} else {
|
||||
next[existing] = cache[existing]
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function cacheWithoutPrefix(cache, prefix) {
|
||||
var next = ({})
|
||||
for (var existing in cache) {
|
||||
if (existing.indexOf(prefix) === 0) {
|
||||
var value = cache[existing]
|
||||
if (value && typeof value.destroy === "function") value.destroy()
|
||||
} else {
|
||||
next[existing] = cache[existing]
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function revokePluginShellApi(cacheKey) {
|
||||
var key = String(cacheKey || "")
|
||||
if (!key) return
|
||||
_pluginAppLibraryApis = shell.cacheWithoutKey(_pluginAppLibraryApis, key, true)
|
||||
_pluginFirstPartyServiceApis = shell.cacheWithoutPrefix(_pluginFirstPartyServiceApis, key + "::")
|
||||
_pluginBarEntryShellApis = shell.cacheWithoutPrefix(_pluginBarEntryShellApis, key + ":")
|
||||
_pluginShellApis = shell.cacheWithoutKey(_pluginShellApis, key, true)
|
||||
_pluginShellApiDescriptors = shell.cacheWithoutKey(_pluginShellApiDescriptors, key, false)
|
||||
}
|
||||
|
||||
function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) {
|
||||
if (_pluginShellApis[cacheKey]) return _pluginShellApis[cacheKey]
|
||||
var key = String(manifest && manifest.id || "")
|
||||
if (!key) return null
|
||||
var profile = shell.pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities)
|
||||
var cached = _pluginShellApis[cacheKey]
|
||||
var descriptor = _pluginShellApiDescriptors[cacheKey]
|
||||
if (cached && descriptor && descriptor.pluginId === key
|
||||
&& descriptor.profile === profile) return cached
|
||||
if (cached || descriptor) shell.revokePluginShellApi(cacheKey)
|
||||
|
||||
function currentManifest() {
|
||||
return shell.pluginRegistry.installedPlugins[key] || null
|
||||
}
|
||||
|
||||
function hasCurrentBarCapabilities() {
|
||||
return barCapabilities && shell.pluginHasBarCapabilities(currentManifest())
|
||||
}
|
||||
|
||||
// Construct the narrow service proxies before any plugin binding can call
|
||||
// firstPartyServiceFor(). Creating a QObject while evaluating that binding
|
||||
@@ -519,40 +579,42 @@ ShellRoot {
|
||||
return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null
|
||||
},
|
||||
_firstPartyServiceLookup: function(requestedId) {
|
||||
return barCapabilities ? (firstPartyServices[requestedId] || null) : null
|
||||
if (allowOwnService && shell.pluginOwnsTarget(key, requestedId))
|
||||
return shell.pluginServiceFor(key, requestedId)
|
||||
return hasCurrentBarCapabilities() ? (firstPartyServices[requestedId] || null) : null
|
||||
},
|
||||
_barEntryShellLookup: function(ownerId, moduleName) {
|
||||
return barCapabilities
|
||||
return hasCurrentBarCapabilities()
|
||||
? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null
|
||||
},
|
||||
_summon: function(requestedId, payloadJson) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(manifest, requestedId)) return false
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
|
||||
return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
|
||||
},
|
||||
_hide: function(requestedId) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(manifest, requestedId)) return false
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
|
||||
return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId))
|
||||
},
|
||||
_toggle: function(requestedId, payloadJson) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(manifest, requestedId)) return false
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
|
||||
return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
|
||||
},
|
||||
_isOpen: function(requestedId) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(manifest, requestedId)) return false
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
|
||||
return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId))
|
||||
},
|
||||
_updateSettings: function(requestedId, settings) {
|
||||
if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings)
|
||||
if (barCapabilities && shell.barEntryConfigured(requestedId))
|
||||
if (hasCurrentBarCapabilities() && shell.barEntryConfigured(requestedId))
|
||||
return shell.updateEntryInline(requestedId, settings)
|
||||
return false
|
||||
},
|
||||
_mutateBarConfig: function(mutator) {
|
||||
return barCapabilities ? shell.mutatePluginBarConfig(mutator) : false
|
||||
return hasCurrentBarCapabilities() ? shell.mutatePluginBarConfig(mutator) : false
|
||||
}
|
||||
})
|
||||
if (!api) return null
|
||||
@@ -561,6 +623,15 @@ ShellRoot {
|
||||
for (var id in _pluginShellApis) next[id] = _pluginShellApis[id]
|
||||
next[cacheKey] = api
|
||||
_pluginShellApis = next
|
||||
var descriptorNext = ({})
|
||||
for (var descriptorKey in _pluginShellApiDescriptors)
|
||||
descriptorNext[descriptorKey] = _pluginShellApiDescriptors[descriptorKey]
|
||||
descriptorNext[cacheKey] = {
|
||||
pluginId: key,
|
||||
allowOwnService: allowOwnService === true,
|
||||
profile: profile
|
||||
}
|
||||
_pluginShellApiDescriptors = descriptorNext
|
||||
return api
|
||||
}
|
||||
|
||||
@@ -679,13 +750,20 @@ ShellRoot {
|
||||
|
||||
function prunePluginApis() {
|
||||
var plugins = shell.pluginRegistry.installedPlugins
|
||||
var shellNext = ({})
|
||||
for (var shellKey in _pluginShellApis) {
|
||||
var shellKeys = Object.keys(_pluginShellApis)
|
||||
for (var si = 0; si < shellKeys.length; si++) {
|
||||
var shellKey = shellKeys[si]
|
||||
var shellApi = _pluginShellApis[shellKey]
|
||||
if (shell.pluginApiActive(shellApi, plugins)) shellNext[shellKey] = shellApi
|
||||
else if (shellApi && typeof shellApi.destroy === "function") shellApi.destroy()
|
||||
var descriptor = _pluginShellApiDescriptors[shellKey]
|
||||
var manifest = descriptor ? plugins[descriptor.pluginId] : null
|
||||
var barCapabilities = descriptor && descriptor.allowOwnService
|
||||
&& shell.pluginHasBarCapabilities(manifest)
|
||||
var expectedProfile = descriptor
|
||||
? shell.pluginShellCapabilityProfile(manifest, descriptor.allowOwnService, barCapabilities) : ""
|
||||
var active = descriptor && manifest && shell.pluginRegistry.isEnabled(descriptor.pluginId)
|
||||
if (!active || descriptor.profile !== expectedProfile)
|
||||
shell.revokePluginShellApi(shellKey)
|
||||
}
|
||||
_pluginShellApis = shellNext
|
||||
|
||||
var registryNext = ({})
|
||||
for (var registryKey in _pluginRegistryApis) {
|
||||
@@ -761,7 +839,7 @@ ShellRoot {
|
||||
}
|
||||
|
||||
function firstPartyServiceFor(pluginId) {
|
||||
return serviceFor(pluginId)
|
||||
return serviceFor(shell.pluginRegistry.resolveEnabledId(pluginId))
|
||||
}
|
||||
|
||||
function isAuthenticationService(manifest) {
|
||||
@@ -844,6 +922,7 @@ ShellRoot {
|
||||
} else {
|
||||
// A kept instance outlives the rescan; hand it the fresh manifest.
|
||||
var kept = _services[id]
|
||||
if (kept && "shell" in kept) kept.shell = shell.pluginShellFor(m)
|
||||
if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -95,6 +95,22 @@ qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' ||
|
||||
fail "disabled plugin facade caches are pruned"
|
||||
pass "plugin facade synchronization is bounded"
|
||||
|
||||
qml_matches "$shell_qml" 'descriptor\.profile *!== *expectedProfile[^}]*shell\.revokePluginShellApi\( *shellKey *\)' ||
|
||||
fail "manifest capability changes do not revoke cached plugin facades"
|
||||
qml_matches "$shell_qml" 'shell\.barPluginMayControl\( *currentManifest\( *\), *requestedId *\)' ||
|
||||
fail "bar lifecycle callbacks do not validate the current manifest"
|
||||
qml_matches "$shell_qml" 'return hasCurrentBarCapabilities\( *\) *\? *shell\.mutatePluginBarConfig\( *mutator *\) *: *false' ||
|
||||
fail "bar configuration mutation does not validate the current manifest"
|
||||
pass "manifest changes revoke cached facade capabilities"
|
||||
|
||||
qml_matches "$shell_qml" 'shell\.serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *id *\) *\)' ||
|
||||
fail "narrow first-party service proxies do not resolve enabled clones"
|
||||
qml_matches "$shell_qml" 'return serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *pluginId *\) *\)' ||
|
||||
fail "trusted first-party service lookups do not resolve enabled clones"
|
||||
qml_matches "$shell_qml" 'allowOwnService *&& *shell\.pluginOwnsTarget\( *key, *requestedId *\)[^}]*return shell\.pluginServiceFor\( *key, *requestedId *\)' ||
|
||||
fail "cloned widgets cannot use a source id to reach their own service"
|
||||
pass "service facades resolve enabled clones without widening replacement-bar access"
|
||||
|
||||
require_compositor "plugin authentication boundary runtime test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
|
||||
@@ -136,6 +136,66 @@ Item {
|
||||
}
|
||||
QML
|
||||
|
||||
# A clone of the built-in media service exercises both supported service paths:
|
||||
# its own widget receives the raw companion service under the trusted bar, while
|
||||
# a replacement bar receives only the narrow media proxy resolved to the clone.
|
||||
media_clone_id="acme.media-clone"
|
||||
media_clone_dir="$test_home/.config/omarchy/plugins/$media_clone_id"
|
||||
mkdir -p "$media_clone_dir"
|
||||
cat >"$media_clone_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$media_clone_id",
|
||||
"name": "Media Clone",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["service", "bar-widget"],
|
||||
"entryPoints": {"service": "Service.qml", "barWidget": "BarWidget.qml"},
|
||||
"barWidget": {"defaultSection": "center"},
|
||||
"omarchy": {"clonedFrom": "omarchy.media"}
|
||||
}
|
||||
JSON
|
||||
cat >"$media_clone_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
property string marker: "clone-service"
|
||||
property bool enabled: true
|
||||
property var activePlayer: null
|
||||
property var sourcePlayers: []
|
||||
|
||||
function runAction(action, showFeedback, targetKey) {}
|
||||
function playerKey(player) { return "" }
|
||||
function selectPlayer(playerKey) {}
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-media-clone-service"
|
||||
function ping(): string { return marker }
|
||||
}
|
||||
}
|
||||
QML
|
||||
cat >"$media_clone_dir/BarWidget.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var bar: null
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-media-clone-widget"
|
||||
function probeOwnService(): string {
|
||||
var service = root.bar && root.bar.shell
|
||||
? root.bar.shell.firstPartyServiceFor("omarchy.media") : null
|
||||
return JSON.stringify({
|
||||
reachable: !!service,
|
||||
marker: service ? String(service.marker || "") : ""
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
review_bar_id="acme.review-bar"
|
||||
review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id"
|
||||
mkdir -p "$review_bar_dir"
|
||||
@@ -145,8 +205,9 @@ cat >"$review_bar_dir/manifest.json" <<JSON
|
||||
"id": "$review_bar_id",
|
||||
"name": "Review Bar",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["bar"],
|
||||
"entryPoints": {"bar": "Bar.qml"}
|
||||
"kinds": ["bar", "service"],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {"bar": "Bar.qml", "service": "Service.qml"}
|
||||
}
|
||||
JSON
|
||||
cat >"$review_bar_dir/Bar.qml" <<'QML'
|
||||
@@ -181,6 +242,12 @@ Item {
|
||||
return JSON.stringify(root.barConfig || {})
|
||||
}
|
||||
|
||||
function probeMediaProxy(): string {
|
||||
var service = root.shell
|
||||
? root.shell.firstPartyServiceFor("omarchy.media") : null
|
||||
return JSON.stringify({ reachable: !!service, enabled: service ? service.enabled === true : false })
|
||||
}
|
||||
|
||||
function mutateSnapshot(): string {
|
||||
if (root.barConfig && root.barConfig.layout
|
||||
&& root.barConfig.layout.left && root.barConfig.layout.left.length > 0)
|
||||
@@ -190,6 +257,38 @@ Item {
|
||||
}
|
||||
}
|
||||
QML
|
||||
cat >"$review_bar_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var shell: null
|
||||
property var retainedShell: null
|
||||
|
||||
onShellChanged: if (!retainedShell && shell) retainedShell = shell
|
||||
|
||||
function mutationAllowed(candidate) {
|
||||
if (!candidate) return false
|
||||
try {
|
||||
return typeof candidate.mutateShellConfig === "function"
|
||||
&& candidate.mutateShellConfig(function(config) {}) === true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-review-capability"
|
||||
function probe(): string {
|
||||
return JSON.stringify({
|
||||
currentAllowed: root.mutationAllowed(root.shell),
|
||||
retainedAllowed: root.mutationAllowed(root.retainedShell)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
cat >"$stub_bin/omarchy-update-available" <<'SH'
|
||||
#!/bin/bash
|
||||
@@ -517,6 +616,24 @@ pass "bar put leaves a widget already on the bar alone"
|
||||
# Run the replacement-bar probes last: switching bar loaders can transiently
|
||||
# leave bar-aware panels without a visual host, which should not add noise to
|
||||
# the default-bar assertions above.
|
||||
[[ $(shell_ipc shell setPluginEnabled "$media_clone_id" true) == "ok" ]] ||
|
||||
fail_with_log "media clone fixture could not be enabled"
|
||||
clone_widget_probe=""
|
||||
for _ in {1..80}; do
|
||||
clone_widget_probe=$(shell_ipc acme-media-clone-widget probeOwnService 2>/dev/null || true)
|
||||
if jq -e '.reachable == true and .marker == "clone-service"' \
|
||||
<<<"$clone_widget_probe" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.reachable == true and .marker == "clone-service"' \
|
||||
<<<"$clone_widget_probe" >/dev/null || {
|
||||
printf 'Clone own-service probe: %s\n' "$clone_widget_probe" >&2
|
||||
fail_with_log "a cloned widget resolves its source id to its own companion service"
|
||||
}
|
||||
pass "trusted bar gives a cloned widget its own companion service"
|
||||
|
||||
[[ $(shell_ipc shell setPluginEnabled "$victim_service_id" true) == "ok" ]] ||
|
||||
fail_with_log "victim service fixture could not be enabled"
|
||||
[[ $(shell_ipc shell enablePlugin "$review_bar_id" '{}') == "ok" ]] ||
|
||||
@@ -540,6 +657,12 @@ jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReac
|
||||
fail_with_log "replacement bar cannot recover another plugin's live service"
|
||||
}
|
||||
|
||||
media_proxy_probe=$(shell_ipc acme-review-bar probeMediaProxy)
|
||||
jq -e '.reachable == true and .enabled == true' <<<"$media_proxy_probe" >/dev/null || {
|
||||
printf 'Replacement-bar media proxy probe: %s\n' "$media_proxy_probe" >&2
|
||||
fail_with_log "replacement-bar service proxies resolve enabled clones"
|
||||
}
|
||||
|
||||
bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
shell_ipc acme-review-bar mutateSnapshot >/dev/null
|
||||
bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
@@ -567,3 +690,33 @@ bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
fail_with_log "replacement bar mutated a refreshed host configuration"
|
||||
|
||||
pass "replacement-bar service and configuration boundaries hold at runtime"
|
||||
|
||||
capability_before=$(shell_ipc acme-review-capability probe)
|
||||
jq -e '.currentAllowed == true and .retainedAllowed == true' \
|
||||
<<<"$capability_before" >/dev/null ||
|
||||
fail_with_log "bar service fixture did not initially receive bar capabilities"
|
||||
|
||||
# Keep the same enabled plugin ID and service instance while dropping the bar
|
||||
# kind. Both the currently injected facade and a reference retained by the
|
||||
# plugin must lose the old configuration capability after the manifest rescan.
|
||||
jq '.kinds = ["service"] | .entryPoints = {"service": "Service.qml"}' \
|
||||
"$review_bar_dir/manifest.json" >"$review_bar_dir/manifest.json.tmp"
|
||||
mv "$review_bar_dir/manifest.json.tmp" "$review_bar_dir/manifest.json"
|
||||
capability_after=""
|
||||
for _ in {1..80}; do
|
||||
capability_after=$(shell_ipc acme-review-capability probe 2>/dev/null || true)
|
||||
if jq -e '.currentAllowed == false and .retainedAllowed == false' \
|
||||
<<<"$capability_after" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
fail_with_log "test shell exited while revoking changed manifest capabilities"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.currentAllowed == false and .retainedAllowed == false' \
|
||||
<<<"$capability_after" >/dev/null || {
|
||||
printf 'Capability revocation probe: %s\n' "$capability_after" >&2
|
||||
fail_with_log "cached plugin facades revoke capabilities removed from the manifest"
|
||||
}
|
||||
pass "manifest reload revokes cached facade capabilities"
|
||||
Reference in new issue
Block a user