mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-04 04:52:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a36a906292 | ||
|
|
05a773b426 |
No files matched your search
@@ -450,6 +450,13 @@ new child to an opened parent directory without a path race.
|
||||
`readDirectory()` likewise keeps its stable SDK/RPC method but fails closed
|
||||
unless the main process supplies a native adapter whose inode checks and entry
|
||||
enumeration are bound to the same directory handle.
|
||||
The host advertises `filesystem.directory-enumeration` only when it supplies that
|
||||
adapter. Declare the feature as required, or optional and check
|
||||
`context.enabledFeatures.has(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)`.
|
||||
The default host supplies no adapter and does not enable this feature. Both the
|
||||
SDK and direct RPC return `unsupported` before a permission request or path probe.
|
||||
An enabled feature still requires the declared `filesystem.read` directory
|
||||
resource and the normal permission, root, symlink and same-handle checks.
|
||||
|
||||
Permission names already use the phase-3 enforcement boundaries: clipboard
|
||||
read/write, terminal metadata/output/input and input/output interception, Vault
|
||||
|
||||
@@ -207,6 +207,12 @@ class PluginFilesystemBroker {
|
||||
};
|
||||
}
|
||||
validatePath(params) { return assertPathParams(params); }
|
||||
validateDirectory(params) {
|
||||
if (typeof this.openDirectoryHandle !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unsupported, "Secure plugin directory enumeration is unavailable on this runtime");
|
||||
}
|
||||
return assertPathParams(params);
|
||||
}
|
||||
|
||||
describeReadAuthorization(params, resourceKind = "exact") {
|
||||
const value = assertPathParams(params);
|
||||
@@ -329,17 +335,11 @@ class PluginFilesystemBroker {
|
||||
}
|
||||
|
||||
async readDirectory(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
const value = this.validateDirectory(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
|
||||
if (!this.openDirectoryHandle) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unsupported,
|
||||
"Secure plugin directory enumeration is unavailable on this runtime",
|
||||
);
|
||||
}
|
||||
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
|
||||
if (
|
||||
!directory
|
||||
|
||||
@@ -7158,6 +7158,9 @@
|
||||
"TerminalInputDraftFeature": {
|
||||
"const": "terminal.input-drafts"
|
||||
},
|
||||
"FilesystemDirectoryEnumerationFeature": {
|
||||
"const": "filesystem.directory-enumeration"
|
||||
},
|
||||
"HostTerminalInputProposalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -36,6 +36,7 @@ const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./ho
|
||||
const { PluginHostVaultService } = require("./hostVaultService.cjs");
|
||||
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
|
||||
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
|
||||
const DIRECTORY_ENUMERATION_FEATURE = require("./generated/plugin-contract.schema.json").$defs.FilesystemDirectoryEnumerationFeature.const;
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
const { PluginViewHost } = require("./pluginViewHost.cjs");
|
||||
@@ -99,7 +100,9 @@ function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])]
|
||||
.filter((feature) => feature !== DIRECTORY_ENUMERATION_FEATURE);
|
||||
if (typeof options.openPluginDirectoryHandle === "function") supportedFeatures.push(DIRECTORY_ENUMERATION_FEATURE);
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
let byteStreamService;
|
||||
|
||||
@@ -335,6 +335,8 @@ test("host service wires a handle-bound directory adapter without changing the R
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
assert.ok(service.runtimeSupervisor.supportedFeatures.includes("filesystem.directory-enumeration"));
|
||||
assert.ok(service.packageStore.supportedFeatures.includes("filesystem.directory-enumeration"));
|
||||
const pluginManifest = {
|
||||
id: "com.example.directory",
|
||||
name: "directory",
|
||||
@@ -481,3 +483,27 @@ test("Deny for the privileged development runtime stops its activation through t
|
||||
assert.equal(stops, 1); assert.equal(current, identity); assert.equal(service.database.getActivePlugin(manifest.id).enabled, true);
|
||||
service.database.privateOwner = privateOwner;
|
||||
});
|
||||
|
||||
test("production host composition does not advertise or prompt for unavailable directory enumeration", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-directory-availability-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let prompts = 0;
|
||||
const feature = "filesystem.directory-enumeration";
|
||||
// Same adapter-free composition as main/registerBridges; even an explicit extra
|
||||
// supportedFeatures entry cannot claim an adapter that was never supplied.
|
||||
const service = createPluginHostService({ ...createOptions(root), supportedFeatures: [feature],
|
||||
requestPermissionDecision: async () => { prompts++; throw new Error("must not prompt"); } });
|
||||
context.after(() => service.manager.shutdown());
|
||||
assert.equal(service.runtimeSupervisor.supportedFeatures.includes(feature), false);
|
||||
assert.equal(service.packageStore.supportedFeatures.includes(feature), false);
|
||||
let authorizations = 0, probes = 0;
|
||||
service.permissionEngine.authorize = async () => { authorizations++; throw new Error("must not authorize"); };
|
||||
service.filesystemBroker.fileSystem = new Proxy({}, { get() { probes++; throw new Error("must not probe"); } });
|
||||
const manifest = { id: "com.example.unavailable-directory", version: "1.0.0",
|
||||
permissions: { optional: [{ permission: "filesystem.read", resources: [root] }] } };
|
||||
const routes = service.rpcRegistry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version,
|
||||
runtimeId: "runtime-unavailable", runtimeKind: "browser", manifest });
|
||||
await assert.rejects(routes.requestHandlers["filesystem.readDirectory"]({ path: root }, transportContext()),
|
||||
(error) => error.code === -32012 && /unavailable/.test(error.message));
|
||||
assert.equal(authorizations, 0); assert.equal(prompts, 0); assert.equal(probes, 0);
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginContributionService } = require("./contributionService.cjs");
|
||||
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
||||
const { registerPluginBridge, CHANNELS } = require("./pluginBridge.cjs");
|
||||
const { OFFICIAL_SOURCE, thirdPartySource } = require("./sourceIdentity.cjs");
|
||||
|
||||
const id = "com.example.sync-composition";
|
||||
const synced = `${id}.synced`, local = `${id}.local`;
|
||||
const manifest = {
|
||||
manifestVersion: 1, id, name: "Synthetic sync composition", version: "1.0.0", publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" },
|
||||
contributes: { settings: [
|
||||
{ id: synced, label: "Synced", control: "text", scope: "application", default: "default-sync", sync: true },
|
||||
{ id: local, label: "Local", control: "text", scope: "application", default: "default-local", sync: false },
|
||||
] },
|
||||
};
|
||||
function install(database, source = OFFICIAL_SOURCE, enabled = true) {
|
||||
database.installVersion({ pluginId: id, version: manifest.version, manifest, source,
|
||||
archiveSha256: "a".repeat(64), packageRelativePath: "synthetic/no-executable-package" }, { enable: enabled });
|
||||
}
|
||||
function profile(context, label, clock) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `netcatty-sync-composition-${label}-`));
|
||||
const database = new PluginDatabase(path.join(root, "plugins.sqlite"), { clock: () => clock });
|
||||
context.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const runtimeSupervisor = {
|
||||
start: async () => { throw new Error("composition test must not execute a plugin"); },
|
||||
request: async () => { throw new Error("composition test must not call a plugin"); },
|
||||
notify: async () => {},
|
||||
};
|
||||
const contributions = new PluginContributionService({ database, runtimeSupervisor, getLocale: () => "en" });
|
||||
const sidecars = new PluginSyncSidecarService({ database, contributionService: contributions });
|
||||
const handlers = new Map();
|
||||
registerPluginBridge({ handle: (name, handler) => handlers.set(name, handler), on() {} }, {
|
||||
manager: { initialize: async () => {}, database }, contributionService: contributions,
|
||||
syncSidecarService: sidecars, isTrustedSender: (event) => event?.trusted === true,
|
||||
});
|
||||
return { database, contributions, sidecars,
|
||||
collect: () => handlers.get(CHANNELS.syncSidecarsCollect)({ trusted: true }),
|
||||
apply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: true }, bundle),
|
||||
untrustedApply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: false }, bundle) };
|
||||
}
|
||||
|
||||
test("two real profiles collect/apply/hydrate only opted-in source-bound settings through registered host entries", async (context) => {
|
||||
for (const state of ["enabled", "disabled", "missing", "other-source"]) {
|
||||
await context.test(state, async (context) => {
|
||||
const a = profile(context, "sender", 200), b = profile(context, "recipient", 100);
|
||||
install(a.database);
|
||||
await a.contributions.updateSetting(id, synced, "synthetic-shared-value");
|
||||
await a.contributions.updateSetting(id, local, "synthetic-device-only-value");
|
||||
const bundle = await a.collect();
|
||||
assert.equal(bundle.entries.filter((entry) => entry.kind === "settings").length, 1);
|
||||
assert.equal(bundle.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
|
||||
const owner = a.database.privateOwner(id);
|
||||
assert.equal(bundle.entries[0].pluginId, owner);
|
||||
if (state !== "missing") install(b.database,
|
||||
state === "other-source" ? thirdPartySource("Synthetic community", "https://community.example.com") : OFFICIAL_SOURCE,
|
||||
state !== "disabled");
|
||||
await assert.rejects(b.untrustedApply(bundle), /Untrusted/);
|
||||
assert.equal(b.database.listAllSyncSidecars().length, 0, "untrusted sender never applies remote rows");
|
||||
const result = await b.apply(bundle);
|
||||
assert.equal(result.applied, true);
|
||||
assert.equal(b.database.getSetting(id, local, "application", "application"), undefined);
|
||||
if (state === "enabled") {
|
||||
assert.equal(await b.contributions.getSetting(id, synced), "synthetic-shared-value");
|
||||
assert.equal(await b.contributions.getSetting(id, local), "default-local");
|
||||
} else {
|
||||
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined,
|
||||
"missing, disabled or different-source plugins cannot receive active settings");
|
||||
assert.ok(b.database.listAllSyncSidecars().some((entry) => entry.pluginId === owner), "remote row is retained without execution");
|
||||
await b.sidecars.hydrateInstalledPluginSettings(id);
|
||||
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined);
|
||||
if (state === "disabled") b.database.setEnabled(id, true);
|
||||
if (state === "missing") install(b.database);
|
||||
await b.sidecars.hydrateInstalledPluginSettings(id);
|
||||
assert.equal(await b.contributions.getSetting(id, synced), state === "other-source" ? "default-sync" : "synthetic-shared-value");
|
||||
if (state === "other-source") assert.notEqual(b.database.privateOwner(id), owner);
|
||||
}
|
||||
const collected = await b.collect();
|
||||
assert.equal(collected.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
|
||||
assert.equal(await a.contributions.getSetting(id, local), "synthetic-device-only-value", "sender's local-only setting stays local");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS, PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
import { connectByteStream } from "./byteStreamClient.mjs";
|
||||
|
||||
@@ -412,8 +412,12 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
...options,
|
||||
}).then(() => undefined),
|
||||
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
|
||||
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
|
||||
.then((result) => result.entries),
|
||||
readDirectory: async (directoryPath) => {
|
||||
if (!config.enabledFeatures?.includes(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)) {
|
||||
throw new PluginError("unsupported", "Secure plugin directory enumeration is unavailable on this runtime");
|
||||
}
|
||||
return (await client.request("filesystem.readDirectory", { path: directoryPath })).entries;
|
||||
},
|
||||
};
|
||||
const companions = {
|
||||
start: async (companionId) => {
|
||||
|
||||
@@ -78,7 +78,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
enabledFeatures: ["filesystem.directory-enumeration"],
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
@@ -128,7 +128,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
|
||||
const initialized = await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
supportedFeatures: ["filesystem.directory-enumeration"],
|
||||
});
|
||||
assert.equal(initialized.pluginId, "com.example.peer");
|
||||
const unhandledStream = await host.streams.openOutgoing("unhandled", 1024);
|
||||
@@ -616,3 +616,23 @@ test("runtime peer routes connection and importer Provider operation maps", asyn
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test("actual SDK directory call rejects an unnegotiated feature without sending host RPC", async (context) => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
let requests = 0;
|
||||
const host = new PluginRpcRouter({ pluginId: "com.example.no-directory", send: (message) => port1.postMessage(message),
|
||||
handlers: { "filesystem.readDirectory": async () => { requests++; return { entries: [] }; }, "log.write": async () => null } });
|
||||
port1.on("message", (message) => host.accept(message));
|
||||
const peer = await startPluginRuntime({ port: port2,
|
||||
config: { pluginId: "com.example.no-directory", pluginVersion: "1.0.0", netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", enabledFeatures: [] },
|
||||
async loadPlugin() { return { default: { async activate(pluginContext) {
|
||||
assert.equal(pluginContext.enabledFeatures.has("filesystem.directory-enumeration"), false);
|
||||
await assert.rejects(pluginContext.filesystem.readDirectory("/not-probed"),
|
||||
(error) => error.code === "unsupported" && /unavailable/.test(error.message));
|
||||
} } }; } });
|
||||
context.after(async () => { await peer.dispose(); host.close(); port1.close(); port2.close(); });
|
||||
await host.request("plugin.initialize", { netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", supportedFeatures: [] });
|
||||
await host.request("plugin.activate", {});
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
@@ -127,7 +127,7 @@ function registerSecurePluginCapabilities(registry, options) {
|
||||
options.filesystemBroker.readDirectory(params, context)
|
||||
), {
|
||||
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
|
||||
validateParams: (params) => options.filesystemBroker.validatePath(params),
|
||||
validateParams: (params) => options.filesystemBroker.validateDirectory(params),
|
||||
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "directory"),
|
||||
});
|
||||
registry.registerRequest("filesystem.writeFile", (params, context) => (
|
||||
|
||||
@@ -4976,6 +4976,9 @@
|
||||
"TerminalInputDraftFeature": {
|
||||
"const": "terminal.input-drafts"
|
||||
},
|
||||
"FilesystemDirectoryEnumerationFeature": {
|
||||
"const": "filesystem.directory-enumeration"
|
||||
},
|
||||
"HostTerminalInputProposalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -194,6 +194,8 @@ export type CredentialRef = {
|
||||
|
||||
export type FeatureId = string;
|
||||
|
||||
export type FilesystemDirectoryEnumerationFeature = "filesystem.directory-enumeration";
|
||||
|
||||
export type HostClipboardReadRequest = {
|
||||
kind: "clipboard.read";
|
||||
title: string;
|
||||
|
||||
@@ -69,3 +69,4 @@ export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satis
|
||||
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
|
||||
|
||||
export const PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE = "terminal.input-drafts" as const satisfies import("./generated/plugin-contract.js").TerminalInputDraftFeature;
|
||||
export const PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE = "filesystem.directory-enumeration" as const satisfies import("./generated/plugin-contract.js").FilesystemDirectoryEnumerationFeature;
|
||||
@@ -711,6 +711,7 @@ export interface PluginFilesystemClient {
|
||||
overwrite: true;
|
||||
}>): Promise<void>;
|
||||
stat(path: string): Promise<PluginFilesystemStat>;
|
||||
/** Requires filesystem.directory-enumeration and an authorized filesystem.read directory. */
|
||||
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
|
||||
}
|
||||
|
||||
@@ -981,3 +982,4 @@ export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
|
||||
Reference in new issue
Block a user