mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-15 03:21:15 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb540c3f8 | ||
|
|
db3a9e88e2 | ||
|
|
65435c06f5 | ||
|
|
3096f86889 | ||
|
|
94f8bd9bc7 | ||
|
|
5cd42cb518 | ||
|
|
9ccb62ea07 | ||
|
|
ad05867026 | ||
|
|
e72d6fc7c6 | ||
|
|
907f60616b | ||
|
|
d536b22dee | ||
|
|
74d5e126d4 |
No files matched your search
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
import { buildCacheKey } from "./sharedRemoteHostCache";
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
|
||||
@@ -999,3 +1000,86 @@ test("no-conflict local copy awaits discovery so a late stat cannot overwrite th
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of ["folder-cp", "folder-fallback", "file-cp", "different-key", "missing-key", "non-utf8"] as const) {
|
||||
test(`fresh remote copy selects a consistent strategy: ${scenario} (#4207)`, async () => {
|
||||
const isDirectory = scenario.startsWith("folder");
|
||||
const source = makePane("left");
|
||||
const target = makePane("left");
|
||||
target.connection = { ...target.connection!, id: "target-conn", currentPath: "/usr/tmp" };
|
||||
if (scenario === "non-utf8") target.filenameEncoding = "gb18030";
|
||||
const key = buildCacheKey("host-1", "debian", 22, "ssh", false, "user", "sftp");
|
||||
const keys = new Map([["remote-conn", key], ["target-conn", key]]);
|
||||
if (scenario === "different-key") keys.set("target-conn", buildCacheKey("host-2", "debian", 22, "ssh", false, "user", "sftp"));
|
||||
if (scenario === "missing-key") keys.delete("target-conn");
|
||||
const ownerId = `same-host-${scenario}-4207`;
|
||||
const streams: Array<{ resumable: boolean; sameHost?: boolean; parentTaskId?: string }> = [];
|
||||
const copies: unknown[][] = [];
|
||||
let listings = 0;
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const restore = installGlobals({
|
||||
statSftp: async (id: string) => id === "sftp-source"
|
||||
? { type: "file", size: 3626, lastModified: 1758000001000 }
|
||||
: null,
|
||||
mkdirSftp: async () => undefined,
|
||||
sameHostCopyDirectory: async (...args: unknown[]) => {
|
||||
copies.push(args);
|
||||
assert.equal(sftpTransferCenterStore.getTask(args[4] as string)?.resumable, false);
|
||||
return { success: scenario !== "folder-fallback" };
|
||||
},
|
||||
startStreamTransfer: async (options: { transferId: string; resumable: boolean; sameHost?: boolean; parentTaskId?: string }) => {
|
||||
streams.push(options);
|
||||
assert.equal(sftpTransferCenterStore.getTask(options.transferId)?.resumable, options.resumable);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: options.transferId,
|
||||
transferred: 3626, totalBytes: 3626, lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => side === "left" ? source : target,
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-source"], ["target-conn", "sftp-target"]]) },
|
||||
connectionCacheKeyMapRef: { current: keys },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => { listings++; return source.files; },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const results = await ops!.startTransfer(
|
||||
[{ name: isDirectory ? "bt_mine" : "aops_dns_view_demo.sh", isDirectory }], "left", "right",
|
||||
);
|
||||
assert.equal(results[0]?.status, "completed");
|
||||
});
|
||||
const eligible = ["folder-cp", "folder-fallback", "file-cp"].includes(scenario);
|
||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
assert.ok(rows.length > 0);
|
||||
assert.ok(rows.every(row => row.resumable === !eligible));
|
||||
assert.equal(copies.length, isDirectory ? 1 : 0);
|
||||
if (isDirectory) assert.deepEqual(copies[0]?.slice(0, 4), ["sftp-source", "/opt/bt_mine", "/usr/tmp/bt_mine", "auto"]);
|
||||
assert.equal(listings, scenario === "folder-fallback" ? 1 : 0);
|
||||
assert.equal(streams.length, scenario === "folder-cp" ? 0 : 1);
|
||||
if (streams.length) {
|
||||
assert.equal(streams[0].resumable, !eligible);
|
||||
assert.equal(streams[0].sameHost, !["different-key", "missing-key"].includes(scenario) || undefined);
|
||||
if (isDirectory) assert.ok(streams[0].parentTaskId);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1348,6 +1348,17 @@ export const useSftpTransfers = ({
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
const batchId = crypto.randomUUID();
|
||||
const usesLegacyScp = sourcePane.connection.fileProtocol === "scp" || targetPane.connection.fileProtocol === "scp";
|
||||
const sourceCacheKey = connectionCacheKeyMapRef.current.get(sourcePane.connection.id);
|
||||
const targetCacheKey = connectionCacheKeyMapRef.current.get(targetPane.connection.id);
|
||||
const encodingSafeForExec = [sourcePane.filenameEncoding, targetPane.filenameEncoding]
|
||||
.every((encoding) => !encoding || encoding === "utf-8" || encoding === "auto");
|
||||
// Select the existing non-resumable cp route only for newly created copies.
|
||||
// Resume/recovery keeps its saved strategy and checkpoints. If cp fails,
|
||||
// recursive/download+upload fallback also remains non-resumable.
|
||||
const useSameHostCopy = !usesLegacyScp
|
||||
&& !sourcePane.connection.isLocal && !targetPane.connection.isLocal
|
||||
&& !!sourceCacheKey && sourceCacheKey === targetCacheKey
|
||||
&& encodingSafeForExec;
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
const skippedResults: TransferResult[] = [];
|
||||
@@ -1582,8 +1593,10 @@ export const useSftpTransfers = ({
|
||||
sourceLastModified,
|
||||
preflightStatSkipped: preflightSkippedNames.has(file.name),
|
||||
origin: "manual",
|
||||
resumable: !usesLegacyScp,
|
||||
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
|
||||
resumable: !usesLegacyScp && !useSameHostCopy,
|
||||
pauseUnavailableReason: usesLegacyScp
|
||||
? "This server uses legacy SCP; cancel and retry from the beginning instead"
|
||||
: useSameHostCopy ? "Same-host copies cannot be paused; cancel and retry from the beginning instead" : undefined,
|
||||
phase: file.isDirectory ? "scanning" : "transferring",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,11 +49,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
chainedHosts,
|
||||
hostChainInherited = false,
|
||||
setActiveSubPanel,
|
||||
clearHostChain,
|
||||
proxySummaryType,
|
||||
proxySummaryLabel,
|
||||
proxySummaryTooltip,
|
||||
proxyInherited = false,
|
||||
clearProxyConfig,
|
||||
}) => {
|
||||
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
|
||||
@@ -641,9 +643,16 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
title={t("hostDetails.jumpHosts")}
|
||||
action={
|
||||
chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
{hostChainInherited && (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{t("vault.groups.details.inherited")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
@@ -664,8 +673,10 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
{hostChainInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{!hostChainInherited && (
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
@@ -674,6 +685,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
clearHostChain();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full space-y-1 pl-5">
|
||||
{chainedHosts.slice(0, 5).map((h, idx) => (
|
||||
@@ -735,7 +747,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId || proxyInherited ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -750,6 +762,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{proxySummaryLabel}
|
||||
{proxyInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
@@ -758,6 +771,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
{!proxyInherited && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -768,6 +782,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import { applyGroupDefaults } from "../domain/groupConfig";
|
||||
import {
|
||||
findProxyProfile,
|
||||
hasIncompleteProxyIdentity,
|
||||
@@ -157,3 +158,69 @@ export const prepareProxyConfigForSave = (params: {
|
||||
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export const EMPTY_CUSTOM_PROXY_DRAFT: ProxyConfig = { type: "http", host: "", port: 8080 };
|
||||
|
||||
export const resolveDisplayProxyProfileId = (
|
||||
form: Pick<Host, "directProxy" | "proxyProfileId" | "proxyConfig">,
|
||||
inheritedProfileId?: string,
|
||||
): string | undefined => {
|
||||
if (form.directProxy || form.proxyConfig !== undefined) return form.proxyProfileId;
|
||||
return form.proxyProfileId ?? inheritedProfileId;
|
||||
};
|
||||
|
||||
export const nextFormAfterSelectProxyProfile = (
|
||||
prev: Host,
|
||||
profileId: string | undefined,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): Host => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
if (!profileId) {
|
||||
const inheritedConfig = !prev.directProxy
|
||||
&& prev.proxyProfileId === undefined
|
||||
&& prev.proxyConfig === undefined
|
||||
&& groupDefaults
|
||||
? applyGroupDefaults(prev, groupDefaults).proxyConfig
|
||||
: undefined;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: prev.proxyConfig ?? inheritedConfig ?? EMPTY_CUSTOM_PROXY_DRAFT,
|
||||
} as Host;
|
||||
}
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
};
|
||||
|
||||
export const resolveFormHostChainIds = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): string[] => {
|
||||
if (host.hostChain !== undefined) return host.hostChain.hostIds || [];
|
||||
if (!groupDefaults) return [];
|
||||
return applyGroupDefaults(host, groupDefaults).hostChain?.hostIds || [];
|
||||
};
|
||||
|
||||
export const nextFormAfterClearHostChain = (
|
||||
prev: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): Host => {
|
||||
const groupWouldRestore = Boolean(
|
||||
groupDefaults
|
||||
&& applyGroupDefaults({ ...prev, hostChain: undefined }, groupDefaults).hostChain?.hostIds?.length,
|
||||
);
|
||||
if (prev.hostChain === undefined || groupWouldRestore) {
|
||||
return { ...prev, hostChain: { hostIds: [] } };
|
||||
}
|
||||
const { hostChain: _hostChain, ...rest } = prev;
|
||||
return rest as Host;
|
||||
};
|
||||
|
||||
export const nextFormAfterRemoveHostFromChain = (
|
||||
prev: Host,
|
||||
index: number,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): Host => {
|
||||
const ids = resolveFormHostChainIds(prev, groupDefaults).filter((_, i) => i !== index);
|
||||
if (ids.length > 0) return { ...prev, hostChain: { hostIds: ids } };
|
||||
return nextFormAfterClearHostChain(prev, groupDefaults);
|
||||
};
|
||||
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host, Identity } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import { applyGroupDefaults } from "../domain/groupConfig.ts";
|
||||
import {
|
||||
prepareProxyConfigForSave,
|
||||
prepareTelnetCredentialsForSave,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
validateProxyConfigForSave,
|
||||
nextFormAfterSelectProxyProfile,
|
||||
nextFormAfterClearHostChain,
|
||||
nextFormAfterRemoveHostFromChain,
|
||||
resolveDisplayProxyProfileId,
|
||||
} from "./HostDetailsPanel.helpers.ts";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
@@ -549,3 +554,198 @@ test("HostDetailsPanel expands notes when the host already has notes", () => {
|
||||
"expected notes to render after connection settings",
|
||||
);
|
||||
});
|
||||
|
||||
const networkSshHost: Host = {
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
group: "network",
|
||||
};
|
||||
|
||||
const jumpHost: Host = {
|
||||
id: "jump-1",
|
||||
label: "Bastion",
|
||||
hostname: "bastion.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const hostOwnedJump: Host = {
|
||||
...jumpHost,
|
||||
id: "jump-host",
|
||||
label: "Edge",
|
||||
hostname: "edge.example.com",
|
||||
};
|
||||
|
||||
const renderHostDetailsWithGroup = (
|
||||
initialData: Host,
|
||||
groupConfig: Record<string, unknown>,
|
||||
extraHosts: Host[] = [],
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: extraHosts,
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", ...groupConfig }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
test("HostDetailsPanel displays inherited group HTTP proxy", () => {
|
||||
const markup = renderHostDetailsWithGroup(networkSshHost, {
|
||||
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
|
||||
});
|
||||
|
||||
assert.match(markup, /group-proxy\.example\.com/);
|
||||
assert.match(markup, /Inherited from group/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel host proxy override wins over the group proxy", () => {
|
||||
const markup = renderHostDetailsWithGroup({
|
||||
...networkSshHost,
|
||||
proxyConfig: { type: "http", host: "host-proxy.example.com", port: 9090 },
|
||||
}, {
|
||||
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
|
||||
});
|
||||
|
||||
assert.match(markup, /host-proxy\.example\.com/);
|
||||
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
|
||||
assert.doesNotMatch(markup, /Inherited from group/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel direct proxy hides inherited group proxy", () => {
|
||||
const markup = renderHostDetailsWithGroup({
|
||||
...networkSshHost,
|
||||
directProxy: true,
|
||||
}, {
|
||||
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
|
||||
});
|
||||
|
||||
assert.match(markup, /Direct \(no proxy\)/);
|
||||
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel displays inherited group jump hosts", () => {
|
||||
const markup = renderHostDetailsWithGroup(networkSshHost, {
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
}, [jumpHost]);
|
||||
|
||||
assert.match(markup, /bastion\.example\.com/);
|
||||
assert.match(markup, /Inherited from group/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel host jump-host override wins over the group chain", () => {
|
||||
const markup = renderHostDetailsWithGroup({
|
||||
...networkSshHost,
|
||||
hostChain: { hostIds: ["jump-host"] },
|
||||
}, {
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
}, [jumpHost, hostOwnedJump]);
|
||||
|
||||
assert.match(markup, /edge\.example\.com/);
|
||||
assert.doesNotMatch(markup, /bastion\.example\.com/);
|
||||
assert.doesNotMatch(markup, /Inherited from group/);
|
||||
});
|
||||
|
||||
|
||||
test("selecting Custom while inheriting a saved proxy keeps a custom draft instead of the profile", () => {
|
||||
const group = { proxyProfileId: "office-proxy" };
|
||||
const next = nextFormAfterSelectProxyProfile(networkSshHost, undefined, group);
|
||||
|
||||
assert.equal(next.proxyProfileId, undefined);
|
||||
assert.equal(next.directProxy, undefined);
|
||||
assert.deepEqual(next.proxyConfig, { type: "http", host: "", port: 8080 });
|
||||
assert.equal(resolveDisplayProxyProfileId(next, "office-proxy"), undefined);
|
||||
assert.equal(applyGroupDefaults(next, group).proxyProfileId, undefined);
|
||||
});
|
||||
|
||||
test("removing the last hop of a multi-hop inherited chain keeps an empty override", () => {
|
||||
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
|
||||
const afterFirst = nextFormAfterRemoveHostFromChain(networkSshHost, 0, group);
|
||||
assert.deepEqual(afterFirst.hostChain, { hostIds: ["jump-host"] });
|
||||
|
||||
const afterLast = nextFormAfterRemoveHostFromChain(afterFirst, 0, group);
|
||||
assert.deepEqual(afterLast.hostChain, { hostIds: [] });
|
||||
assert.deepEqual(applyGroupDefaults(afterLast, group).hostChain, { hostIds: [] });
|
||||
});
|
||||
|
||||
test("clearing an inherited-only chain keeps an empty override instead of restoring group hops", () => {
|
||||
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
|
||||
const cleared = nextFormAfterClearHostChain(networkSshHost, group);
|
||||
assert.deepEqual(cleared.hostChain, { hostIds: [] });
|
||||
assert.deepEqual(applyGroupDefaults(cleared, group).hostChain, { hostIds: [] });
|
||||
});
|
||||
|
||||
test("HostDetailsPanel custom proxy draft hides the inherited saved proxy", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...networkSshHost,
|
||||
proxyConfig: { type: "http", host: "", port: 8080 },
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [{
|
||||
id: "office-proxy",
|
||||
label: "Office Proxy",
|
||||
config: { type: "http", host: "office-proxy.example.com", port: 8080 },
|
||||
createdAt: 1,
|
||||
}],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", proxyProfileId: "office-proxy" }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.doesNotMatch(markup, /Office Proxy/);
|
||||
assert.doesNotMatch(markup, /office-proxy\.example\.com/);
|
||||
assert.doesNotMatch(markup, /Inherited from group/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel empty jump-host override hides the inherited group chain", () => {
|
||||
const markup = renderHostDetailsWithGroup({
|
||||
...networkSshHost,
|
||||
hostChain: { hostIds: [] },
|
||||
}, {
|
||||
hostChain: { hostIds: ["jump-1", "jump-host"] },
|
||||
}, [jumpHost, hostOwnedJump]);
|
||||
|
||||
assert.doesNotMatch(markup, /bastion\.example\.com/);
|
||||
assert.doesNotMatch(markup, /edge\.example\.com/);
|
||||
assert.doesNotMatch(markup, /Inherited from group/);
|
||||
});
|
||||
@@ -55,6 +55,11 @@ import {
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
prepareTelnetCredentialsForSave,
|
||||
prepareProxyConfigForSave,
|
||||
nextFormAfterSelectProxyProfile,
|
||||
nextFormAfterClearHostChain,
|
||||
nextFormAfterRemoveHostFromChain,
|
||||
resolveFormHostChainIds,
|
||||
resolveDisplayProxyProfileId,
|
||||
} from "./HostDetailsPanel.helpers";
|
||||
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
||||
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
|
||||
@@ -424,24 +429,34 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
);
|
||||
|
||||
const effectiveFormDistro = getEffectiveHostDistro(form);
|
||||
const hasHostProxyOverride = form.directProxy === true
|
||||
|| form.proxyProfileId !== undefined
|
||||
|| form.proxyConfig !== undefined;
|
||||
const displayProxyProfileId = resolveDisplayProxyProfileId(form, effectiveAuthHost.proxyProfileId);
|
||||
const displayProxyConfig = form.directProxy
|
||||
? undefined
|
||||
: (form.proxyConfig ?? effectiveAuthHost.proxyConfig);
|
||||
const selectedProxyProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
|
||||
[form.proxyProfileId, proxyProfiles],
|
||||
() => proxyProfiles.find((profile) => profile.id === displayProxyProfileId),
|
||||
[displayProxyProfileId, proxyProfiles],
|
||||
);
|
||||
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
||||
const hasMissingProxyProfile = Boolean(displayProxyProfileId && !selectedProxyProfile);
|
||||
const proxyInherited = !hasHostProxyOverride && Boolean(
|
||||
displayProxyProfileId || displayProxyConfig?.host || displayProxyConfig?.command,
|
||||
) && Boolean(effectiveGroupDefaults?.proxyProfileId || effectiveGroupDefaults?.proxyConfig);
|
||||
const proxySummaryType = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missing")
|
||||
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
|
||||
: formatProxyConfigType(selectedProxyProfile?.config || displayProxyConfig) || "HTTP";
|
||||
const proxySummaryLabel = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: formatProxyConfigEndpoint(form.proxyConfig);
|
||||
: formatProxyConfigEndpoint(displayProxyConfig);
|
||||
const proxySummaryTooltip = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
|
||||
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
|
||||
: `${formatProxyConfigType(displayProxyConfig)} ${formatProxyConfigEndpoint(displayProxyConfig)}`;
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
@@ -458,13 +473,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
const inheritedConfig = !prev.directProxy
|
||||
&& prev.proxyProfileId === undefined
|
||||
&& prev.proxyConfig === undefined
|
||||
&& effectiveGroupDefaults
|
||||
? applyGroupDefaults(prev, effectiveGroupDefaults).proxyConfig
|
||||
: undefined;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig ?? inheritedConfig, field, value),
|
||||
} as Host;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[effectiveGroupDefaults],
|
||||
);
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
@@ -475,12 +496,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
setForm((prev) => nextFormAfterSelectProxyProfile(prev, profileId, effectiveGroupDefaults));
|
||||
}, [effectiveGroupDefaults]);
|
||||
|
||||
const selectDirectProxy = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
@@ -493,24 +510,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hostChain: {
|
||||
hostIds: [...(prev.hostChain?.hostIds || []), hostId],
|
||||
hostIds: [...resolveFormHostChainIds(prev, effectiveGroupDefaults), hostId],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const removeHostFromChain = (index: number) => {
|
||||
setForm((prev) => {
|
||||
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
|
||||
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
|
||||
});
|
||||
setForm((prev) => nextFormAfterRemoveHostFromChain(prev, index, effectiveGroupDefaults));
|
||||
};
|
||||
|
||||
const clearHostChain = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { hostChain: _hostChain, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
setForm((prev) => nextFormAfterClearHostChain(prev, effectiveGroupDefaults));
|
||||
}, [effectiveGroupDefaults]);
|
||||
|
||||
const [newEnvName, setNewEnvName] = useState("");
|
||||
const [newEnvValue, setNewEnvValue] = useState("");
|
||||
@@ -773,8 +784,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
setActiveSubPanel("none");
|
||||
};
|
||||
|
||||
const displayHostChainIds = useMemo(
|
||||
() => form.hostChain !== undefined
|
||||
? (form.hostChain.hostIds || [])
|
||||
: (effectiveAuthHost.hostChain?.hostIds || []),
|
||||
[effectiveAuthHost.hostChain?.hostIds, form.hostChain],
|
||||
);
|
||||
const hostChainInherited = form.hostChain === undefined && displayHostChainIds.length > 0;
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
const chainedIds = new Set(displayHostChainIds);
|
||||
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
|
||||
// entries (e.g. saved local shells) as jump hosts.
|
||||
return allHosts.filter((h) =>
|
||||
@@ -782,14 +801,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
&& !chainedIds.has(h.id)
|
||||
&& canServeAsSshJumpHost(h),
|
||||
);
|
||||
}, [allHosts, form.id, form.hostChain?.hostIds]);
|
||||
}, [allHosts, displayHostChainIds, form.id]);
|
||||
|
||||
const chainedHosts = useMemo(() => {
|
||||
const ids = form.hostChain?.hostIds || [];
|
||||
return ids
|
||||
return displayHostChainIds
|
||||
.map((id) => allHosts.find((h) => h.id === id))
|
||||
.filter(Boolean) as Host[];
|
||||
}, [allHosts, form.hostChain?.hostIds]);
|
||||
}, [allHosts, displayHostChainIds]);
|
||||
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
return groups.map((g) => ({
|
||||
@@ -905,11 +923,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
if (activeSubPanel === "proxy") {
|
||||
return (
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyConfig={displayProxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
selectedProxyProfileId={displayProxyProfileId}
|
||||
directProxy={form.directProxy}
|
||||
allowClear={hasHostProxyOverride}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onSelectDirectProxy={selectDirectProxy}
|
||||
@@ -1237,11 +1256,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
||||
chainedHosts={chainedHosts}
|
||||
hostChainInherited={hostChainInherited}
|
||||
setActiveSubPanel={setActiveSubPanel}
|
||||
clearHostChain={clearHostChain}
|
||||
proxySummaryType={proxySummaryType}
|
||||
proxySummaryLabel={proxySummaryLabel}
|
||||
proxySummaryTooltip={proxySummaryTooltip}
|
||||
proxyInherited={proxyInherited}
|
||||
clearProxyConfig={clearProxyConfig}
|
||||
/>
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ProxyPanelProps {
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
allowClear?: boolean;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
allowClear = true,
|
||||
layout = 'overlay',
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
@@ -346,7 +348,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
|
||||
{allowClear && (proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
|
||||
@@ -66,9 +66,15 @@ test('local logging controls default off, disclose verbose risk, and call host-o
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
|
||||
await act(async () => button('Close').click());
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, configure);
|
||||
// Radix restores focus in a setTimeout(0) after the dialog unmounts.
|
||||
// act() can finish before that timer, so drain it before checking focus.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// Comparing DOM objects directly makes Node inspect the entire JSDOM graph
|
||||
// on failure, which can stall the test process while formatting the diff.
|
||||
assert.ok(document.activeElement === configure, `expected focus on Configure, got ${document.activeElement?.tagName}`);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
|
||||
@@ -9,6 +9,13 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
|
||||
assert.match(source, /Toggle,\s*\n\s*Select,/);
|
||||
});
|
||||
|
||||
test("folder compression row exposes its settings search anchor", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/<SettingRow\s+anchorId="sftp-compressed-upload"\s+label=\{t\('settings\.sftp\.compressedUpload\.enable'\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
function functionBody(functionName: string): string {
|
||||
const start = source.indexOf(`const ${functionName} = useCallback`);
|
||||
assert.notEqual(start, -1, `${functionName} should exist`);
|
||||
|
||||
@@ -180,6 +180,7 @@ export default function SettingsFileAssociationsTab() {
|
||||
<SectionHeader title={t('settings.sftp.compressedUpload')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-compressed-upload"
|
||||
label={t('settings.sftp.compressedUpload.enable')}
|
||||
description={t('settings.sftp.compressedUpload.enableDesc')}
|
||||
>
|
||||
@@ -231,12 +232,14 @@ export default function SettingsFileAssociationsTab() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="sftp-skip-unchanged"
|
||||
label={t('settings.sftp.skipUnchanged.enable')}
|
||||
description={t('settings.sftp.skipUnchanged.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="sftp-transport-idle-ttl"
|
||||
label={t('settings.ssh.transportIdleTtl')}
|
||||
description={t('settings.ssh.transportIdleTtl.desc')}
|
||||
>
|
||||
|
||||
@@ -75,6 +75,9 @@ test('list hook preserves local copy/move routing, remote HTML drag, selection a
|
||||
await act(async () => root.render(React.createElement(Harness, { local: false })));
|
||||
await act(async () => right.handlePaneDrop(event()));
|
||||
assert.equal(actions.at(-1)?.kind, 'copy');
|
||||
actions.length = 0;
|
||||
await act(async () => left.handleEntryDrop(entries[2], event()));
|
||||
assert.deepEqual(actions, [{ kind: 'move', args: [['/files/b.txt'], '/files/folder'] }], 'same-pane remote drag stays a move');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
clearLocalFileDrag();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("overview CPU cores list renders every reported core and can scroll", () => {
|
||||
const source = readFileSync(new URL("./SystemOverviewTab.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /cpuPerCore\.slice\(/);
|
||||
assert.match(
|
||||
source,
|
||||
/className="grid grid-cols-2 gap-x-3 gap-y-1\.5 max-h-\[\d+px\] overflow-y-auto pr-1 overscroll-contain"/,
|
||||
);
|
||||
});
|
||||
@@ -358,8 +358,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
|
||||
</span>
|
||||
</div>
|
||||
{stats.cpuPerCore.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
||||
{stats.cpuPerCore.slice(0, 12).map((core, index) => (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain">
|
||||
{stats.cpuPerCore.map((core, index) => (
|
||||
<ResourceBar key={`core-${index}`} label={`C${index + 1}`} value={core} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,15 @@ test("a visible pane past the cap releases the least recently seen hidden pane",
|
||||
assert.equal(budget.getActiveCount(), 3);
|
||||
});
|
||||
|
||||
test("new visible panes are refused when visible holders fill the cap", () => {
|
||||
const { budget } = setup(2);
|
||||
const members = ["a", "b", "c", "d"].map((name) => new FakeMember(name));
|
||||
assert.deepEqual(members.map((member) => budget.tryAcquire(member)), [true, true, false, false]);
|
||||
assert.equal(budget.tryAcquire(members[0]), true, "an existing holder keeps its slot");
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
assert.deepEqual(members.map((member) => member.releases), [0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test("recently hidden panes are released only once they stay hidden long enough", () => {
|
||||
const { clock, budget } = setup(2);
|
||||
const [a, b] = ["a", "b"].map((name) => new FakeMember(name));
|
||||
@@ -101,6 +110,8 @@ test("recently hidden panes are released only once they stay hidden long enough"
|
||||
budget.tryAcquire(new FakeMember("shown"));
|
||||
assert.equal(a.releases, 0, "a quick tab flip must not lose the renderer");
|
||||
assert.equal(budget.getActiveCount(), 3);
|
||||
assert.equal(budget.tryAcquire(new FakeMember("overflow")), false);
|
||||
assert.equal(budget.getActiveCount(), 3, "one hidden holder allows only one extra context");
|
||||
|
||||
clock.advance(2_999);
|
||||
assert.equal(a.releases, 0);
|
||||
@@ -110,26 +121,33 @@ test("recently hidden panes are released only once they stay hidden long enough"
|
||||
assert.equal(clock.pendingTimers, 0);
|
||||
});
|
||||
|
||||
test("a pane hidden after the budget went over is released once eligible", () => {
|
||||
test("a pane hidden after a grace overshoot is released once eligible", () => {
|
||||
const { clock, budget } = setup(2);
|
||||
const [a, b, c] = ["a", "b", "c"].map((name) => new FakeMember(name));
|
||||
budget.tryAcquire(a);
|
||||
budget.tryAcquire(b);
|
||||
budget.tryAcquire(c);
|
||||
assert.equal(budget.getActiveCount(), 3, "three visible panes all keep WebGL");
|
||||
assert.equal(budget.tryAcquire(c), false);
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
assert.equal(clock.pendingTimers, 0);
|
||||
|
||||
a.hiddenSince = clock.now();
|
||||
budget.noteHidden(a);
|
||||
assert.equal(budget.tryAcquire(c), true);
|
||||
assert.equal(budget.getActiveCount(), 3, "recently hidden holders allow a grace overshoot");
|
||||
a.hiddenSince = null;
|
||||
b.hiddenSince = clock.now();
|
||||
budget.noteHidden(b);
|
||||
clock.advance(3_000);
|
||||
|
||||
assert.equal(b.releases, 1);
|
||||
assert.equal(a.releases, 0, "a holder shown again keeps its renderer");
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
});
|
||||
|
||||
test("a hidden pane cannot take a context once the budget is full", () => {
|
||||
const { budget } = setup(2);
|
||||
budget.tryAcquire(new FakeMember("a"));
|
||||
const { clock, budget } = setup(2);
|
||||
const a = new FakeMember("a");
|
||||
budget.tryAcquire(a);
|
||||
budget.tryAcquire(new FakeMember("b"));
|
||||
|
||||
const hidden = new FakeMember("hidden");
|
||||
@@ -138,7 +156,13 @@ test("a hidden pane cannot take a context once the budget is full", () => {
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
|
||||
hidden.hiddenSince = null;
|
||||
assert.equal(budget.tryAcquire(hidden), true, "it gets one as soon as it is shown");
|
||||
assert.equal(budget.tryAcquire(hidden), false, "visible holders still fill the cap");
|
||||
a.hiddenSince = clock.now();
|
||||
budget.noteHidden(a);
|
||||
clock.advance(3_000);
|
||||
assert.equal(budget.tryAcquire(hidden), true, "a shown pane can replace an eligible hidden holder");
|
||||
assert.equal(a.releases, 1);
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
});
|
||||
|
||||
test("released and forgotten panes free their slot", () => {
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
*
|
||||
* The budget stays below the browser limit by releasing the renderers of panes
|
||||
* that have been hidden for a while, least recently seen first. Visible panes
|
||||
* are never released and always get a context. A released pane builds a new
|
||||
* renderer when it is shown again. Below the cap nothing changes.
|
||||
* are never released, but new ones stay on DOM when visible holders fill the
|
||||
* cap. Recently hidden holders may temporarily exceed the cap during tab flips.
|
||||
* A released pane can rebuild when shown if the budget permits.
|
||||
*/
|
||||
|
||||
export const MAX_ACTIVE_WEBGL_CONTEXTS = 12;
|
||||
@@ -31,8 +32,8 @@ export interface WebglContextBudgetMember {
|
||||
|
||||
export interface WebglContextBudget {
|
||||
/**
|
||||
* Reserve a context for a member about to create its renderer. Visible
|
||||
* members always get one; hidden members only while the budget has room.
|
||||
* Reserve a context if the budget has room. Visible members may replace
|
||||
* hidden holders, allowing a temporary overshoot until their grace expires.
|
||||
*/
|
||||
tryAcquire(member: WebglContextBudgetMember): boolean;
|
||||
/** Give the slot back once the member's renderer is gone. Idempotent. */
|
||||
@@ -102,7 +103,11 @@ export function createWebglContextBudget(
|
||||
return {
|
||||
tryAcquire(member) {
|
||||
if (active.has(member)) return true;
|
||||
if (member.getHiddenSince() !== null && active.size >= maxActive) return false;
|
||||
if (active.size >= maxActive) {
|
||||
if (member.getHiddenSince() !== null) return false;
|
||||
const visibleCount = [...active].filter((holder) => holder.getHiddenSince() === null).length;
|
||||
if (visibleCount >= maxActive) return false;
|
||||
}
|
||||
active.add(member);
|
||||
releaseOverflow();
|
||||
return true;
|
||||
|
||||
@@ -186,20 +186,93 @@ test("panes past the budget give up WebGL after staying hidden, and get it back
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
});
|
||||
|
||||
test("simultaneous visible ensures stay within the context budget", () => {
|
||||
const { budget, createPane } = setup();
|
||||
const panes = Array.from({ length: 13 }, () => createPane());
|
||||
for (const pane of panes) pane.controller.ensure();
|
||||
|
||||
assert.deepEqual(panes.map((pane) => pane.loaded), [...Array(12).fill(true), false]);
|
||||
assert.equal(panes[12].addons.length, 0);
|
||||
assert.equal(budget.getActiveCount(), 12);
|
||||
});
|
||||
|
||||
test("visible context loss recovers at the cap, including with a refused visible pane", () => {
|
||||
for (const withOverflow of [false, true]) {
|
||||
const { clock, budget, createPane } = setup(2);
|
||||
const panes = [createPane(), createPane()];
|
||||
for (const pane of panes) pane.controller.ensure();
|
||||
const overflow = withOverflow ? createPane() : null;
|
||||
overflow?.controller.ensure();
|
||||
assert.equal(overflow?.addons.length ?? 0, 0);
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
|
||||
panes[0].addons[0].loseContext();
|
||||
assert.equal(budget.getActiveCount(), 1);
|
||||
clock.advance(49);
|
||||
assert.equal(panes[0].loaded, false);
|
||||
clock.advance(1);
|
||||
assert.equal(panes[0].loaded, true);
|
||||
assert.equal(panes[0].addons.length, 2);
|
||||
assert.equal(panes[1].addons.length, 1);
|
||||
overflow?.controller.ensure();
|
||||
assert.equal(overflow?.addons.length ?? 0, 0);
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
assert.equal(clock.pendingTimers, 0);
|
||||
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
|
||||
}
|
||||
});
|
||||
|
||||
test("visible loss stays on DOM if another visible pane takes the freed slot before recovery", () => {
|
||||
const { clock, budget, createPane } = setup(2);
|
||||
const panes = [createPane(), createPane(), createPane()];
|
||||
for (const pane of panes) pane.controller.ensure();
|
||||
assert.equal(panes[2].loaded, false);
|
||||
|
||||
panes[0].addons[0].loseContext();
|
||||
panes[2].controller.ensure();
|
||||
assert.equal(panes[2].loaded, true);
|
||||
clock.advance(50);
|
||||
|
||||
assert.equal(panes[0].loaded, false);
|
||||
assert.deepEqual(panes.map((pane) => pane.addons.length), [1, 1, 1]);
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
assert.equal(clock.pendingTimers, 0);
|
||||
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
|
||||
});
|
||||
|
||||
test("a hidden pane mounted past the budget keeps the DOM renderer until shown", () => {
|
||||
const { budget, createPane } = setup(1);
|
||||
createPane().controller.ensure();
|
||||
const { clock, budget, createPane } = setup(1);
|
||||
const occupant = createPane();
|
||||
occupant.controller.ensure();
|
||||
|
||||
const hidden = createPane(false);
|
||||
hidden.controller.ensure();
|
||||
assert.equal(hidden.addons.length, 0);
|
||||
assert.equal(budget.getActiveCount(), 1);
|
||||
|
||||
occupant.setVisible(false);
|
||||
clock.advance(3_000);
|
||||
hidden.reveal();
|
||||
assert.equal(hidden.loaded, true);
|
||||
assert.equal(occupant.loaded, false);
|
||||
assert.equal(budget.getActiveCount(), 1);
|
||||
});
|
||||
|
||||
test("a pane on screen is never refused or released, even if a sync was missed", () => {
|
||||
test("revealing a hidden pane is refused while visible holders fill the budget", () => {
|
||||
const { budget, createPane } = setup(1);
|
||||
const occupant = createPane();
|
||||
occupant.controller.ensure();
|
||||
const hidden = createPane(false);
|
||||
hidden.controller.ensure();
|
||||
hidden.reveal();
|
||||
|
||||
assert.equal(hidden.loaded, false);
|
||||
assert.equal(hidden.addons.length, 0);
|
||||
assert.equal(occupant.loaded, true);
|
||||
assert.equal(budget.getActiveCount(), 1);
|
||||
});
|
||||
|
||||
test("a holder on screen is never released, even if a sync was missed", () => {
|
||||
const { clock, budget, createPane } = setup(1);
|
||||
const visible = createPane();
|
||||
visible.controller.ensure();
|
||||
@@ -210,12 +283,37 @@ test("a pane on screen is never refused or released, even if a sync was missed",
|
||||
createPane().controller.ensure();
|
||||
clock.advance(10_000);
|
||||
assert.equal(visible.loaded, true);
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
assert.equal(budget.getActiveCount(), 1);
|
||||
|
||||
const late = createPane(false);
|
||||
late.setVisibleWithoutSync(true);
|
||||
late.controller.ensure();
|
||||
assert.equal(late.loaded, true);
|
||||
assert.equal(late.loaded, false);
|
||||
assert.equal(late.addons.length, 0);
|
||||
assert.equal(budget.getActiveCount(), 1);
|
||||
});
|
||||
|
||||
test("grace overflow never releases a holder shown again without a visibility sync", () => {
|
||||
const { clock, budget, createPane } = setup(1);
|
||||
const holder = createPane();
|
||||
holder.controller.ensure();
|
||||
holder.setVisible(false);
|
||||
const front = createPane();
|
||||
front.controller.ensure();
|
||||
assert.equal(budget.getActiveCount(), 2);
|
||||
|
||||
holder.setVisibleWithoutSync(true);
|
||||
clock.advance(3_000);
|
||||
assert.equal(holder.loaded, true);
|
||||
assert.equal(front.loaded, true);
|
||||
assert.equal(holder.addons[0].disposeCalls, 0);
|
||||
|
||||
front.addons[0].loseContext();
|
||||
clock.advance(50);
|
||||
assert.equal(front.loaded, false, "recovery cannot exceed a cap already filled by visible holders");
|
||||
assert.equal(front.addons.length, 1);
|
||||
assert.equal(budget.getActiveCount(), 1);
|
||||
assert.equal(clock.pendingTimers, 0);
|
||||
});
|
||||
|
||||
test("suspend, dispose and failed loads return the budget slot", () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
|
||||
|
||||
// isVisible() is the source of truth; hiddenSince only remembers when the
|
||||
// pane was last seen. Re-reading it at every decision keeps a pane that is
|
||||
// on screen from being refused or released even if a sync was missed.
|
||||
// on screen from being released even if a sync was missed.
|
||||
const readVisibility = () => {
|
||||
if (!options.isVisible) return;
|
||||
if (options.isVisible()) hiddenSince = null;
|
||||
@@ -94,7 +94,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
|
||||
|
||||
const ensure = () => {
|
||||
if (loaded || circuitBroken || disposed || !options.enabled) return;
|
||||
// A hidden pane past the budget keeps the DOM renderer until it is shown.
|
||||
// Panes past the budget keep the DOM renderer until a slot is available.
|
||||
if (budget && !budget.tryAcquire(budgetMember)) return;
|
||||
let nextAddon: TAddon | null = null;
|
||||
try {
|
||||
|
||||
@@ -15,33 +15,35 @@
|
||||
<text x="50%" y="30" fill="#e6edf3" font-size="20" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Star History</text>
|
||||
<rect width="178" height="32" x="105" y="90" fill="#161b22" fill-opacity="0.9" stroke="#30363d" stroke-width="1.5" rx="4" ry="4" filter="url(#xkcdify)"/><rect width="8" height="8" x="115" y="102" rx="2" ry="2" fill="#dd4528" filter="url(#xkcdify)"/><text x="131" y="110" fill="#e6edf3" font-size="15" font-family="Handlee, cursive" font-weight="bold">binaricat/Netcatty</text>
|
||||
<text x="85" y="468.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">0</text>
|
||||
<line x1="90" y1="408.3" x2="765" y2="408.3" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="413.2857142857143" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="353.6" x2="765" y2="353.6" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="358.57142857142856" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="298.9" x2="765" y2="298.9" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="303.8571428571429" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="244.1" x2="765" y2="244.1" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="249.14285714285717" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="189.4" x2="765" y2="189.4" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="194.42857142857144" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<line x1="90" y1="134.7" x2="765" y2="134.7" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="139.71428571428572" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<line x1="90" y1="415.1" x2="765" y2="415.1" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="420.125" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="367.2" x2="765" y2="367.2" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="372.25" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="319.4" x2="765" y2="319.4" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="324.375" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="271.5" x2="765" y2="271.5" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="276.5" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="223.6" x2="765" y2="223.6" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="228.625" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<line x1="90" y1="175.8" x2="765" y2="175.8" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="180.75" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<line x1="90" y1="127.9" x2="765" y2="127.9" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="132.875" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<line x1="90" y1="80.0" x2="765" y2="80.0" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">8K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="217.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="353.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="490.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="628.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="765.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Nov 2026</text>
|
||||
<text x="214.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="347.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="481.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="616.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="749.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Nov 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.2 91.9,461.9 92.2,461.8 C 93.0,461.6 93.7,461.5 94.5,461.3 C 95.2,461.1 96.0,460.9 96.7,460.8 C 97.5,460.7 98.2,460.8 98.9,460.8 C 99.7,460.7 100.4,460.7 101.2,460.6 C 101.9,460.6 102.7,460.5 103.4,460.5 C 104.2,460.4 104.5,460.4 105.6,460.3 C 106.8,460.3 109.0,460.2 110.1,460.2 C 111.2,460.1 111.6,460.1 112.4,460.0 C 113.1,460.0 113.8,460.0 114.6,460.0 C 115.3,460.0 116.1,460.0 116.8,459.9 C 117.6,459.9 118.3,459.9 119.1,459.9 C 119.8,459.8 120.5,459.8 121.3,459.7 C 122.0,459.7 122.8,459.7 123.5,459.5 C 124.3,459.3 125.0,459.1 125.8,458.7 C 126.5,458.4 127.3,457.6 128.0,457.4 C 128.7,457.1 129.5,457.2 130.2,457.2 C 131.0,457.2 131.7,457.1 132.5,457.1 C 133.2,457.1 133.6,457.0 134.7,457.0 C 135.8,457.0 137.7,457.0 139.2,456.9 C 140.7,456.9 142.5,456.9 143.6,456.8 C 144.8,456.8 145.1,456.7 145.9,456.7 C 146.6,456.7 147.4,456.6 148.1,456.6 C 148.9,456.6 149.6,456.5 150.3,456.5 C 151.1,456.5 151.8,456.5 152.6,456.4 C 153.3,456.4 153.7,456.4 154.8,456.4 C 155.9,456.4 157.4,456.3 159.3,456.3 C 161.2,456.2 164.1,456.2 166.0,456.2 C 167.9,456.2 168.2,456.2 170.5,456.2 C 172.7,456.1 177.5,456.0 179.4,455.9 C 181.3,455.9 180.5,455.9 181.6,455.9 C 182.8,455.9 184.2,455.9 186.1,455.8 C 188.0,455.8 189.1,455.8 192.8,455.7 C 196.5,455.7 205.5,455.6 208.5,455.6 C 211.4,455.5 210.0,455.5 210.7,455.5 C 211.4,455.5 211.8,456.1 212.9,455.4 C 214.0,454.8 216.3,454.2 217.4,451.4 C 218.5,448.6 218.9,440.8 219.6,438.4 C 220.4,436.0 221.1,437.4 221.9,437.0 C 222.6,436.6 223.4,436.4 224.1,436.1 C 224.9,435.9 225.6,435.7 226.3,435.5 C 227.1,435.4 227.8,435.4 228.6,435.3 C 229.3,435.2 230.1,435.0 230.8,434.9 C 231.6,434.9 232.3,434.9 233.0,434.9 C 233.8,434.8 234.5,434.6 235.3,434.5 C 236.0,434.4 236.8,434.5 237.5,434.3 C 238.3,434.2 239.0,433.9 239.8,433.7 C 240.5,433.6 241.2,433.7 242.0,433.6 C 242.7,433.5 243.1,433.3 244.2,433.2 C 245.3,433.1 247.6,433.1 248.7,433.0 C 249.8,433.0 250.2,432.9 250.9,432.9 C 251.7,432.8 252.4,432.8 253.2,432.7 C 253.9,432.6 254.7,432.3 255.4,432.2 C 256.1,432.0 256.9,432.7 257.6,431.8 C 258.4,430.9 259.1,427.7 259.9,426.7 C 260.6,425.6 261.4,425.8 262.1,425.6 C 262.8,425.4 263.6,425.7 264.3,425.5 C 265.1,425.4 265.8,425.0 266.6,424.8 C 267.3,424.5 268.1,424.3 268.8,423.8 C 269.6,423.3 270.3,422.3 271.0,421.7 C 271.8,421.1 272.5,420.6 273.3,420.2 C 274.0,419.7 274.8,419.4 275.5,419.1 C 276.3,418.9 277.0,418.7 277.7,418.6 C 278.5,418.5 279.2,418.5 280.0,418.4 C 280.7,418.3 281.5,418.1 282.2,418.0 C 283.0,417.9 283.7,417.9 284.5,417.9 C 285.2,417.8 285.9,417.8 286.7,417.6 C 287.4,417.5 288.2,417.6 288.9,417.1 C 289.7,416.7 290.4,415.7 291.2,415.2 C 291.9,414.7 292.6,414.5 293.4,414.1 C 294.1,413.8 294.9,414.5 295.6,412.9 C 296.4,411.4 297.1,406.6 297.9,404.7 C 298.6,402.9 299.4,402.6 300.1,401.8 C 300.8,401.0 301.6,400.5 302.3,400.0 C 303.1,399.4 303.8,398.9 304.6,398.5 C 305.3,398.1 306.1,397.7 306.8,397.5 C 307.5,397.2 308.3,397.1 309.0,396.9 C 309.8,396.7 310.5,396.4 311.3,396.1 C 312.0,395.8 312.8,395.4 313.5,395.1 C 314.3,394.8 315.0,394.9 315.7,394.6 C 316.5,394.4 317.2,393.9 318.0,393.6 C 318.7,393.3 319.5,393.0 320.2,392.7 C 321.0,392.5 321.7,392.3 322.5,392.1 C 323.2,392.0 323.9,392.0 324.7,391.8 C 325.4,391.7 326.2,391.5 326.9,391.3 C 327.7,391.2 328.4,391.0 329.2,390.8 C 329.9,390.6 330.6,390.4 331.4,390.1 C 332.1,389.7 332.9,389.2 333.6,388.9 C 334.4,388.6 335.1,388.5 335.9,388.3 C 336.6,388.2 337.4,388.1 338.1,387.9 C 338.8,387.8 339.6,387.7 340.3,387.6 C 341.1,387.5 341.8,387.4 342.6,387.3 C 343.3,387.2 344.1,387.2 344.8,387.1 C 345.5,387.0 346.3,386.8 347.0,386.5 C 347.8,386.2 348.5,385.8 349.3,385.4 C 350.0,385.1 350.8,384.7 351.5,384.5 C 352.3,384.3 353.0,384.2 353.7,384.1 C 354.5,384.0 355.2,383.9 356.0,383.7 C 356.7,383.4 357.5,382.9 358.2,382.7 C 359.0,382.5 359.7,382.5 360.4,382.4 C 361.2,382.3 361.9,382.0 362.7,381.9 C 363.4,381.7 364.2,381.5 364.9,381.3 C 365.7,381.1 366.4,380.9 367.2,380.7 C 367.9,380.5 368.6,380.4 369.4,380.2 C 370.1,380.0 370.9,379.6 371.6,379.4 C 372.4,379.2 373.1,379.0 373.9,378.8 C 374.6,378.6 375.3,378.2 376.1,378.0 C 376.8,377.7 377.6,377.4 378.3,377.2 C 379.1,376.9 379.8,376.8 380.6,376.4 C 381.3,376.0 382.1,375.1 382.8,374.7 C 383.5,374.4 384.3,374.3 385.0,374.2 C 385.8,374.1 386.5,374.1 387.3,374.0 C 388.0,373.9 388.8,373.7 389.5,373.6 C 390.2,373.5 391.0,373.7 391.7,373.3 C 392.5,373.0 393.2,371.9 394.0,371.5 C 394.7,371.1 395.5,371.1 396.2,370.9 C 397.0,370.6 397.7,370.1 398.4,369.9 C 399.2,369.7 399.9,369.9 400.7,369.7 C 401.4,369.5 402.2,368.9 402.9,368.7 C 403.7,368.5 404.4,368.6 405.1,368.5 C 405.9,368.4 406.6,368.3 407.4,368.1 C 408.1,367.9 408.9,367.7 409.6,367.1 C 410.4,366.6 411.1,365.6 411.9,365.1 C 412.6,364.5 413.3,364.3 414.1,363.9 C 414.8,363.5 415.6,363.4 416.3,362.7 C 417.1,362.0 417.8,360.3 418.6,359.5 C 419.3,358.7 420.0,358.3 420.8,357.8 C 421.5,357.4 422.3,357.3 423.0,356.6 C 423.8,355.9 424.5,354.2 425.3,353.5 C 426.0,352.8 426.8,352.7 427.5,352.3 C 428.2,351.9 429.0,351.6 429.7,351.1 C 430.5,35Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.2 91.9,461.9 92.2,461.8 C 93.0,461.6 93.7,461.5 94.5,461.3 C 95.2,461.1 96.0,460.9 96.7,460.8 C 97.5,460.7 98.2,460.8 98.9,460.8 C 99.7,460.7 100.4,460.7 101.2,460.6 C 101.9,460.6 102.7,460.5 103.4,460.5 C 104.2,460.4 104.5,460.4 105.6,460.3 C 106.8,460.3 109.0,460.2 110.1,460.2 C 111.2,460.1 111.6,460.1 112.4,460.0 C 113.1,460.0 113.8,460.0 114.6,460.0 C 115.3,460.0 116.1,460.0 116.8,459.9 C 117.6,459.9 118.3,459.9 119.1,459.9 C 119.8,459.8 120.5,459.8 121.3,459.7 C 122.0,459.7 122.8,459.7 123.5,459.5 C 124.3,459.3 125.0,459.1 125.8,458.7 C 126.5,458.4 127.3,457.6 128.0,457.4 C 128.7,457.1 129.5,457.2 130.2,457.2 C 131.0,457.2 131.7,457.1 132.5,457.1 C 133.2,457.1 133.6,457.0 134.7,457.0 C 135.8,457.0 137.7,457.0 139.2,456.9 C 140.7,456.9 142.5,456.9 143.6,456.8 C 144.8,456.8 145.1,456.7 145.9,456.7 C 146.6,456.7 147.4,456.6 148.1,456.6 C 148.9,456.6 149.6,456.5 150.3,456.5 C 151.1,456.5 151.8,456.5 152.6,456.4 C 153.3,456.4 153.7,456.4 154.8,456.4 C 155.9,456.4 157.4,456.3 159.3,456.3 C 161.2,456.2 164.1,456.2 166.0,456.2 C 167.9,456.2 168.2,456.2 170.5,456.2 C 172.7,456.1 177.5,456.0 179.4,455.9 C 181.3,455.9 180.5,455.9 181.6,455.9 C 182.8,455.9 184.2,455.9 186.1,455.8 C 188.0,455.8 189.1,455.8 192.8,455.7 C 196.5,455.7 205.5,455.6 208.5,455.6 C 211.4,455.5 210.0,455.5 210.7,455.5 C 211.4,455.5 211.8,456.1 212.9,455.4 C 214.0,454.8 216.3,454.2 217.4,451.4 C 218.5,448.6 218.9,440.8 219.6,438.4 C 220.4,436.0 221.1,437.4 221.9,437.0 C 222.6,436.6 223.4,436.4 224.1,436.1 C 224.9,435.9 225.6,435.7 226.3,435.5 C 227.1,435.4 227.8,435.4 228.6,435.3 C 229.3,435.2 230.1,435.0 230.8,434.9 C 231.6,434.9 232.3,434.9 233.0,434.9 C 233.8,434.8 234.5,434.6 235.3,434.5 C 236.0,434.4 236.8,434.5 237.5,434.3 C 238.3,434.2 239.0,433.9 239.8,433.7 C 240.5,433.6 241.2,433.7 242.0,433.6 C 242.7,433.5 243.1,433.3 244.2,433.2 C 245.3,433.1 247.6,433.1 248.7,433.0 C 249.8,433.0 250.2,432.9 250.9,432.9 C 251.7,432.8 252.4,432.8 253.2,432.7 C 253.9,432.6 254.7,432.3 255.4,432.2 C 256.1,432.0 256.9,432.7 257.6,431.8 C 258.4,430.9 259.1,427.7 259.9,426.7 C 260.6,425.6 261.4,425.8 262.1,425.6 C 262.8,425.4 263.6,425.7 264.3,425.5 C 265.1,425.4 265.8,425.0 266.6,424.8 C 267.3,424.5 268.1,424.3 268.8,423.8 C 269.6,423.3 270.3,422.3 271.0,421.7 C 271.8,421.1 272.5,420.6 273.3,420.2 C 274.0,419.7 274.8,419.4 275.5,419.1 C 276.3,418.9 277.0,418.7 277.7,418.6 C 278.5,418.5 279.2,418.5 280.0,418.4 C 280.7,418.3 281.5,418.1 282.2,418.0 C 283.0,417.9 283.7,417.9 284.5,417.9 C 285.2,417.8 285.9,417.8 286.7,417.6 C 287.4,417.5 288.2,417.6 288.9,417.1 C 289.7,416.7 290.4,415.7 291.2,415.2 C 291.9,414.7 292.6,414.5 293.4,414.1 C 294.1,413.8 294.9,414.5 295.6,412.9 C 296.4,411.4 297.1,406.6 297.9,404.7 C 298.6,402.9 299.4,402.6 300.1,401.8 C 300.8,401.0 301.6,400.5 302.3,400.0 C 303.1,399.4 303.8,398.9 304.6,398.5 C 305.3,398.1 306.1,397.7 306.8,397.5 C 307.5,397.2 308.3,397.1 309.0,396.9 C 309.8,396.7 310.5,396.4 311.3,396.1 C 312.0,395.8 312.8,395.4 313.5,395.1 C 314.3,394.8 315.0,394.9 315.7,394.6 C 316.5,394.4 317.2,393.9 318.0,393.6 C 318.7,393.3 319.5,393.0 320.2,392.7 C 321.0,392.5 321.7,392.3 322.5,392.1 C 323.2,392.0 323.9,392.0 324.7,391.8 C 325.4,391.7 326.2,391.5 326.9,391.3 C 327.7,391.2 328.4,391.0 329.2,390.8 C 329.9,390.6 330.6,390.4 331.4,390.1 C 332.1,389.7 332.9,389.2 333.6,388.9 C 334.4,388.6 335.1,388.5 335.9,388.3 C 336.6,388.2 337.4,388.1 338.1,387.9 C 338.8,387.8 339.6,387.7 340.3,387.6 C 341.1,387.5 341.8,387.4 342.6,387.3 C 343.3,387.2 344.1,387.2 344.8,387.1 C 345.5,387.0 346.3,386.8 347.0,386.5 C 347.8,386.2 348.5,385.8 349.3,385.4 C 350.0,385.1 350.8,384.7 351.5,384.5 C 352.3,384.3 353.0,384.2 353.7,384.1 C 354.5,384.0 355.2,383.9 356.0,383.7 C 356.7,383.4 357.5,382.9 358.2,382.7 C 359.0,382.5 359.7,382.5 360.4,382.4 C 361.2,382.3 361.9,382.0 362.7,381.9 C 363.4,381.7 364.2,381.5 364.9,381.3 C 365.7,381.1 366.4,380.9 367.2,380.7 C 367.9,380.5 368.6,380.4 369.4,380.2 C 370.1,380.0 370.9,379.6 371.6,379.4 C 372.4,379.2 373.1,379.0 373.9,378.8 C 374.6,378.6 375.3,378.2 376.1,378.0 C 376.8,377.7 377.6,377.4 378.3,377.2 C 379.1,376.9 379.8,376.8 380.6,376.4 C 381.3,376.0 382.1,375.1 382.8,374.7 C 383.5,374.4 384.3,374.3 385.0,374.2 C 385.8,374.1 386.5,374.1 387.3,374.0 C 388.0,373.9 388.8,373.7 389.5,373.6 C 390.2,373.5 391.0,373.7 391.7,373.3 C 392.5,373.0 393.2,371.9 394.0,371.5 C 394.7,371.1 395.5,371.1 396.2,370.9 C 397.0,370.6 397.7,370.1 398.4,369.9 C 399.2,369.7 399.9,369.9 400.7,369.7 C 401.4,369.5 402.2,368.9 402.9,368.7 C 403.7,368.5 404.4,368.6 405.1,368.5 C 405.9,368.4 406.6,368.3 407.4,368.1 C 408.1,367.9 408.9,367.7 409.6,367.1 C 410.4,366.6 411.1,365.6 411.9,365.1 C 412.6,364.5 413.3,364.3 414.1,363.9 C 414.8,363.5 415.6,363.4 416.3,362.7 C 417.1,362.0 417.8,360.3 418.6,359.5 C 419.3,358.7 420.0,358.3 420.8,357.8 C 421.5,357.4 422.3,357.3 423.0,356.6 C 423.8,355.9 424.5,354.2 425.3,353.5 C 426.0,352.8 426.8,352.7 427.5,352.3 C 428.2,351.9 429.0,351.6 429.7,351.1 C 430.5,35Line truncated
|
||||
<circle cx="765.0" cy="80.1" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6999</text>
|
||||
<path d="M 90.0,462.3 C 90.4,462.3 91.8,462.0 92.2,461.9 C 92.9,461.8 93.6,461.7 94.4,461.5 C 95.1,461.4 95.8,461.2 96.6,461.1 C 97.3,461.0 98.0,461.1 98.7,461.0 C 99.5,461.0 100.2,461.0 100.9,460.9 C 101.7,460.9 102.4,460.8 103.1,460.8 C 103.8,460.7 104.2,460.7 105.3,460.7 C 106.4,460.6 108.6,460.6 109.7,460.5 C 110.8,460.5 111.1,460.4 111.8,460.4 C 112.6,460.4 113.3,460.4 114.0,460.4 C 114.8,460.4 115.5,460.3 116.2,460.3 C 116.9,460.3 117.7,460.3 118.4,460.3 C 119.1,460.2 119.9,460.2 120.6,460.1 C 121.3,460.1 122.0,460.1 122.8,459.9 C 123.5,459.8 124.2,459.6 125.0,459.3 C 125.7,459.0 126.4,458.3 127.1,458.1 C 127.9,457.8 128.6,458.0 129.3,457.9 C 130.0,457.9 130.8,457.9 131.5,457.8 C 132.2,457.8 132.6,457.8 133.7,457.7 C 134.8,457.7 136.6,457.7 138.1,457.7 C 139.5,457.7 141.3,457.6 142.4,457.6 C 143.5,457.6 143.9,457.5 144.6,457.5 C 145.3,457.5 146.1,457.4 146.8,457.4 C 147.5,457.4 148.3,457.3 149.0,457.3 C 149.7,457.3 150.4,457.3 151.2,457.3 C 151.9,457.2 152.3,457.2 153.3,457.2 C 154.4,457.2 155.9,457.1 157.7,457.1 C 159.5,457.1 162.5,457.1 164.3,457.1 C 166.1,457.0 166.5,457.1 168.6,457.0 C 170.8,457.0 175.6,456.9 177.4,456.8 C 179.2,456.8 178.5,456.8 179.6,456.8 C 180.7,456.8 182.1,456.8 183.9,456.7 C 185.8,456.7 186.8,456.7 190.5,456.6 C 194.1,456.6 202.9,456.5 205.8,456.5 C 208.7,456.5 207.2,456.5 208.0,456.4 C 208.7,456.4 209.1,457.0 210.1,456.4 C 211.2,455.8 213.4,455.3 214.5,452.9 C 215.6,450.4 216.0,443.6 216.7,441.5 C 217.4,439.4 218.2,440.6 218.9,440.3 C 219.6,439.9 220.3,439.7 221.1,439.5 C 221.8,439.3 222.5,439.1 223.3,439.0 C 224.0,438.8 224.7,438.8 225.4,438.7 C 226.2,438.6 226.9,438.5 227.6,438.4 C 228.3,438.4 229.1,438.5 229.8,438.4 C 230.5,438.3 231.3,438.1 232.0,438.1 C 232.7,438.0 233.4,438.0 234.2,437.9 C 234.9,437.8 235.6,437.5 236.4,437.4 C 237.1,437.3 237.8,437.3 238.5,437.2 C 239.3,437.2 239.6,437.0 240.7,436.9 C 241.8,436.8 244.0,436.8 245.1,436.8 C 246.2,436.7 246.6,436.7 247.3,436.6 C 248.0,436.6 248.7,436.6 249.5,436.5 C 250.2,436.4 250.9,436.2 251.7,436.0 C 252.4,435.9 253.1,436.5 253.8,435.7 C 254.6,434.9 255.3,432.1 256.0,431.2 C 256.7,430.3 257.5,430.4 258.2,430.3 C 258.9,430.1 259.7,430.3 260.4,430.2 C 261.1,430.1 261.8,429.8 262.6,429.5 C 263.3,429.3 264.0,429.1 264.8,428.7 C 265.5,428.2 266.2,427.4 266.9,426.9 C 267.7,426.4 268.4,425.9 269.1,425.5 C 269.9,425.1 270.6,424.8 271.3,424.6 C 272.0,424.4 272.8,424.3 273.5,424.2 C 274.2,424.1 275.0,424.1 275.7,424.0 C 276.4,423.9 277.1,423.7 277.9,423.6 C 278.6,423.5 279.3,423.6 280.0,423.5 C 280.8,423.5 281.5,423.4 282.2,423.3 C 283.0,423.2 283.7,423.2 284.4,422.9 C 285.1,422.5 285.9,421.6 286.6,421.2 C 287.3,420.7 288.1,420.6 288.8,420.2 C 289.5,419.9 290.2,420.6 291.0,419.2 C 291.7,417.8 292.4,413.6 293.2,412.0 C 293.9,410.4 294.6,410.1 295.3,409.4 C 296.1,408.7 296.8,408.3 297.5,407.8 C 298.3,407.4 299.0,406.9 299.7,406.6 C 300.4,406.2 301.2,405.9 301.9,405.6 C 302.6,405.4 303.3,405.4 304.1,405.2 C 304.8,405.0 305.5,404.8 306.3,404.5 C 307.0,404.2 307.7,403.8 308.4,403.6 C 309.2,403.4 309.9,403.4 310.6,403.2 C 311.4,402.9 312.1,402.5 312.8,402.2 C 313.5,402.0 314.3,401.7 315.0,401.5 C 315.7,401.3 316.5,401.1 317.2,401.0 C 317.9,400.9 318.6,400.8 319.4,400.7 C 320.1,400.6 320.8,400.4 321.6,400.3 C 322.3,400.1 323.0,400.0 323.7,399.8 C 324.5,399.6 325.2,399.5 325.9,399.2 C 326.7,398.9 327.4,398.4 328.1,398.1 C 328.8,397.9 329.6,397.8 330.3,397.7 C 331.0,397.5 331.7,397.4 332.5,397.3 C 333.2,397.2 333.9,397.1 334.7,397.0 C 335.4,396.9 336.1,396.8 336.8,396.7 C 337.6,396.7 338.3,396.7 339.0,396.6 C 339.8,396.5 340.5,396.3 341.2,396.1 C 341.9,395.8 342.7,395.4 343.4,395.1 C 344.1,394.8 344.9,394.5 345.6,394.3 C 346.3,394.1 347.0,394.1 347.8,394.0 C 348.5,393.8 349.2,393.8 350.0,393.6 C 350.7,393.4 351.4,392.9 352.1,392.7 C 352.9,392.5 353.6,392.6 354.3,392.5 C 355.0,392.4 355.8,392.2 356.5,392.0 C 357.2,391.8 358.0,391.7 358.7,391.5 C 359.4,391.4 360.1,391.2 360.9,391.0 C 361.6,390.8 362.3,390.8 363.1,390.6 C 363.8,390.4 364.5,390.1 365.2,389.8 C 366.0,389.6 366.7,389.5 367.4,389.3 C 368.2,389.1 368.9,388.8 369.6,388.6 C 370.3,388.4 371.1,388.1 371.8,387.9 C 372.5,387.7 373.3,387.6 374.0,387.3 C 374.7,386.9 375.4,386.1 376.2,385.8 C 376.9,385.5 377.6,385.4 378.3,385.3 C 379.1,385.2 379.8,385.2 380.5,385.1 C 381.3,385.0 382.0,384.9 382.7,384.8 C 383.4,384.7 384.2,384.8 384.9,384.5 C 385.6,384.2 386.4,383.3 387.1,382.9 C 387.8,382.5 388.5,382.6 389.3,382.4 C 390.0,382.2 390.7,381.7 391.5,381.6 C 392.2,381.4 392.9,381.5 393.6,381.3 C 394.4,381.1 395.1,380.7 395.8,380.5 C 396.6,380.3 397.3,380.4 398.0,380.3 C 398.7,380.2 399.5,380.2 400.2,380.0 C 400.9,379.8 401.7,379.6 402.4,379.1 C 403.1,378.7 403.8,377.8 404.6,377.3 C 405.3,376.8 406.0,376.6 406.7,376.3 C 407.5,376.0 408.2,375.9 408.9,375.2 C 409.7,374.6 410.4,373.2 411.1,372.5 C 411.8,371.8 412.6,371.5 413.3,371.0 C 414.0,370.6 414.8,370.6 415.5,369.9 C 416.2,369.3 416.9,367.9 417.7,367.2 C 418.4,366.6 419.1,366.5 419.9,366.2 C 420.6,365.8 421.3,365.6 422.0,365.1 C 422.8,36Line truncated
|
||||
<path d="M 90.0,462.3 C 90.4,462.3 91.8,462.0 92.2,461.9 C 92.9,461.8 93.6,461.7 94.4,461.5 C 95.1,461.4 95.8,461.2 96.6,461.1 C 97.3,461.0 98.0,461.1 98.7,461.0 C 99.5,461.0 100.2,461.0 100.9,460.9 C 101.7,460.9 102.4,460.8 103.1,460.8 C 103.8,460.7 104.2,460.7 105.3,460.7 C 106.4,460.6 108.6,460.6 109.7,460.5 C 110.8,460.5 111.1,460.4 111.8,460.4 C 112.6,460.4 113.3,460.4 114.0,460.4 C 114.8,460.4 115.5,460.3 116.2,460.3 C 116.9,460.3 117.7,460.3 118.4,460.3 C 119.1,460.2 119.9,460.2 120.6,460.1 C 121.3,460.1 122.0,460.1 122.8,459.9 C 123.5,459.8 124.2,459.6 125.0,459.3 C 125.7,459.0 126.4,458.3 127.1,458.1 C 127.9,457.8 128.6,458.0 129.3,457.9 C 130.0,457.9 130.8,457.9 131.5,457.8 C 132.2,457.8 132.6,457.8 133.7,457.7 C 134.8,457.7 136.6,457.7 138.1,457.7 C 139.5,457.7 141.3,457.6 142.4,457.6 C 143.5,457.6 143.9,457.5 144.6,457.5 C 145.3,457.5 146.1,457.4 146.8,457.4 C 147.5,457.4 148.3,457.3 149.0,457.3 C 149.7,457.3 150.4,457.3 151.2,457.3 C 151.9,457.2 152.3,457.2 153.3,457.2 C 154.4,457.2 155.9,457.1 157.7,457.1 C 159.5,457.1 162.5,457.1 164.3,457.1 C 166.1,457.0 166.5,457.1 168.6,457.0 C 170.8,457.0 175.6,456.9 177.4,456.8 C 179.2,456.8 178.5,456.8 179.6,456.8 C 180.7,456.8 182.1,456.8 183.9,456.7 C 185.8,456.7 186.8,456.7 190.5,456.6 C 194.1,456.6 202.9,456.5 205.8,456.5 C 208.7,456.5 207.2,456.5 208.0,456.4 C 208.7,456.4 209.1,457.0 210.1,456.4 C 211.2,455.8 213.4,455.3 214.5,452.9 C 215.6,450.4 216.0,443.6 216.7,441.5 C 217.4,439.4 218.2,440.6 218.9,440.3 C 219.6,439.9 220.3,439.7 221.1,439.5 C 221.8,439.3 222.5,439.1 223.3,439.0 C 224.0,438.8 224.7,438.8 225.4,438.7 C 226.2,438.6 226.9,438.5 227.6,438.4 C 228.3,438.4 229.1,438.5 229.8,438.4 C 230.5,438.3 231.3,438.1 232.0,438.1 C 232.7,438.0 233.4,438.0 234.2,437.9 C 234.9,437.8 235.6,437.5 236.4,437.4 C 237.1,437.3 237.8,437.3 238.5,437.2 C 239.3,437.2 239.6,437.0 240.7,436.9 C 241.8,436.8 244.0,436.8 245.1,436.8 C 246.2,436.7 246.6,436.7 247.3,436.6 C 248.0,436.6 248.7,436.6 249.5,436.5 C 250.2,436.4 250.9,436.2 251.7,436.0 C 252.4,435.9 253.1,436.5 253.8,435.7 C 254.6,434.9 255.3,432.1 256.0,431.2 C 256.7,430.3 257.5,430.4 258.2,430.3 C 258.9,430.1 259.7,430.3 260.4,430.2 C 261.1,430.1 261.8,429.8 262.6,429.5 C 263.3,429.3 264.0,429.1 264.8,428.7 C 265.5,428.2 266.2,427.4 266.9,426.9 C 267.7,426.4 268.4,425.9 269.1,425.5 C 269.9,425.1 270.6,424.8 271.3,424.6 C 272.0,424.4 272.8,424.3 273.5,424.2 C 274.2,424.1 275.0,424.1 275.7,424.0 C 276.4,423.9 277.1,423.7 277.9,423.6 C 278.6,423.5 279.3,423.6 280.0,423.5 C 280.8,423.5 281.5,423.4 282.2,423.3 C 283.0,423.2 283.7,423.2 284.4,422.9 C 285.1,422.5 285.9,421.6 286.6,421.2 C 287.3,420.7 288.1,420.6 288.8,420.2 C 289.5,419.9 290.2,420.6 291.0,419.2 C 291.7,417.8 292.4,413.6 293.2,412.0 C 293.9,410.4 294.6,410.1 295.3,409.4 C 296.1,408.7 296.8,408.3 297.5,407.8 C 298.3,407.4 299.0,406.9 299.7,406.6 C 300.4,406.2 301.2,405.9 301.9,405.6 C 302.6,405.4 303.3,405.4 304.1,405.2 C 304.8,405.0 305.5,404.8 306.3,404.5 C 307.0,404.2 307.7,403.8 308.4,403.6 C 309.2,403.4 309.9,403.4 310.6,403.2 C 311.4,402.9 312.1,402.5 312.8,402.2 C 313.5,402.0 314.3,401.7 315.0,401.5 C 315.7,401.3 316.5,401.1 317.2,401.0 C 317.9,400.9 318.6,400.8 319.4,400.7 C 320.1,400.6 320.8,400.4 321.6,400.3 C 322.3,400.1 323.0,400.0 323.7,399.8 C 324.5,399.6 325.2,399.5 325.9,399.2 C 326.7,398.9 327.4,398.4 328.1,398.1 C 328.8,397.9 329.6,397.8 330.3,397.7 C 331.0,397.5 331.7,397.4 332.5,397.3 C 333.2,397.2 333.9,397.1 334.7,397.0 C 335.4,396.9 336.1,396.8 336.8,396.7 C 337.6,396.7 338.3,396.7 339.0,396.6 C 339.8,396.5 340.5,396.3 341.2,396.1 C 341.9,395.8 342.7,395.4 343.4,395.1 C 344.1,394.8 344.9,394.5 345.6,394.3 C 346.3,394.1 347.0,394.1 347.8,394.0 C 348.5,393.8 349.2,393.8 350.0,393.6 C 350.7,393.4 351.4,392.9 352.1,392.7 C 352.9,392.5 353.6,392.6 354.3,392.5 C 355.0,392.4 355.8,392.2 356.5,392.0 C 357.2,391.8 358.0,391.7 358.7,391.5 C 359.4,391.4 360.1,391.2 360.9,391.0 C 361.6,390.8 362.3,390.8 363.1,390.6 C 363.8,390.4 364.5,390.1 365.2,389.8 C 366.0,389.6 366.7,389.5 367.4,389.3 C 368.2,389.1 368.9,388.8 369.6,388.6 C 370.3,388.4 371.1,388.1 371.8,387.9 C 372.5,387.7 373.3,387.6 374.0,387.3 C 374.7,386.9 375.4,386.1 376.2,385.8 C 376.9,385.5 377.6,385.4 378.3,385.3 C 379.1,385.2 379.8,385.2 380.5,385.1 C 381.3,385.0 382.0,384.9 382.7,384.8 C 383.4,384.7 384.2,384.8 384.9,384.5 C 385.6,384.2 386.4,383.3 387.1,382.9 C 387.8,382.5 388.5,382.6 389.3,382.4 C 390.0,382.2 390.7,381.7 391.5,381.6 C 392.2,381.4 392.9,381.5 393.6,381.3 C 394.4,381.1 395.1,380.7 395.8,380.5 C 396.6,380.3 397.3,380.4 398.0,380.3 C 398.7,380.2 399.5,380.2 400.2,380.0 C 400.9,379.8 401.7,379.6 402.4,379.1 C 403.1,378.7 403.8,377.8 404.6,377.3 C 405.3,376.8 406.0,376.6 406.7,376.3 C 407.5,376.0 408.2,375.9 408.9,375.2 C 409.7,374.6 410.4,373.2 411.1,372.5 C 411.8,371.8 412.6,371.5 413.3,371.0 C 414.0,370.6 414.8,370.6 415.5,369.9 C 416.2,369.3 416.9,367.9 417.7,367.2 C 418.4,366.6 419.1,366.5 419.9,366.2 C 420.6,365.8 421.3,365.6 422.0,365.1 C 422.8,36Line truncated
|
||||
<circle cx="765.0" cy="119.4" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7176</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 37 KiB |
@@ -15,33 +15,35 @@
|
||||
<text x="50%" y="30" fill="#000" font-size="20" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Star History</text>
|
||||
<rect width="178" height="32" x="105" y="90" fill="#fff" fill-opacity="0.9" stroke="#000" stroke-width="1.5" rx="4" ry="4" filter="url(#xkcdify)"/><rect width="8" height="8" x="115" y="102" rx="2" ry="2" fill="#dd4528" filter="url(#xkcdify)"/><text x="131" y="110" fill="#000" font-size="15" font-family="Handlee, cursive" font-weight="bold">binaricat/Netcatty</text>
|
||||
<text x="85" y="468.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">0</text>
|
||||
<line x1="90" y1="408.3" x2="765" y2="408.3" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="413.2857142857143" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="353.6" x2="765" y2="353.6" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="358.57142857142856" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="298.9" x2="765" y2="298.9" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="303.8571428571429" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="244.1" x2="765" y2="244.1" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="249.14285714285717" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="189.4" x2="765" y2="189.4" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="194.42857142857144" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<line x1="90" y1="134.7" x2="765" y2="134.7" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="139.71428571428572" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<line x1="90" y1="415.1" x2="765" y2="415.1" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="420.125" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="367.2" x2="765" y2="367.2" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="372.25" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="319.4" x2="765" y2="319.4" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="324.375" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="271.5" x2="765" y2="271.5" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="276.5" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="223.6" x2="765" y2="223.6" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="228.625" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<line x1="90" y1="175.8" x2="765" y2="175.8" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="180.75" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<line x1="90" y1="127.9" x2="765" y2="127.9" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="132.875" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<line x1="90" y1="80.0" x2="765" y2="80.0" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">8K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="217.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="353.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="490.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="628.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="765.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Nov 2026</text>
|
||||
<text x="214.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="347.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="481.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="616.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="749.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Nov 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.2 91.9,461.9 92.2,461.8 C 93.0,461.6 93.7,461.5 94.5,461.3 C 95.2,461.1 96.0,460.9 96.7,460.8 C 97.5,460.7 98.2,460.8 98.9,460.8 C 99.7,460.7 100.4,460.7 101.2,460.6 C 101.9,460.6 102.7,460.5 103.4,460.5 C 104.2,460.4 104.5,460.4 105.6,460.3 C 106.8,460.3 109.0,460.2 110.1,460.2 C 111.2,460.1 111.6,460.1 112.4,460.0 C 113.1,460.0 113.8,460.0 114.6,460.0 C 115.3,460.0 116.1,460.0 116.8,459.9 C 117.6,459.9 118.3,459.9 119.1,459.9 C 119.8,459.8 120.5,459.8 121.3,459.7 C 122.0,459.7 122.8,459.7 123.5,459.5 C 124.3,459.3 125.0,459.1 125.8,458.7 C 126.5,458.4 127.3,457.6 128.0,457.4 C 128.7,457.1 129.5,457.2 130.2,457.2 C 131.0,457.2 131.7,457.1 132.5,457.1 C 133.2,457.1 133.6,457.0 134.7,457.0 C 135.8,457.0 137.7,457.0 139.2,456.9 C 140.7,456.9 142.5,456.9 143.6,456.8 C 144.8,456.8 145.1,456.7 145.9,456.7 C 146.6,456.7 147.4,456.6 148.1,456.6 C 148.9,456.6 149.6,456.5 150.3,456.5 C 151.1,456.5 151.8,456.5 152.6,456.4 C 153.3,456.4 153.7,456.4 154.8,456.4 C 155.9,456.4 157.4,456.3 159.3,456.3 C 161.2,456.2 164.1,456.2 166.0,456.2 C 167.9,456.2 168.2,456.2 170.5,456.2 C 172.7,456.1 177.5,456.0 179.4,455.9 C 181.3,455.9 180.5,455.9 181.6,455.9 C 182.8,455.9 184.2,455.9 186.1,455.8 C 188.0,455.8 189.1,455.8 192.8,455.7 C 196.5,455.7 205.5,455.6 208.5,455.6 C 211.4,455.5 210.0,455.5 210.7,455.5 C 211.4,455.5 211.8,456.1 212.9,455.4 C 214.0,454.8 216.3,454.2 217.4,451.4 C 218.5,448.6 218.9,440.8 219.6,438.4 C 220.4,436.0 221.1,437.4 221.9,437.0 C 222.6,436.6 223.4,436.4 224.1,436.1 C 224.9,435.9 225.6,435.7 226.3,435.5 C 227.1,435.4 227.8,435.4 228.6,435.3 C 229.3,435.2 230.1,435.0 230.8,434.9 C 231.6,434.9 232.3,434.9 233.0,434.9 C 233.8,434.8 234.5,434.6 235.3,434.5 C 236.0,434.4 236.8,434.5 237.5,434.3 C 238.3,434.2 239.0,433.9 239.8,433.7 C 240.5,433.6 241.2,433.7 242.0,433.6 C 242.7,433.5 243.1,433.3 244.2,433.2 C 245.3,433.1 247.6,433.1 248.7,433.0 C 249.8,433.0 250.2,432.9 250.9,432.9 C 251.7,432.8 252.4,432.8 253.2,432.7 C 253.9,432.6 254.7,432.3 255.4,432.2 C 256.1,432.0 256.9,432.7 257.6,431.8 C 258.4,430.9 259.1,427.7 259.9,426.7 C 260.6,425.6 261.4,425.8 262.1,425.6 C 262.8,425.4 263.6,425.7 264.3,425.5 C 265.1,425.4 265.8,425.0 266.6,424.8 C 267.3,424.5 268.1,424.3 268.8,423.8 C 269.6,423.3 270.3,422.3 271.0,421.7 C 271.8,421.1 272.5,420.6 273.3,420.2 C 274.0,419.7 274.8,419.4 275.5,419.1 C 276.3,418.9 277.0,418.7 277.7,418.6 C 278.5,418.5 279.2,418.5 280.0,418.4 C 280.7,418.3 281.5,418.1 282.2,418.0 C 283.0,417.9 283.7,417.9 284.5,417.9 C 285.2,417.8 285.9,417.8 286.7,417.6 C 287.4,417.5 288.2,417.6 288.9,417.1 C 289.7,416.7 290.4,415.7 291.2,415.2 C 291.9,414.7 292.6,414.5 293.4,414.1 C 294.1,413.8 294.9,414.5 295.6,412.9 C 296.4,411.4 297.1,406.6 297.9,404.7 C 298.6,402.9 299.4,402.6 300.1,401.8 C 300.8,401.0 301.6,400.5 302.3,400.0 C 303.1,399.4 303.8,398.9 304.6,398.5 C 305.3,398.1 306.1,397.7 306.8,397.5 C 307.5,397.2 308.3,397.1 309.0,396.9 C 309.8,396.7 310.5,396.4 311.3,396.1 C 312.0,395.8 312.8,395.4 313.5,395.1 C 314.3,394.8 315.0,394.9 315.7,394.6 C 316.5,394.4 317.2,393.9 318.0,393.6 C 318.7,393.3 319.5,393.0 320.2,392.7 C 321.0,392.5 321.7,392.3 322.5,392.1 C 323.2,392.0 323.9,392.0 324.7,391.8 C 325.4,391.7 326.2,391.5 326.9,391.3 C 327.7,391.2 328.4,391.0 329.2,390.8 C 329.9,390.6 330.6,390.4 331.4,390.1 C 332.1,389.7 332.9,389.2 333.6,388.9 C 334.4,388.6 335.1,388.5 335.9,388.3 C 336.6,388.2 337.4,388.1 338.1,387.9 C 338.8,387.8 339.6,387.7 340.3,387.6 C 341.1,387.5 341.8,387.4 342.6,387.3 C 343.3,387.2 344.1,387.2 344.8,387.1 C 345.5,387.0 346.3,386.8 347.0,386.5 C 347.8,386.2 348.5,385.8 349.3,385.4 C 350.0,385.1 350.8,384.7 351.5,384.5 C 352.3,384.3 353.0,384.2 353.7,384.1 C 354.5,384.0 355.2,383.9 356.0,383.7 C 356.7,383.4 357.5,382.9 358.2,382.7 C 359.0,382.5 359.7,382.5 360.4,382.4 C 361.2,382.3 361.9,382.0 362.7,381.9 C 363.4,381.7 364.2,381.5 364.9,381.3 C 365.7,381.1 366.4,380.9 367.2,380.7 C 367.9,380.5 368.6,380.4 369.4,380.2 C 370.1,380.0 370.9,379.6 371.6,379.4 C 372.4,379.2 373.1,379.0 373.9,378.8 C 374.6,378.6 375.3,378.2 376.1,378.0 C 376.8,377.7 377.6,377.4 378.3,377.2 C 379.1,376.9 379.8,376.8 380.6,376.4 C 381.3,376.0 382.1,375.1 382.8,374.7 C 383.5,374.4 384.3,374.3 385.0,374.2 C 385.8,374.1 386.5,374.1 387.3,374.0 C 388.0,373.9 388.8,373.7 389.5,373.6 C 390.2,373.5 391.0,373.7 391.7,373.3 C 392.5,373.0 393.2,371.9 394.0,371.5 C 394.7,371.1 395.5,371.1 396.2,370.9 C 397.0,370.6 397.7,370.1 398.4,369.9 C 399.2,369.7 399.9,369.9 400.7,369.7 C 401.4,369.5 402.2,368.9 402.9,368.7 C 403.7,368.5 404.4,368.6 405.1,368.5 C 405.9,368.4 406.6,368.3 407.4,368.1 C 408.1,367.9 408.9,367.7 409.6,367.1 C 410.4,366.6 411.1,365.6 411.9,365.1 C 412.6,364.5 413.3,364.3 414.1,363.9 C 414.8,363.5 415.6,363.4 416.3,362.7 C 417.1,362.0 417.8,360.3 418.6,359.5 C 419.3,358.7 420.0,358.3 420.8,357.8 C 421.5,357.4 422.3,357.3 423.0,356.6 C 423.8,355.9 424.5,354.2 425.3,353.5 C 426.0,352.8 426.8,352.7 427.5,352.3 C 428.2,351.9 429.0,351.6 429.7,351.1 C 430.5,35Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.2 91.9,461.9 92.2,461.8 C 93.0,461.6 93.7,461.5 94.5,461.3 C 95.2,461.1 96.0,460.9 96.7,460.8 C 97.5,460.7 98.2,460.8 98.9,460.8 C 99.7,460.7 100.4,460.7 101.2,460.6 C 101.9,460.6 102.7,460.5 103.4,460.5 C 104.2,460.4 104.5,460.4 105.6,460.3 C 106.8,460.3 109.0,460.2 110.1,460.2 C 111.2,460.1 111.6,460.1 112.4,460.0 C 113.1,460.0 113.8,460.0 114.6,460.0 C 115.3,460.0 116.1,460.0 116.8,459.9 C 117.6,459.9 118.3,459.9 119.1,459.9 C 119.8,459.8 120.5,459.8 121.3,459.7 C 122.0,459.7 122.8,459.7 123.5,459.5 C 124.3,459.3 125.0,459.1 125.8,458.7 C 126.5,458.4 127.3,457.6 128.0,457.4 C 128.7,457.1 129.5,457.2 130.2,457.2 C 131.0,457.2 131.7,457.1 132.5,457.1 C 133.2,457.1 133.6,457.0 134.7,457.0 C 135.8,457.0 137.7,457.0 139.2,456.9 C 140.7,456.9 142.5,456.9 143.6,456.8 C 144.8,456.8 145.1,456.7 145.9,456.7 C 146.6,456.7 147.4,456.6 148.1,456.6 C 148.9,456.6 149.6,456.5 150.3,456.5 C 151.1,456.5 151.8,456.5 152.6,456.4 C 153.3,456.4 153.7,456.4 154.8,456.4 C 155.9,456.4 157.4,456.3 159.3,456.3 C 161.2,456.2 164.1,456.2 166.0,456.2 C 167.9,456.2 168.2,456.2 170.5,456.2 C 172.7,456.1 177.5,456.0 179.4,455.9 C 181.3,455.9 180.5,455.9 181.6,455.9 C 182.8,455.9 184.2,455.9 186.1,455.8 C 188.0,455.8 189.1,455.8 192.8,455.7 C 196.5,455.7 205.5,455.6 208.5,455.6 C 211.4,455.5 210.0,455.5 210.7,455.5 C 211.4,455.5 211.8,456.1 212.9,455.4 C 214.0,454.8 216.3,454.2 217.4,451.4 C 218.5,448.6 218.9,440.8 219.6,438.4 C 220.4,436.0 221.1,437.4 221.9,437.0 C 222.6,436.6 223.4,436.4 224.1,436.1 C 224.9,435.9 225.6,435.7 226.3,435.5 C 227.1,435.4 227.8,435.4 228.6,435.3 C 229.3,435.2 230.1,435.0 230.8,434.9 C 231.6,434.9 232.3,434.9 233.0,434.9 C 233.8,434.8 234.5,434.6 235.3,434.5 C 236.0,434.4 236.8,434.5 237.5,434.3 C 238.3,434.2 239.0,433.9 239.8,433.7 C 240.5,433.6 241.2,433.7 242.0,433.6 C 242.7,433.5 243.1,433.3 244.2,433.2 C 245.3,433.1 247.6,433.1 248.7,433.0 C 249.8,433.0 250.2,432.9 250.9,432.9 C 251.7,432.8 252.4,432.8 253.2,432.7 C 253.9,432.6 254.7,432.3 255.4,432.2 C 256.1,432.0 256.9,432.7 257.6,431.8 C 258.4,430.9 259.1,427.7 259.9,426.7 C 260.6,425.6 261.4,425.8 262.1,425.6 C 262.8,425.4 263.6,425.7 264.3,425.5 C 265.1,425.4 265.8,425.0 266.6,424.8 C 267.3,424.5 268.1,424.3 268.8,423.8 C 269.6,423.3 270.3,422.3 271.0,421.7 C 271.8,421.1 272.5,420.6 273.3,420.2 C 274.0,419.7 274.8,419.4 275.5,419.1 C 276.3,418.9 277.0,418.7 277.7,418.6 C 278.5,418.5 279.2,418.5 280.0,418.4 C 280.7,418.3 281.5,418.1 282.2,418.0 C 283.0,417.9 283.7,417.9 284.5,417.9 C 285.2,417.8 285.9,417.8 286.7,417.6 C 287.4,417.5 288.2,417.6 288.9,417.1 C 289.7,416.7 290.4,415.7 291.2,415.2 C 291.9,414.7 292.6,414.5 293.4,414.1 C 294.1,413.8 294.9,414.5 295.6,412.9 C 296.4,411.4 297.1,406.6 297.9,404.7 C 298.6,402.9 299.4,402.6 300.1,401.8 C 300.8,401.0 301.6,400.5 302.3,400.0 C 303.1,399.4 303.8,398.9 304.6,398.5 C 305.3,398.1 306.1,397.7 306.8,397.5 C 307.5,397.2 308.3,397.1 309.0,396.9 C 309.8,396.7 310.5,396.4 311.3,396.1 C 312.0,395.8 312.8,395.4 313.5,395.1 C 314.3,394.8 315.0,394.9 315.7,394.6 C 316.5,394.4 317.2,393.9 318.0,393.6 C 318.7,393.3 319.5,393.0 320.2,392.7 C 321.0,392.5 321.7,392.3 322.5,392.1 C 323.2,392.0 323.9,392.0 324.7,391.8 C 325.4,391.7 326.2,391.5 326.9,391.3 C 327.7,391.2 328.4,391.0 329.2,390.8 C 329.9,390.6 330.6,390.4 331.4,390.1 C 332.1,389.7 332.9,389.2 333.6,388.9 C 334.4,388.6 335.1,388.5 335.9,388.3 C 336.6,388.2 337.4,388.1 338.1,387.9 C 338.8,387.8 339.6,387.7 340.3,387.6 C 341.1,387.5 341.8,387.4 342.6,387.3 C 343.3,387.2 344.1,387.2 344.8,387.1 C 345.5,387.0 346.3,386.8 347.0,386.5 C 347.8,386.2 348.5,385.8 349.3,385.4 C 350.0,385.1 350.8,384.7 351.5,384.5 C 352.3,384.3 353.0,384.2 353.7,384.1 C 354.5,384.0 355.2,383.9 356.0,383.7 C 356.7,383.4 357.5,382.9 358.2,382.7 C 359.0,382.5 359.7,382.5 360.4,382.4 C 361.2,382.3 361.9,382.0 362.7,381.9 C 363.4,381.7 364.2,381.5 364.9,381.3 C 365.7,381.1 366.4,380.9 367.2,380.7 C 367.9,380.5 368.6,380.4 369.4,380.2 C 370.1,380.0 370.9,379.6 371.6,379.4 C 372.4,379.2 373.1,379.0 373.9,378.8 C 374.6,378.6 375.3,378.2 376.1,378.0 C 376.8,377.7 377.6,377.4 378.3,377.2 C 379.1,376.9 379.8,376.8 380.6,376.4 C 381.3,376.0 382.1,375.1 382.8,374.7 C 383.5,374.4 384.3,374.3 385.0,374.2 C 385.8,374.1 386.5,374.1 387.3,374.0 C 388.0,373.9 388.8,373.7 389.5,373.6 C 390.2,373.5 391.0,373.7 391.7,373.3 C 392.5,373.0 393.2,371.9 394.0,371.5 C 394.7,371.1 395.5,371.1 396.2,370.9 C 397.0,370.6 397.7,370.1 398.4,369.9 C 399.2,369.7 399.9,369.9 400.7,369.7 C 401.4,369.5 402.2,368.9 402.9,368.7 C 403.7,368.5 404.4,368.6 405.1,368.5 C 405.9,368.4 406.6,368.3 407.4,368.1 C 408.1,367.9 408.9,367.7 409.6,367.1 C 410.4,366.6 411.1,365.6 411.9,365.1 C 412.6,364.5 413.3,364.3 414.1,363.9 C 414.8,363.5 415.6,363.4 416.3,362.7 C 417.1,362.0 417.8,360.3 418.6,359.5 C 419.3,358.7 420.0,358.3 420.8,357.8 C 421.5,357.4 422.3,357.3 423.0,356.6 C 423.8,355.9 424.5,354.2 425.3,353.5 C 426.0,352.8 426.8,352.7 427.5,352.3 C 428.2,351.9 429.0,351.6 429.7,351.1 C 430.5,35Line truncated
|
||||
<circle cx="765.0" cy="80.1" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6999</text>
|
||||
<path d="M 90.0,462.3 C 90.4,462.3 91.8,462.0 92.2,461.9 C 92.9,461.8 93.6,461.7 94.4,461.5 C 95.1,461.4 95.8,461.2 96.6,461.1 C 97.3,461.0 98.0,461.1 98.7,461.0 C 99.5,461.0 100.2,461.0 100.9,460.9 C 101.7,460.9 102.4,460.8 103.1,460.8 C 103.8,460.7 104.2,460.7 105.3,460.7 C 106.4,460.6 108.6,460.6 109.7,460.5 C 110.8,460.5 111.1,460.4 111.8,460.4 C 112.6,460.4 113.3,460.4 114.0,460.4 C 114.8,460.4 115.5,460.3 116.2,460.3 C 116.9,460.3 117.7,460.3 118.4,460.3 C 119.1,460.2 119.9,460.2 120.6,460.1 C 121.3,460.1 122.0,460.1 122.8,459.9 C 123.5,459.8 124.2,459.6 125.0,459.3 C 125.7,459.0 126.4,458.3 127.1,458.1 C 127.9,457.8 128.6,458.0 129.3,457.9 C 130.0,457.9 130.8,457.9 131.5,457.8 C 132.2,457.8 132.6,457.8 133.7,457.7 C 134.8,457.7 136.6,457.7 138.1,457.7 C 139.5,457.7 141.3,457.6 142.4,457.6 C 143.5,457.6 143.9,457.5 144.6,457.5 C 145.3,457.5 146.1,457.4 146.8,457.4 C 147.5,457.4 148.3,457.3 149.0,457.3 C 149.7,457.3 150.4,457.3 151.2,457.3 C 151.9,457.2 152.3,457.2 153.3,457.2 C 154.4,457.2 155.9,457.1 157.7,457.1 C 159.5,457.1 162.5,457.1 164.3,457.1 C 166.1,457.0 166.5,457.1 168.6,457.0 C 170.8,457.0 175.6,456.9 177.4,456.8 C 179.2,456.8 178.5,456.8 179.6,456.8 C 180.7,456.8 182.1,456.8 183.9,456.7 C 185.8,456.7 186.8,456.7 190.5,456.6 C 194.1,456.6 202.9,456.5 205.8,456.5 C 208.7,456.5 207.2,456.5 208.0,456.4 C 208.7,456.4 209.1,457.0 210.1,456.4 C 211.2,455.8 213.4,455.3 214.5,452.9 C 215.6,450.4 216.0,443.6 216.7,441.5 C 217.4,439.4 218.2,440.6 218.9,440.3 C 219.6,439.9 220.3,439.7 221.1,439.5 C 221.8,439.3 222.5,439.1 223.3,439.0 C 224.0,438.8 224.7,438.8 225.4,438.7 C 226.2,438.6 226.9,438.5 227.6,438.4 C 228.3,438.4 229.1,438.5 229.8,438.4 C 230.5,438.3 231.3,438.1 232.0,438.1 C 232.7,438.0 233.4,438.0 234.2,437.9 C 234.9,437.8 235.6,437.5 236.4,437.4 C 237.1,437.3 237.8,437.3 238.5,437.2 C 239.3,437.2 239.6,437.0 240.7,436.9 C 241.8,436.8 244.0,436.8 245.1,436.8 C 246.2,436.7 246.6,436.7 247.3,436.6 C 248.0,436.6 248.7,436.6 249.5,436.5 C 250.2,436.4 250.9,436.2 251.7,436.0 C 252.4,435.9 253.1,436.5 253.8,435.7 C 254.6,434.9 255.3,432.1 256.0,431.2 C 256.7,430.3 257.5,430.4 258.2,430.3 C 258.9,430.1 259.7,430.3 260.4,430.2 C 261.1,430.1 261.8,429.8 262.6,429.5 C 263.3,429.3 264.0,429.1 264.8,428.7 C 265.5,428.2 266.2,427.4 266.9,426.9 C 267.7,426.4 268.4,425.9 269.1,425.5 C 269.9,425.1 270.6,424.8 271.3,424.6 C 272.0,424.4 272.8,424.3 273.5,424.2 C 274.2,424.1 275.0,424.1 275.7,424.0 C 276.4,423.9 277.1,423.7 277.9,423.6 C 278.6,423.5 279.3,423.6 280.0,423.5 C 280.8,423.5 281.5,423.4 282.2,423.3 C 283.0,423.2 283.7,423.2 284.4,422.9 C 285.1,422.5 285.9,421.6 286.6,421.2 C 287.3,420.7 288.1,420.6 288.8,420.2 C 289.5,419.9 290.2,420.6 291.0,419.2 C 291.7,417.8 292.4,413.6 293.2,412.0 C 293.9,410.4 294.6,410.1 295.3,409.4 C 296.1,408.7 296.8,408.3 297.5,407.8 C 298.3,407.4 299.0,406.9 299.7,406.6 C 300.4,406.2 301.2,405.9 301.9,405.6 C 302.6,405.4 303.3,405.4 304.1,405.2 C 304.8,405.0 305.5,404.8 306.3,404.5 C 307.0,404.2 307.7,403.8 308.4,403.6 C 309.2,403.4 309.9,403.4 310.6,403.2 C 311.4,402.9 312.1,402.5 312.8,402.2 C 313.5,402.0 314.3,401.7 315.0,401.5 C 315.7,401.3 316.5,401.1 317.2,401.0 C 317.9,400.9 318.6,400.8 319.4,400.7 C 320.1,400.6 320.8,400.4 321.6,400.3 C 322.3,400.1 323.0,400.0 323.7,399.8 C 324.5,399.6 325.2,399.5 325.9,399.2 C 326.7,398.9 327.4,398.4 328.1,398.1 C 328.8,397.9 329.6,397.8 330.3,397.7 C 331.0,397.5 331.7,397.4 332.5,397.3 C 333.2,397.2 333.9,397.1 334.7,397.0 C 335.4,396.9 336.1,396.8 336.8,396.7 C 337.6,396.7 338.3,396.7 339.0,396.6 C 339.8,396.5 340.5,396.3 341.2,396.1 C 341.9,395.8 342.7,395.4 343.4,395.1 C 344.1,394.8 344.9,394.5 345.6,394.3 C 346.3,394.1 347.0,394.1 347.8,394.0 C 348.5,393.8 349.2,393.8 350.0,393.6 C 350.7,393.4 351.4,392.9 352.1,392.7 C 352.9,392.5 353.6,392.6 354.3,392.5 C 355.0,392.4 355.8,392.2 356.5,392.0 C 357.2,391.8 358.0,391.7 358.7,391.5 C 359.4,391.4 360.1,391.2 360.9,391.0 C 361.6,390.8 362.3,390.8 363.1,390.6 C 363.8,390.4 364.5,390.1 365.2,389.8 C 366.0,389.6 366.7,389.5 367.4,389.3 C 368.2,389.1 368.9,388.8 369.6,388.6 C 370.3,388.4 371.1,388.1 371.8,387.9 C 372.5,387.7 373.3,387.6 374.0,387.3 C 374.7,386.9 375.4,386.1 376.2,385.8 C 376.9,385.5 377.6,385.4 378.3,385.3 C 379.1,385.2 379.8,385.2 380.5,385.1 C 381.3,385.0 382.0,384.9 382.7,384.8 C 383.4,384.7 384.2,384.8 384.9,384.5 C 385.6,384.2 386.4,383.3 387.1,382.9 C 387.8,382.5 388.5,382.6 389.3,382.4 C 390.0,382.2 390.7,381.7 391.5,381.6 C 392.2,381.4 392.9,381.5 393.6,381.3 C 394.4,381.1 395.1,380.7 395.8,380.5 C 396.6,380.3 397.3,380.4 398.0,380.3 C 398.7,380.2 399.5,380.2 400.2,380.0 C 400.9,379.8 401.7,379.6 402.4,379.1 C 403.1,378.7 403.8,377.8 404.6,377.3 C 405.3,376.8 406.0,376.6 406.7,376.3 C 407.5,376.0 408.2,375.9 408.9,375.2 C 409.7,374.6 410.4,373.2 411.1,372.5 C 411.8,371.8 412.6,371.5 413.3,371.0 C 414.0,370.6 414.8,370.6 415.5,369.9 C 416.2,369.3 416.9,367.9 417.7,367.2 C 418.4,366.6 419.1,366.5 419.9,366.2 C 420.6,365.8 421.3,365.6 422.0,365.1 C 422.8,36Line truncated
|
||||
<path d="M 90.0,462.3 C 90.4,462.3 91.8,462.0 92.2,461.9 C 92.9,461.8 93.6,461.7 94.4,461.5 C 95.1,461.4 95.8,461.2 96.6,461.1 C 97.3,461.0 98.0,461.1 98.7,461.0 C 99.5,461.0 100.2,461.0 100.9,460.9 C 101.7,460.9 102.4,460.8 103.1,460.8 C 103.8,460.7 104.2,460.7 105.3,460.7 C 106.4,460.6 108.6,460.6 109.7,460.5 C 110.8,460.5 111.1,460.4 111.8,460.4 C 112.6,460.4 113.3,460.4 114.0,460.4 C 114.8,460.4 115.5,460.3 116.2,460.3 C 116.9,460.3 117.7,460.3 118.4,460.3 C 119.1,460.2 119.9,460.2 120.6,460.1 C 121.3,460.1 122.0,460.1 122.8,459.9 C 123.5,459.8 124.2,459.6 125.0,459.3 C 125.7,459.0 126.4,458.3 127.1,458.1 C 127.9,457.8 128.6,458.0 129.3,457.9 C 130.0,457.9 130.8,457.9 131.5,457.8 C 132.2,457.8 132.6,457.8 133.7,457.7 C 134.8,457.7 136.6,457.7 138.1,457.7 C 139.5,457.7 141.3,457.6 142.4,457.6 C 143.5,457.6 143.9,457.5 144.6,457.5 C 145.3,457.5 146.1,457.4 146.8,457.4 C 147.5,457.4 148.3,457.3 149.0,457.3 C 149.7,457.3 150.4,457.3 151.2,457.3 C 151.9,457.2 152.3,457.2 153.3,457.2 C 154.4,457.2 155.9,457.1 157.7,457.1 C 159.5,457.1 162.5,457.1 164.3,457.1 C 166.1,457.0 166.5,457.1 168.6,457.0 C 170.8,457.0 175.6,456.9 177.4,456.8 C 179.2,456.8 178.5,456.8 179.6,456.8 C 180.7,456.8 182.1,456.8 183.9,456.7 C 185.8,456.7 186.8,456.7 190.5,456.6 C 194.1,456.6 202.9,456.5 205.8,456.5 C 208.7,456.5 207.2,456.5 208.0,456.4 C 208.7,456.4 209.1,457.0 210.1,456.4 C 211.2,455.8 213.4,455.3 214.5,452.9 C 215.6,450.4 216.0,443.6 216.7,441.5 C 217.4,439.4 218.2,440.6 218.9,440.3 C 219.6,439.9 220.3,439.7 221.1,439.5 C 221.8,439.3 222.5,439.1 223.3,439.0 C 224.0,438.8 224.7,438.8 225.4,438.7 C 226.2,438.6 226.9,438.5 227.6,438.4 C 228.3,438.4 229.1,438.5 229.8,438.4 C 230.5,438.3 231.3,438.1 232.0,438.1 C 232.7,438.0 233.4,438.0 234.2,437.9 C 234.9,437.8 235.6,437.5 236.4,437.4 C 237.1,437.3 237.8,437.3 238.5,437.2 C 239.3,437.2 239.6,437.0 240.7,436.9 C 241.8,436.8 244.0,436.8 245.1,436.8 C 246.2,436.7 246.6,436.7 247.3,436.6 C 248.0,436.6 248.7,436.6 249.5,436.5 C 250.2,436.4 250.9,436.2 251.7,436.0 C 252.4,435.9 253.1,436.5 253.8,435.7 C 254.6,434.9 255.3,432.1 256.0,431.2 C 256.7,430.3 257.5,430.4 258.2,430.3 C 258.9,430.1 259.7,430.3 260.4,430.2 C 261.1,430.1 261.8,429.8 262.6,429.5 C 263.3,429.3 264.0,429.1 264.8,428.7 C 265.5,428.2 266.2,427.4 266.9,426.9 C 267.7,426.4 268.4,425.9 269.1,425.5 C 269.9,425.1 270.6,424.8 271.3,424.6 C 272.0,424.4 272.8,424.3 273.5,424.2 C 274.2,424.1 275.0,424.1 275.7,424.0 C 276.4,423.9 277.1,423.7 277.9,423.6 C 278.6,423.5 279.3,423.6 280.0,423.5 C 280.8,423.5 281.5,423.4 282.2,423.3 C 283.0,423.2 283.7,423.2 284.4,422.9 C 285.1,422.5 285.9,421.6 286.6,421.2 C 287.3,420.7 288.1,420.6 288.8,420.2 C 289.5,419.9 290.2,420.6 291.0,419.2 C 291.7,417.8 292.4,413.6 293.2,412.0 C 293.9,410.4 294.6,410.1 295.3,409.4 C 296.1,408.7 296.8,408.3 297.5,407.8 C 298.3,407.4 299.0,406.9 299.7,406.6 C 300.4,406.2 301.2,405.9 301.9,405.6 C 302.6,405.4 303.3,405.4 304.1,405.2 C 304.8,405.0 305.5,404.8 306.3,404.5 C 307.0,404.2 307.7,403.8 308.4,403.6 C 309.2,403.4 309.9,403.4 310.6,403.2 C 311.4,402.9 312.1,402.5 312.8,402.2 C 313.5,402.0 314.3,401.7 315.0,401.5 C 315.7,401.3 316.5,401.1 317.2,401.0 C 317.9,400.9 318.6,400.8 319.4,400.7 C 320.1,400.6 320.8,400.4 321.6,400.3 C 322.3,400.1 323.0,400.0 323.7,399.8 C 324.5,399.6 325.2,399.5 325.9,399.2 C 326.7,398.9 327.4,398.4 328.1,398.1 C 328.8,397.9 329.6,397.8 330.3,397.7 C 331.0,397.5 331.7,397.4 332.5,397.3 C 333.2,397.2 333.9,397.1 334.7,397.0 C 335.4,396.9 336.1,396.8 336.8,396.7 C 337.6,396.7 338.3,396.7 339.0,396.6 C 339.8,396.5 340.5,396.3 341.2,396.1 C 341.9,395.8 342.7,395.4 343.4,395.1 C 344.1,394.8 344.9,394.5 345.6,394.3 C 346.3,394.1 347.0,394.1 347.8,394.0 C 348.5,393.8 349.2,393.8 350.0,393.6 C 350.7,393.4 351.4,392.9 352.1,392.7 C 352.9,392.5 353.6,392.6 354.3,392.5 C 355.0,392.4 355.8,392.2 356.5,392.0 C 357.2,391.8 358.0,391.7 358.7,391.5 C 359.4,391.4 360.1,391.2 360.9,391.0 C 361.6,390.8 362.3,390.8 363.1,390.6 C 363.8,390.4 364.5,390.1 365.2,389.8 C 366.0,389.6 366.7,389.5 367.4,389.3 C 368.2,389.1 368.9,388.8 369.6,388.6 C 370.3,388.4 371.1,388.1 371.8,387.9 C 372.5,387.7 373.3,387.6 374.0,387.3 C 374.7,386.9 375.4,386.1 376.2,385.8 C 376.9,385.5 377.6,385.4 378.3,385.3 C 379.1,385.2 379.8,385.2 380.5,385.1 C 381.3,385.0 382.0,384.9 382.7,384.8 C 383.4,384.7 384.2,384.8 384.9,384.5 C 385.6,384.2 386.4,383.3 387.1,382.9 C 387.8,382.5 388.5,382.6 389.3,382.4 C 390.0,382.2 390.7,381.7 391.5,381.6 C 392.2,381.4 392.9,381.5 393.6,381.3 C 394.4,381.1 395.1,380.7 395.8,380.5 C 396.6,380.3 397.3,380.4 398.0,380.3 C 398.7,380.2 399.5,380.2 400.2,380.0 C 400.9,379.8 401.7,379.6 402.4,379.1 C 403.1,378.7 403.8,377.8 404.6,377.3 C 405.3,376.8 406.0,376.6 406.7,376.3 C 407.5,376.0 408.2,375.9 408.9,375.2 C 409.7,374.6 410.4,373.2 411.1,372.5 C 411.8,371.8 412.6,371.5 413.3,371.0 C 414.0,370.6 414.8,370.6 415.5,369.9 C 416.2,369.3 416.9,367.9 417.7,367.2 C 418.4,366.6 419.1,366.5 419.9,366.2 C 420.6,365.8 421.3,365.6 422.0,365.1 C 422.8,36Line truncated
|
||||
<circle cx="765.0" cy="119.4" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7176</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 37 KiB |
@@ -1,6 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { describeHostKey } = require("../electron/bridges/hostKeyVerifier.cjs");
|
||||
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("../electron/bridges/fixtures/hostCertificates.test.fixture.cjs");
|
||||
|
||||
import type { KnownHost } from "./models";
|
||||
import {
|
||||
@@ -139,6 +144,55 @@ test("fingerprintFromPublicKey returns empty string on missing input", () => {
|
||||
|
||||
// --- Migration --------------------------------------------------------------
|
||||
|
||||
for (const options of [
|
||||
{ type: "ed25519" },
|
||||
{ type: "rsa", bits: 2048 },
|
||||
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
|
||||
{ type: "ed25519", sk: true },
|
||||
{ type: "ecdsa", bits: 256, sk: true },
|
||||
]) {
|
||||
test(`renderer certificate fingerprint and migration agree with verifier: ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t, options);
|
||||
const expected = describeHostKey(first.blob).fingerprint;
|
||||
assert.equal(fingerprintFromPublicKey(first.line), expected);
|
||||
assert.equal(expected, first.publicKeyFingerprint);
|
||||
assert.equal(fingerprintFromPublicKey(renewed.line), expected);
|
||||
assert.notEqual(fingerprintFromPublicKey(different.line), expected);
|
||||
assert.equal(fingerprintFromPublicKey(first.plain.line), expected);
|
||||
|
||||
const legacy = knownHost({
|
||||
keyType: first.keyType,
|
||||
publicKey: first.line,
|
||||
fingerprint: crypto.createHash("sha256").update(first.blob).digest("base64").replace(/=+$/g, ""),
|
||||
});
|
||||
const migrated = normalizeKnownHost(legacy);
|
||||
assert.equal(migrated.fingerprint, expected);
|
||||
assert.equal(migrated.keyType, first.keyType);
|
||||
assert.equal(migrated.publicKey, first.line);
|
||||
assert.equal(normalizeKnownHost(migrated), migrated, "migration is idempotent");
|
||||
assert.equal(normalizeKnownHost({ ...legacy, fingerprint: undefined }).fingerprint, expected);
|
||||
assert.equal(normalizeKnownHost({ ...legacy, keyType: "" }).keyType, first.keyType);
|
||||
const unrelated = { ...legacy, fingerprint: "unrelated-explicit-fingerprint" };
|
||||
assert.equal(normalizeKnownHost(unrelated), unrelated);
|
||||
const opaque = { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` };
|
||||
assert.equal(normalizeKnownHost(opaque), opaque);
|
||||
const fingerprintOnly = { ...legacy, publicKey: undefined };
|
||||
assert.equal(normalizeKnownHost(fingerprintOnly), fingerprintOnly);
|
||||
});
|
||||
}
|
||||
|
||||
test("renderer fingerprints v00 certificates and falls back for unparseable certificates", () => {
|
||||
const cert = legacyCertificate("ssh-rsa", ["exponent", "modulus"]);
|
||||
assert.equal(fingerprintFromPublicKey(cert.line), describeHostKey(cert.plain).fingerprint);
|
||||
const malformed = Buffer.concat([
|
||||
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
|
||||
]);
|
||||
const line = `ssh-ed25519-cert-v01@openssh.com ${malformed.toString("base64")}`;
|
||||
const fullHash = crypto.createHash("sha256").update(malformed).digest("base64").replace(/=+$/g, "");
|
||||
assert.equal(fingerprintFromPublicKey(line), fullHash);
|
||||
assert.equal(fingerprintFromPublicKey(line), describeHostKey(malformed).fingerprint);
|
||||
});
|
||||
|
||||
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519");
|
||||
const base64Body = rawKey.toString("base64");
|
||||
|
||||
+15
-6
@@ -1,4 +1,5 @@
|
||||
import type { KnownHost } from "./models";
|
||||
import { extractCertificatePublicKey } from "@/domain/sshPublicKey.shared.cjs";
|
||||
|
||||
const normalizeHost = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
@@ -132,7 +133,7 @@ export const fingerprintFromPublicKey = (publicKey: string | undefined | null):
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
|
||||
const bytes = base64Decode(parts[1]);
|
||||
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
|
||||
if (bytes) return stripPadding(base64Encode(sha256Bytes(extractCertificatePublicKey(bytes) || bytes)));
|
||||
}
|
||||
|
||||
return stripPadding(trimmed);
|
||||
@@ -145,9 +146,9 @@ const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): stri
|
||||
};
|
||||
|
||||
/**
|
||||
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
|
||||
* verifier can match it without falling back to the brittle re-derivation
|
||||
* path. Returns the same reference when nothing changes so callers can skip
|
||||
* Backfill missing `fingerprint` / `keyType` and migrate full-certificate
|
||||
* fingerprints to the underlying key when the certificate body is available.
|
||||
* Returns the same reference when nothing changes so callers can skip
|
||||
* persistence writes and React re-renders.
|
||||
*/
|
||||
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
|
||||
@@ -156,9 +157,17 @@ export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
|
||||
&& knownHost.keyType.length > 0
|
||||
&& knownHost.keyType !== "unknown";
|
||||
|
||||
if (hasFingerprint && hasKeyType) return knownHost;
|
||||
const parts = knownHost.publicKey?.trim().split(/\s+/) ?? [];
|
||||
const certBytes = /-cert-v0[01]@openssh\.com$/.test(parts[0]) && parts[1]
|
||||
? base64Decode(parts[1]) : null;
|
||||
const legacyCertFingerprint = certBytes && extractCertificatePublicKey(certBytes)
|
||||
? stripPadding(base64Encode(sha256Bytes(certBytes))) : "";
|
||||
const migrateCertificate = legacyCertFingerprint
|
||||
&& stripPadding((knownHost.fingerprint ?? "").replace(/^SHA256:/i, "")) === legacyCertFingerprint;
|
||||
|
||||
const derivedFingerprint = hasFingerprint
|
||||
if (hasFingerprint && hasKeyType && !migrateCertificate) return knownHost;
|
||||
|
||||
const derivedFingerprint = hasFingerprint && !migrateCertificate
|
||||
? knownHost.fingerprint!
|
||||
: fingerprintFromPublicKey(knownHost.publicKey);
|
||||
const derivedKeyType = hasKeyType
|
||||
|
||||
@@ -125,6 +125,48 @@ test("settings search finds App Lock from the zh-CN locale", () => {
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
|
||||
});
|
||||
|
||||
test("settings search finds folder compression from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["压缩", "普通传输"]],
|
||||
[tEnReal, ["compress", "tar"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-compressed-upload"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds skip unchanged files from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["跳过未改文件", "修改时间"]],
|
||||
[tEnReal, ["skip unchanged", "rsync"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-skip-unchanged"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds SSH connection keep-alive from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["保活时间", "重新认证"]],
|
||||
[tEnReal, ["keep-alive", "re-authentication"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-transport-idle-ttl"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds temporary network device mode", () => {
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
const tZhTW = (key: string) => zhTW[key] ?? key;
|
||||
|
||||
@@ -671,6 +671,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.sftp.autoSync.enableDesc",
|
||||
sectionKey: "settings.sftp.autoSync",
|
||||
},
|
||||
{
|
||||
id: "sftp-compressed-upload",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.sftp.compressedUpload.enable",
|
||||
descriptionKey: "settings.sftp.compressedUpload.enableDesc",
|
||||
sectionKey: "settings.sftp.compressedUpload",
|
||||
},
|
||||
{
|
||||
id: "sftp-follow-terminal-cwd",
|
||||
tab: "file-associations",
|
||||
@@ -691,6 +698,20 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
labelKey: "settings.sftp.transferConcurrency",
|
||||
descriptionKey: "settings.sftp.transferConcurrency.desc",
|
||||
},
|
||||
{
|
||||
id: "sftp-skip-unchanged",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.sftp.skipUnchanged.enable",
|
||||
descriptionKey: "settings.sftp.skipUnchanged.enableDesc",
|
||||
sectionKey: "settings.sftp.transferConcurrency",
|
||||
},
|
||||
{
|
||||
id: "sftp-transport-idle-ttl",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.ssh.transportIdleTtl",
|
||||
descriptionKey: "settings.ssh.transportIdleTtl.desc",
|
||||
sectionKey: "settings.sftp.transferConcurrency",
|
||||
},
|
||||
{
|
||||
id: "sftp-default-opener",
|
||||
tab: "file-associations",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
|
||||
// Extract only the public-key wire blob for fingerprinting, never CA trust or
|
||||
// certificate validation. ssh2's getPublicSSH() preserves the full certificate,
|
||||
// and its internal base-type helper is not exported. Keep this byte-only helper
|
||||
// shared by Electron and the renderer so migration uses exactly the same bytes.
|
||||
// Layouts: OpenSSH PROTOCOL.certkeys (v00/v01) and PROTOCOL.u2f (SK).
|
||||
function extractCertificatePublicKey(blob) {
|
||||
let offset = 0;
|
||||
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
|
||||
const skip = (length) => {
|
||||
if (offset + length > blob.length) throw new Error("Truncated SSH key");
|
||||
offset += length;
|
||||
};
|
||||
const readString = () => {
|
||||
skip(4);
|
||||
const length = view.getUint32(offset - 4, false);
|
||||
const start = offset;
|
||||
skip(length);
|
||||
return blob.subarray(start, offset);
|
||||
};
|
||||
const ascii = (bytes) => String.fromCharCode(...bytes);
|
||||
|
||||
try {
|
||||
const typeBytes = readString();
|
||||
if (typeBytes.length > 128) return null;
|
||||
const match = ascii(typeBytes).match(/^(.*)-cert-v0([01])@openssh\.com$/);
|
||||
if (!match) return null;
|
||||
let baseType = match[1];
|
||||
const v01 = match[2] === "1";
|
||||
if (/^rsa-sha2-(256|512)$/.test(baseType)) baseType = "ssh-rsa";
|
||||
if (baseType === "sk-ssh-ed25519" || baseType === "sk-ecdsa-sha2-nistp256") {
|
||||
baseType += "@openssh.com";
|
||||
}
|
||||
let fields;
|
||||
switch (baseType) {
|
||||
case "ssh-rsa": fields = 2; break;
|
||||
case "ssh-dss": fields = 4; break;
|
||||
case "ssh-ed25519": fields = 1; break;
|
||||
case "sk-ssh-ed25519@openssh.com": fields = 2; break;
|
||||
case "sk-ecdsa-sha2-nistp256@openssh.com": fields = 3; break;
|
||||
case "ecdsa-sha2-nistp256":
|
||||
case "ecdsa-sha2-nistp384":
|
||||
case "ecdsa-sha2-nistp521": fields = 2; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
// v00 put its nonce after the constraints, not before the key material.
|
||||
if (v01) readString();
|
||||
const keyStart = offset;
|
||||
for (let i = 0; i < fields; i += 1) {
|
||||
const field = readString();
|
||||
const isApplication = baseType.startsWith("sk-") && i === fields - 1;
|
||||
if (!field.length && !isApplication) return null;
|
||||
if (i === 0 && baseType.includes("ed25519") && field.length !== 32) return null;
|
||||
if (i === 0 && baseType.includes("ecdsa")) {
|
||||
const curve = baseType.replace(/^sk-/, "").replace(/@openssh\.com$/, "").replace(/^ecdsa-sha2-/, "");
|
||||
if (field.length > 8 || ascii(field) !== curve) return null;
|
||||
}
|
||||
}
|
||||
const keyEnd = offset;
|
||||
// Check the outer envelope too; truncated/unsupported certificates retain
|
||||
// their full-blob fingerprint rather than gaining an inner-key match.
|
||||
if (v01) skip(8); // serial
|
||||
skip(4); // host/user type
|
||||
readString(); // key ID
|
||||
readString(); // principals
|
||||
skip(16); // validity
|
||||
readString(); // critical options (v00 constraints)
|
||||
readString(); // extensions (v00 nonce)
|
||||
readString(); // reserved
|
||||
if (!readString().length || !readString().length || offset !== blob.length) return null;
|
||||
|
||||
const result = new Uint8Array(4 + baseType.length + keyEnd - keyStart);
|
||||
new DataView(result.buffer).setUint32(0, baseType.length, false);
|
||||
for (let i = 0; i < baseType.length; i += 1) result[4 + i] = baseType.charCodeAt(i);
|
||||
result.set(blob.subarray(keyStart, keyEnd), 4 + baseType.length);
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
exports.extractCertificatePublicKey = extractCertificatePublicKey;
|
||||
@@ -84,6 +84,7 @@ module.exports = {
|
||||
// is excluded below, so the shared TOTP runtime must be listed.
|
||||
'domain/totp.shared.cjs',
|
||||
'domain/totpPrompt.shared.cjs',
|
||||
'domain/sshPublicKey.shared.cjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
|
||||
@@ -281,6 +281,26 @@ test('wall deadline during authorization reports authorization failure rather th
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('idle Enter-PSSession remoting prompt allows the command to be sent', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
const prompt = '[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ';
|
||||
trackSessionIdlePrompt(session, `PowerShell 7\r\n${prompt}`);
|
||||
assert.equal(readyPrompt(session), prompt);
|
||||
|
||||
const result = await startPtyJob(pty, 'Write-Output ok', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
typedInput: true,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.stdout, 'OK');
|
||||
assert.equal(pty.writes.length, 1);
|
||||
assert.match(pty.writes[0], /\$__NCMCP_/);
|
||||
});
|
||||
|
||||
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
|
||||
@@ -50,11 +50,12 @@ function formatSyntheticEcho(command) {
|
||||
}
|
||||
|
||||
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
|
||||
// `PS /home/alice>`). Anchored so command output that merely starts with
|
||||
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `PS /home/alice>`), with optional Enter-PSSession / remoting `[host]: `
|
||||
// and debug `[DBG]: ` prefixes. Anchored so command output that merely
|
||||
// starts with `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `"PS >"` (which the default prompt never emits) so a script that prints
|
||||
// such a line can't trick prompt-driven shell-kind selection.
|
||||
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
|
||||
const POWERSHELL_PROMPT_PATTERN = /^(?:\[[^\]\r\n]+\]: )*PS(?:\s+\S.*)?>$/;
|
||||
|
||||
// Default cmd.exe prompt (e.g. `C:\>`, `C:\Users\alice>`, `D:\data>`).
|
||||
// Drive letter + optional path + `>`. Rejects `C: >` (space before `>`) and
|
||||
|
||||
@@ -112,6 +112,46 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
|
||||
assert.equal(isDefaultPowerShellPromptLine(null), false);
|
||||
});
|
||||
|
||||
test("PowerShell prompt helpers recognize remoting and debug prefixes", () => {
|
||||
for (const prompt of [
|
||||
"[192.168.1.10]: PS C:\\Users\\Administrator\\Documents>",
|
||||
"[SERVER01]: PS C:\\>",
|
||||
"[host.example.com]: PS C:\\Windows\\System32>",
|
||||
"[fe80::1]: PS C:\\>",
|
||||
"[DBG]: PS C:\\Users\\alice>",
|
||||
"[SERVER01]: [DBG]: PS C:\\>",
|
||||
]) {
|
||||
assert.equal(isDefaultPowerShellPromptLine(prompt), true, prompt);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("PowerShell prompt helpers reject malformed remoting prefixes and look-alikes", () => {
|
||||
for (const line of [
|
||||
"[host] PS C:\\>",
|
||||
"prefix [host]: PS C:\\>",
|
||||
"[]: PS C:\\>",
|
||||
"[ho]st]: PS C:\\>",
|
||||
"[ho\rst]: PS C:\\>",
|
||||
"[ho\nst]: PS C:\\>",
|
||||
"[host]:PS C:\\>",
|
||||
"[host]: PS >",
|
||||
"PS >",
|
||||
"PSO>",
|
||||
"ZIPS>",
|
||||
"",
|
||||
null,
|
||||
]) {
|
||||
assert.equal(isDefaultPowerShellPromptLine(line), false, String(line));
|
||||
assert.equal(extractTrailingIdlePrompt(line), "", String(line));
|
||||
}
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt preserves trailing space on a remoting prompt", () => {
|
||||
const prompt = "[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ";
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt);
|
||||
});
|
||||
|
||||
test("extracts a trailing cmd.exe idle prompt", () => {
|
||||
// Windows OpenSSH default shell is cmd.exe; without capturing `C:\...>`
|
||||
// AI exec cannot select the cmd wrapper when shellKind is still unset.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
let sshKeygenSkip = false;
|
||||
try {
|
||||
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") sshKeygenSkip = "ssh-keygen is not installed";
|
||||
}
|
||||
|
||||
const sshString = (value) => {
|
||||
const body = Buffer.from(value);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(body.length);
|
||||
return Buffer.concat([length, body]);
|
||||
};
|
||||
|
||||
// Real local certificates, with no server/network or security-key device.
|
||||
function mintHostCertificates(t, { type = "ed25519", bits, sk = false, user = false } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(__dirname, ".hostcert-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const keygen = (...args) => execFileSync("ssh-keygen", ["-q", ...args], { stdio: "pipe" });
|
||||
const ca = path.join(dir, "ca");
|
||||
keygen("-t", "ed25519", "-N", "", "-f", ca);
|
||||
const readKey = (file) => {
|
||||
const line = fs.readFileSync(file, "utf8").trim();
|
||||
const [keyType, body] = line.split(/\s+/);
|
||||
return { line, keyType, blob: Buffer.from(body, "base64") };
|
||||
};
|
||||
const mint = (name, validity, serial) => {
|
||||
const host = path.join(dir, name);
|
||||
if (!fs.existsSync(host)) {
|
||||
keygen("-t", type, ...(bits ? ["-b", String(bits)] : []), "-N", "", "-f", host);
|
||||
if (sk) {
|
||||
const plain = readKey(`${host}.pub`);
|
||||
const keyMaterial = plain.blob.subarray(4 + plain.keyType.length);
|
||||
const skType = `sk-${plain.keyType}@openssh.com`;
|
||||
const blob = Buffer.concat([sshString(skType), keyMaterial, sshString("ssh:netcatty-test")]);
|
||||
fs.writeFileSync(`${host}.pub`, `${skType} ${blob.toString("base64")}\n`);
|
||||
}
|
||||
}
|
||||
keygen("-s", ca, "-I", "netcatty-hostcert", ...(user ? [] : ["-h"]),
|
||||
"-n", "127.0.0.1", "-V", validity, "-z", String(serial), `${host}.pub`);
|
||||
const cert = readKey(`${host}-cert.pub`);
|
||||
const listing = execFileSync("ssh-keygen", ["-L", "-f", `${host}-cert.pub`], { encoding: "utf8" });
|
||||
const publicKeyFingerprint = listing.match(/Public key: .* SHA256:(\S+)/)?.[1];
|
||||
if (!publicKeyFingerprint) throw new Error("Missing ssh-keygen certificate fingerprint");
|
||||
return { ...cert, publicKeyFingerprint, plain: readKey(`${host}.pub`) };
|
||||
};
|
||||
return {
|
||||
first: mint("host", "20250101:20260101", 1),
|
||||
renewed: mint("host", "20260101:20270101", 2),
|
||||
different: mint("other", "20260101:20270101", 3),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy v00 is no longer emitted by ssh-keygen. This follows the original
|
||||
// PROTOCOL.certkeys layout: key material first, nonce after constraints.
|
||||
function legacyCertificate(keyType, keyFields) {
|
||||
const blob = Buffer.concat([
|
||||
sshString(`${keyType}-cert-v00@openssh.com`),
|
||||
...keyFields.map(sshString),
|
||||
Buffer.from([0, 0, 0, 2]),
|
||||
sshString("legacy"), sshString(""), Buffer.alloc(16),
|
||||
sshString(""), sshString("nonce"), sshString(""),
|
||||
sshString("signing key"), sshString("signature"),
|
||||
]);
|
||||
return {
|
||||
blob,
|
||||
plain: Buffer.concat([sshString(keyType), ...keyFields.map(sshString)]),
|
||||
line: `${keyType}-cert-v00@openssh.com ${blob.toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip };
|
||||
@@ -1,6 +1,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { utils: sshUtils } = require("ssh2");
|
||||
const { extractCertificatePublicKey } = require("../../domain/sshPublicKey.shared.cjs");
|
||||
|
||||
const REQUEST_TTL_MS = 2 * 60 * 1000;
|
||||
const hostKeyRequests = new Map();
|
||||
@@ -65,8 +66,9 @@ const fingerprintFromPublicKey = (publicKey) => {
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
|
||||
try {
|
||||
const blob = Buffer.from(parts[1], "base64");
|
||||
return crypto.createHash("sha256")
|
||||
.update(Buffer.from(parts[1], "base64"))
|
||||
.update(extractCertificatePublicKey(blob) || blob)
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, "");
|
||||
} catch {
|
||||
@@ -77,7 +79,16 @@ const fingerprintFromPublicKey = (publicKey) => {
|
||||
};
|
||||
|
||||
const getKnownHostFingerprint = (knownHost) => {
|
||||
return normalizeFingerprint(knownHost?.fingerprint)
|
||||
const stored = normalizeFingerprint(knownHost?.fingerprint);
|
||||
const parts = knownHost?.publicKey?.trim().split(/\s+/) || [];
|
||||
if (parts.length >= 2 && /-cert-v0[01]@openssh\.com$/.test(parts[0])) {
|
||||
const blob = Buffer.from(parts[1], "base64");
|
||||
// Only replace an explicit fingerprint if it is the legacy full-cert hash.
|
||||
// Fingerprint-only records cannot be migrated without the certificate body.
|
||||
const legacy = crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
|
||||
if (!stored || stored === legacy) return fingerprintFromPublicKey(knownHost.publicKey);
|
||||
}
|
||||
return stored
|
||||
|| fingerprintFromPublicKey(knownHost?.publicKey);
|
||||
};
|
||||
|
||||
@@ -134,7 +145,7 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
|
||||
const describeHostKey = (rawKey) => {
|
||||
const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || "");
|
||||
const fingerprint = crypto.createHash("sha256")
|
||||
.update(key)
|
||||
.update(extractCertificatePublicKey(key) || key)
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, "");
|
||||
let keyType = "unknown";
|
||||
@@ -149,7 +160,9 @@ const describeHostKey = (rawKey) => {
|
||||
try {
|
||||
const parsed = sshUtils.parseKey(key);
|
||||
const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (parsedKey && !(parsedKey instanceof Error)) {
|
||||
// Keep the presented certificate identity, even if a parser returns its
|
||||
// base algorithm or underlying public key as metadata.
|
||||
if (parsedKey && !(parsedKey instanceof Error) && !/-cert-v0[01]@openssh\.com$/.test(keyType)) {
|
||||
keyType = parsedKey.type || keyType;
|
||||
const publicSsh = parsedKey.getPublicSSH?.();
|
||||
if (publicSsh) publicKey = publicSsh.toString("utf8");
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("./fixtures/hostCertificates.test.fixture.cjs");
|
||||
|
||||
const {
|
||||
classifyHostKey,
|
||||
createHostVerifier,
|
||||
describeHostKey,
|
||||
getKnownHostFingerprint,
|
||||
handleResponse,
|
||||
normalizeFingerprint,
|
||||
} = require("./hostKeyVerifier.cjs");
|
||||
@@ -17,6 +19,84 @@ const makeRawPublicKey = (keyType, body = "trusted imported host key") => {
|
||||
return Buffer.concat([length, type, Buffer.from(body)]);
|
||||
};
|
||||
|
||||
const hashKey = (blob) => crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
|
||||
|
||||
test("host certificate renewals trust the inner key, including legacy saved fingerprints", { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t);
|
||||
const original = describeHostKey(first.blob);
|
||||
const live = describeHostKey(renewed.blob);
|
||||
const changed = describeHostKey(different.blob);
|
||||
assert.notDeepEqual(first.blob, renewed.blob);
|
||||
assert.equal(original.fingerprint, first.publicKeyFingerprint);
|
||||
assert.equal(live.fingerprint, original.fingerprint);
|
||||
assert.notEqual(changed.fingerprint, live.fingerprint);
|
||||
assert.equal(original.keyType, "ssh-ed25519-cert-v01@openssh.com");
|
||||
assert.equal(original.publicKey, `${first.keyType} ${first.blob.toString("base64")}`);
|
||||
assert.equal(describeHostKey(first.plain.blob).fingerprint, hashKey(first.plain.blob));
|
||||
|
||||
const stored = { id: "kh-cert", hostname: "cert.local", port: 2222, ...original };
|
||||
const classify = (key, record = stored) => classifyHostKey({
|
||||
knownHosts: [record], hostname: "cert.local", port: 2222, ...key,
|
||||
});
|
||||
assert.equal(classify(live).status, "trusted");
|
||||
assert.equal(classify(changed).status, "changed");
|
||||
assert.equal(classify(changed, { ...stored, keyType: "ssh-rsa" }).status, "unknown");
|
||||
|
||||
const legacy = { ...stored, fingerprint: hashKey(first.blob) };
|
||||
assert.notEqual(legacy.fingerprint, live.fingerprint);
|
||||
assert.equal(getKnownHostFingerprint(legacy), live.fingerprint);
|
||||
assert.equal(classify(live, legacy).status, "trusted");
|
||||
assert.equal(classify(changed, legacy).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, publicKey: undefined }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, fingerprint: "unrelated-explicit-fingerprint" }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, fingerprint: undefined }).status, "trusted");
|
||||
|
||||
// Renewal is trusted on reconnect, but changing the raw certificate during
|
||||
// an established connection must still be rejected synchronously on rekey.
|
||||
const verifier = createHostVerifier({ hostname: stored.hostname, port: stored.port, knownHosts: [legacy] });
|
||||
const results = [];
|
||||
verifier(first.blob, (accepted) => results.push(accepted));
|
||||
verifier(Buffer.from(first.blob), (accepted) => results.push(accepted));
|
||||
verifier(renewed.blob, (accepted) => results.push(accepted));
|
||||
assert.deepEqual(results, [true, true, false]);
|
||||
});
|
||||
|
||||
for (const options of [
|
||||
{ type: "rsa", bits: 2048 },
|
||||
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
|
||||
{ type: "ed25519", sk: true },
|
||||
{ type: "ecdsa", bits: 256, sk: true },
|
||||
{ type: "ed25519", user: true },
|
||||
]) {
|
||||
test(`certificate fingerprint matches ssh-keygen for ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t, options);
|
||||
assert.equal(describeHostKey(first.blob).fingerprint, first.publicKeyFingerprint);
|
||||
assert.equal(describeHostKey(first.blob).fingerprint, hashKey(first.plain.blob));
|
||||
assert.equal(describeHostKey(renewed.blob).fingerprint, first.publicKeyFingerprint);
|
||||
assert.notEqual(describeHostKey(different.blob).fingerprint, first.publicKeyFingerprint);
|
||||
});
|
||||
}
|
||||
|
||||
test("v00 certificates extract the key before the nonce", () => {
|
||||
for (const [type, fields] of [["ssh-rsa", ["exponent", "modulus"]], ["ssh-dss", ["p", "q", "g", "y"]]]) {
|
||||
const cert = legacyCertificate(type, fields);
|
||||
assert.equal(describeHostKey(cert.blob).fingerprint, hashKey(cert.plain));
|
||||
assert.equal(describeHostKey(cert.blob).keyType, `${type}-cert-v00@openssh.com`);
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed and unsupported certificates keep their full-blob fingerprint", () => {
|
||||
const truncated = Buffer.concat([
|
||||
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
|
||||
]);
|
||||
const badLength = Buffer.concat([sshString("ssh-ed25519-cert-v01@openssh.com"), Buffer.from([255, 255, 255, 255])]);
|
||||
const unsupported = Buffer.concat([sshString("unknown-cert-v01@openssh.com"), sshString("nonce")]);
|
||||
for (const blob of [truncated, badLength, unsupported]) {
|
||||
assert.equal(describeHostKey(blob).fingerprint, hashKey(blob));
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyHostKey prompts for unknown hosts", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [],
|
||||
|
||||
@@ -1913,6 +1913,16 @@ function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
|
||||
const attrs = await statAsync(sftp, remotePath);
|
||||
return statResultFromAttrs(attrs);
|
||||
},
|
||||
async lstat(remotePath) {
|
||||
const sftp = await requireSftpChannel(client);
|
||||
// Copy preflights must inspect link nodes, never their followed targets.
|
||||
// lstatAsync falls back to STAT when LSTAT is absent, so reject first.
|
||||
if (typeof sftp.lstat !== "function") {
|
||||
throw Object.assign(new Error("Remote server does not support LSTAT"), { code: "ENOTSUP" });
|
||||
}
|
||||
const attrs = await lstatAsync(sftp, remotePath);
|
||||
return statResultFromAttrs(attrs);
|
||||
},
|
||||
async realPath(remotePath) {
|
||||
const sftp = await requireSftpChannel(client);
|
||||
return await realpathAsync(sftp, remotePath);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { Readable, PassThrough } = require("node:stream");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
|
||||
const missing = () => Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
|
||||
async function openSessionClient(t, { sourceIsLink = false, targetExists = false } = {}) {
|
||||
const commands = [];
|
||||
const channel = new EventEmitter();
|
||||
channel.readdir = (_path, callback) => callback(null, []);
|
||||
channel.mkdir = (_path, callback) => callback(null);
|
||||
channel.unlink = (_path, callback) => callback(null);
|
||||
channel.stat = (remotePath, callback) => {
|
||||
if (String(remotePath) === "/target/file" && !targetExists) return callback(missing());
|
||||
callback(null, { size: 66, mode: 0o100640, mtime: 1 });
|
||||
};
|
||||
channel.lstat = (remotePath, callback) => {
|
||||
if (String(remotePath) === "/target" && !targetExists) return callback(missing());
|
||||
callback(null, { size: 66, mode: sourceIsLink ? 0o120777 : 0o100640, mtime: 1 });
|
||||
};
|
||||
channel.createReadStream = () => Readable.from([Buffer.alloc(66)]);
|
||||
const connection = {
|
||||
sftp(callback) { callback(null, channel); },
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
};
|
||||
const clients = new Map();
|
||||
sftpBridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["copy-session", { conn: connection }]]),
|
||||
sftpClients: clients,
|
||||
});
|
||||
transferBridge.init({ sftpClients: clients });
|
||||
const { sftpId } = await sftpBridge.openSftpForSession(null, {
|
||||
sessionId: "copy-session", fileProtocol: "sftp",
|
||||
});
|
||||
t.after(() => sftpBridge.closeSftp(null, { sftpId }));
|
||||
return { client: clients.get(sftpId), channel, commands, sftpId };
|
||||
}
|
||||
|
||||
test("same-host file copy executes cp using the actual session-backed client (#4207)", async (t) => {
|
||||
const { commands, sftpId } = await openSessionClient(t);
|
||||
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId: "session-backed-file-copy",
|
||||
sourcePath: "/source/file", targetPath: "/target/file",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: sftpId, targetSftpId: sftpId,
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: 66, sameHost: true, resumable: false,
|
||||
});
|
||||
assert.deepEqual(commands, ["cp -a '/source/file' '/target/file'"]);
|
||||
assert.equal(result.error, undefined);
|
||||
});
|
||||
|
||||
for (const sourceIsLink of [false, true]) {
|
||||
test(`session-backed LSTAT preserves link classification (${sourceIsLink})`, async (t) => {
|
||||
const { client } = await openSessionClient(t, { sourceIsLink });
|
||||
assert.equal((await client.lstat("/source/file")).isSymbolicLink, sourceIsLink);
|
||||
assert.equal((await client.stat("/source/file")).isSymbolicLink, false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const unsupported of ["missing-method", "server-error"]) {
|
||||
test(`session-backed LSTAT never follows STAT on ${unsupported}`, async (t) => {
|
||||
const { client, channel } = await openSessionClient(t);
|
||||
let statCalls = 0;
|
||||
channel.stat = () => { statCalls++; throw new Error("must not follow symlinks"); };
|
||||
if (unsupported === "missing-method") delete channel.lstat;
|
||||
else channel.lstat = (_path, callback) => callback(Object.assign(new Error("unsupported"), { code: 8 }));
|
||||
await assert.rejects(() => client.lstat("/source/file"), error =>
|
||||
error.code === (unsupported === "missing-method" ? "ENOTSUP" : 8));
|
||||
assert.equal(statCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const targetExists of [false, true]) {
|
||||
test(`session-backed directory cp preserves the destination merge guard (${targetExists})`, async (t) => {
|
||||
const { commands, sftpId } = await openSessionClient(t, { targetExists });
|
||||
const result = await transferBridge.sameHostCopyDirectory({ sender: { send() {} } }, {
|
||||
transferId: `session-backed-directory-copy-${targetExists}`,
|
||||
sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
|
||||
});
|
||||
assert.equal(result.success, !targetExists);
|
||||
assert.deepEqual(commands, targetExists ? [] : ["cp -ra '/source/.' '/target/'"]);
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,8 @@
|
||||
* leaves the Mosh stats bar empty (graceful degradation), never weakens
|
||||
* security.
|
||||
*/
|
||||
const { extractCertificatePublicKey } = require("../../../domain/sshPublicKey.shared.cjs");
|
||||
|
||||
function createSystemKnownHostsApi(ctx) {
|
||||
const { fs, path, os, crypto, log } = ctx;
|
||||
|
||||
@@ -64,7 +66,7 @@ function createSystemKnownHostsApi(ctx) {
|
||||
}
|
||||
if (blob.length === 0) return "";
|
||||
return stripFingerprintPadding(
|
||||
crypto.createHash("sha256").update(blob).digest("base64"),
|
||||
crypto.createHash("sha256").update(extractCertificatePublicKey(blob) || blob).digest("base64"),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -176,7 +178,7 @@ function createSystemKnownHostsApi(ctx) {
|
||||
|
||||
// Parse one known_hosts line into { revoked, certAuthority, hostField,
|
||||
// keyType, fingerprint } or null when it is a comment / blank / malformed.
|
||||
// The fingerprint is the SHA-256 of the line's key blob.
|
||||
// Certificates pin their underlying key, just like the live verifier.
|
||||
const parseKnownHostsLine = (rawLine) => {
|
||||
const line = String(rawLine || "").trim();
|
||||
if (!line || line.startsWith("#")) return null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { mintHostCertificates, sshKeygenSkip } = require("../fixtures/hostCertificates.test.fixture.cjs");
|
||||
const { describeHostKey } = require("../hostKeyVerifier.cjs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
@@ -59,6 +61,22 @@ function hashedHostField(token, salt = crypto.randomBytes(20)) {
|
||||
|
||||
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
|
||||
|
||||
test("system certificate entries pin the inner key across renewals without CA trust", { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t);
|
||||
const { api } = makeApi({ [HOME_KH]: `example.com ${first.line}\n` });
|
||||
const liveFingerprint = describeHostKey(renewed.blob).fingerprint;
|
||||
assert.equal(api.parseKnownHostsLine(`example.com ${first.line}`).fingerprint, liveFingerprint);
|
||||
assert.equal(api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), true);
|
||||
assert.equal(api.isHostKeyTrustedBySystem({
|
||||
hostname: "example.com", fingerprint: describeHostKey(different.blob).fingerprint,
|
||||
}), false);
|
||||
|
||||
const revoked = makeApi({ [HOME_KH]: `example.com ${first.line}\n@revoked example.com ${renewed.line}\n` });
|
||||
assert.equal(revoked.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
|
||||
const ca = makeApi({ [HOME_KH]: `@cert-authority example.com ${first.line}\n` });
|
||||
assert.equal(ca.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
|
||||
});
|
||||
|
||||
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
|
||||
const { api } = makeApi();
|
||||
const paths = api.getSystemKnownHostsPaths();
|
||||
|
||||
@@ -959,6 +959,9 @@ async function buildLocalSessionEnv(
|
||||
) {
|
||||
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");
|
||||
const env = { ...buildTerminalProcessEnv(baseEnv), ...(payload?.env || {}) };
|
||||
// AppImage's ARGV0 makes zsh rename external commands, including ssh. Xorg
|
||||
// then mistakes ssh's X11 connection for a local client and sends unusable fds.
|
||||
delete env.ARGV0;
|
||||
if (platform !== "win32") return env;
|
||||
|
||||
const { getPathEnvKey, resolveWindowsLivePath } = require("./ai/shellUtils.cjs");
|
||||
|
||||
@@ -37,6 +37,15 @@ const STALE_PATH =
|
||||
const LIVE_PATH =
|
||||
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32";
|
||||
|
||||
test("buildLocalSessionEnv strips AppImage ARGV0 without changing the base env", async () => {
|
||||
const baseEnv = { PATH: "/usr/bin:/bin", ARGV0: "/opt/Netcatty.AppImage" };
|
||||
const env = await bridge.buildLocalSessionEnv({}, { baseEnv, platform: "linux" });
|
||||
|
||||
assert.equal(Object.hasOwn(env, "ARGV0"), false);
|
||||
assert.equal(env.PATH, baseEnv.PATH);
|
||||
assert.equal(baseEnv.ARGV0, "/opt/Netcatty.AppImage");
|
||||
});
|
||||
|
||||
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => {
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{ env: { NETCATTY_MARKER: "1" } },
|
||||
|
||||
@@ -7174,6 +7174,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
const singleChannelCopy = srcClient.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh;
|
||||
if (sshClient && typeof sshClient.exec === 'function' && !singleChannelCopy) {
|
||||
try {
|
||||
// cp -a preserves link nodes; the SFTP fallback follows target bytes.
|
||||
// A failed lstat also falls through to that existing route.
|
||||
const sourceAttrs = await runCancelablePreflight(() => srcClient.lstat(
|
||||
encodePathForSession(sourceSftpId, sourcePath, resolvedSourceEnc),
|
||||
));
|
||||
if (sourceAttrs.isSymbolicLink) throw new Error("Source is a symbolic link");
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try {
|
||||
await runTransferAbortableOperation(transfer, (signal) => ensureRemoteDirForSession(
|
||||
@@ -7186,6 +7193,18 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const targetClient = sftpClients.get(targetSftpId);
|
||||
let existingRemoteMode = null;
|
||||
try {
|
||||
const attrs = await runCancelablePreflight(() => targetClient.stat(
|
||||
encodePathForSession(targetSftpId, targetPath, resolvedTargetEnc),
|
||||
));
|
||||
if (Number.isFinite(attrs?.mode)) existingRemoteMode = attrs.mode & 0o7777;
|
||||
} catch {
|
||||
// Missing destinations or failed stats need no permission restore.
|
||||
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
|
||||
@@ -7194,6 +7213,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
if (result.code === 0) {
|
||||
sendProgress(fileSize, fileSize);
|
||||
sameHostDone = true;
|
||||
await restoreRemoteUploadModeBestEffort(
|
||||
targetClient,
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
resolvedTargetEnc,
|
||||
existingRemoteMode,
|
||||
);
|
||||
} else if (result.code === 127) {
|
||||
// Exit 127 = command not found — cache to skip future attempts
|
||||
cpUnavailableSet.add(srcClient);
|
||||
@@ -8204,11 +8230,28 @@ async function sameHostCopyDirectory(event, payload) {
|
||||
}
|
||||
if (cpUnavailableSet.has(client)) return { success: false };
|
||||
|
||||
const resolvedEncoding = resolveEncodingForRequest(sftpId, encoding);
|
||||
if (resolvedEncoding && resolvedEncoding !== 'utf-8') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== 'function') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// Merges must use recursive SFTP transfers to preserve destination child modes.
|
||||
// Only a clearly missing target is safe for the remote cp optimization.
|
||||
try {
|
||||
await runTransferCancelablePreflight(transfer, () => client.lstat(
|
||||
encodePathForSession(sftpId, targetPath, resolvedEncoding),
|
||||
));
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
if (!isMissingRemoteStageError(error)) return { success: false };
|
||||
}
|
||||
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const lifecyclePayload = {
|
||||
...payload,
|
||||
@@ -8233,8 +8276,7 @@ async function sameHostCopyDirectory(event, payload) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
// Use "source/." to copy directory *contents* into target, preserving merge
|
||||
// semantics consistent with the recursive per-file transfer path.
|
||||
// Use "source/." to copy directory *contents* into the newly created target.
|
||||
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
|
||||
@@ -106,6 +106,7 @@ test("same-host cp capability cache follows the live SFTP client, not a recycled
|
||||
bridge.init({ sftpClients });
|
||||
|
||||
const makeClient = (exitCode, calls) => ({
|
||||
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
calls.count += 1;
|
||||
|
||||
@@ -1294,7 +1294,9 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
t.after(async () => fs.promises.rm(tempPath, { force: true }));
|
||||
let markPrefixStarted;
|
||||
const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; });
|
||||
let execCalls = 0;
|
||||
const sourceClient = {
|
||||
client: { exec() { execCalls += 1; throw new Error("checkpointed copies must not exec cp"); } },
|
||||
sftp: createFastSftp({
|
||||
createReadStream() {
|
||||
markPrefixStarted();
|
||||
@@ -1317,7 +1319,7 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
targetSftpId: "target",
|
||||
totalBytes: 8,
|
||||
resumable: true,
|
||||
sameHost: false,
|
||||
sameHost: true,
|
||||
resumeStage: "download",
|
||||
checkpointBytes: 4,
|
||||
downloadCheckpointBytes: 4,
|
||||
@@ -1332,6 +1334,7 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
]);
|
||||
|
||||
assert.match(result.error || "", /cancel/i);
|
||||
assert.equal(execCalls, 0, "same-host resume must verify its saved prefix instead of running cp");
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
||||
});
|
||||
@@ -13243,6 +13246,119 @@ test("same-host directory copy rejected by a committed close leaves no lease or
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
|
||||
test("same-host directory copy skips cp when auto encoding resolves to non-UTF-8", async (t) => {
|
||||
const {
|
||||
sftpTransferSessionLeaseStore,
|
||||
} = require("./sftpTransferSessionLease.cjs");
|
||||
sftpTransferSessionLeaseStore.resetForTests();
|
||||
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
||||
|
||||
const sftpId = "same-host-directory-auto-gb18030";
|
||||
// Seed the session's resolved encoding before the copy requests auto.
|
||||
sftpBridge.resolveEncodingForRequest(sftpId, "gb18030");
|
||||
let execCalls = 0;
|
||||
transferBridge.init({
|
||||
sftpClients: new Map([[sftpId, {
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
execCalls += 1;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
}]]),
|
||||
});
|
||||
const sender = createSender();
|
||||
|
||||
const result = await transferBridge.sameHostCopyDirectory(
|
||||
{ sender },
|
||||
{
|
||||
transferId: "same-host-directory-auto-gb18030-copy",
|
||||
sftpId,
|
||||
sourcePath: "/source",
|
||||
targetPath: "/\u76ee\u6807",
|
||||
encoding: "auto",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(execCalls, 0);
|
||||
assert.equal(result.success, false);
|
||||
assert.deepEqual(sender.sent, []);
|
||||
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
|
||||
for (const targetState of ["existing", "missing", "stat-error"]) {
|
||||
test(`same-host directory copy ${targetState} target uses cp only for a fresh destination`, async (t) => {
|
||||
const { sftpTransferSessionLeaseStore } = require("./sftpTransferSessionLease.cjs");
|
||||
sftpTransferSessionLeaseStore.resetForTests();
|
||||
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
||||
const sftpId = `same-host-directory-${targetState}`;
|
||||
const commands = [];
|
||||
const mkdirs = [];
|
||||
let targetExists = targetState === "existing";
|
||||
const client = {
|
||||
async lstat(remotePath) {
|
||||
assert.equal(String(remotePath), "/target");
|
||||
assert.deepEqual(mkdirs, [], "check the destination before ensuring it");
|
||||
if (targetState === "stat-error") {
|
||||
throw Object.assign(new Error("Permission denied"), { code: 3 });
|
||||
}
|
||||
if (!targetExists) throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
return { isDirectory: true };
|
||||
},
|
||||
sftp: createFastSftp({
|
||||
stat(_remotePath, callback) {
|
||||
if (!targetExists) {
|
||||
callback(Object.assign(new Error("ENOENT"), { code: 2 }));
|
||||
return;
|
||||
}
|
||||
callback(null, { isDirectory: () => true });
|
||||
},
|
||||
mkdir(remotePath, callback) {
|
||||
mkdirs.push(String(remotePath));
|
||||
targetExists = true;
|
||||
callback(null);
|
||||
},
|
||||
}),
|
||||
client: {
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
assert.equal(targetExists, true, "create the fresh destination before cp");
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
};
|
||||
const sftpClients = new Map([[sftpId, client]]);
|
||||
transferBridge.init({ sftpClients });
|
||||
sftpBridge.init({ sftpClients });
|
||||
const sender = createSender();
|
||||
const result = await transferBridge.sameHostCopyDirectory({ sender }, {
|
||||
transferId: `${sftpId}-copy`, sftpId,
|
||||
sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (targetState === "missing") {
|
||||
assert.deepEqual(result, { success: true });
|
||||
assert.deepEqual(commands, ["cp -ra '/source/.' '/target/'"]);
|
||||
assert.deepEqual(mkdirs, ["/target"]);
|
||||
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
|
||||
} else {
|
||||
assert.deepEqual(result, { success: false });
|
||||
assert.deepEqual(commands, []);
|
||||
assert.deepEqual(mkdirs, []);
|
||||
assert.deepEqual(sender.sent, []);
|
||||
}
|
||||
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => {
|
||||
const {
|
||||
sftpTransferSessionLeaseStore,
|
||||
@@ -13259,6 +13375,7 @@ test("same-host directory copy cancellation aborts cp and releases its lease", a
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
const client = {
|
||||
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
@@ -13342,6 +13459,7 @@ test("same-host file cp cancellation releases the shared session lease", async (
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
const client = {
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
@@ -15139,3 +15257,209 @@ test("pending channel-open skips the fastPut reopen and uses the shared channel"
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
|
||||
test("same-host file cp restores existing destination mode", async () => {
|
||||
const commands = [];
|
||||
const chmods = [];
|
||||
let targetMode = 0o100600;
|
||||
let downloads = 0;
|
||||
let uploads = 0;
|
||||
const sourceSftp = createFastSftp({
|
||||
open() { downloads++; throw new Error("unexpected download"); },
|
||||
});
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
stat: async () => ({ size: 64, mode: 0o100644 }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
targetMode = 0o100644;
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetClient = {
|
||||
sftp: createFastSftp({
|
||||
open() { uploads++; throw new Error("unexpected upload"); },
|
||||
}),
|
||||
stat: async () => ({ size: 64, mode: targetMode }),
|
||||
chmod: async (remotePath, mode) => {
|
||||
chmods.push([String(remotePath), mode]);
|
||||
targetMode = mode;
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: `same-host-cp-mode-${crypto.randomUUID()}`,
|
||||
sourcePath: "/source/payload.bin", targetPath: "/target/payload.bin",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: 64,
|
||||
sameHost: true, resumable: false,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
|
||||
assert.deepEqual(chmods, [["/target/payload.bin", 0o600]]);
|
||||
assert.equal(targetMode, 0o600);
|
||||
assert.equal(downloads, 0);
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
|
||||
for (const sourceState of ["symlink", "lstat-error"]) {
|
||||
test(`same-host file cp skips a source ${sourceState} and downloads/uploads target bytes`, async (t) => {
|
||||
const transferId = `same-host-cp-fallback-${crypto.randomUUID()}`;
|
||||
const sourcePath = "/opt/app/config";
|
||||
const targetPath = "/tmp/app/config";
|
||||
const payload = Buffer.from("contents of ../shared/config");
|
||||
const commands = [];
|
||||
let reads = 0;
|
||||
let remote = Buffer.alloc(0);
|
||||
let promoted = false;
|
||||
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
|
||||
const originalRead = sourceSftp.read;
|
||||
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
async lstat(remotePath) {
|
||||
assert.equal(String(remotePath), sourcePath);
|
||||
if (sourceState === "lstat-error") throw new Error("lstat unsupported");
|
||||
return { isSymbolicLink: true, size: "../shared/config".length };
|
||||
},
|
||||
stat: async () => ({ size: payload.length }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
callback(new Error("unexpected exec"));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetSftp = createFastSftp({
|
||||
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
|
||||
write(_handle, buffer, offset, length, position, callback) {
|
||||
if (remote.length < position + length) {
|
||||
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
|
||||
}
|
||||
buffer.copy(remote, position, offset, offset + length);
|
||||
callback(null);
|
||||
},
|
||||
close(_handle, callback) { callback(null); },
|
||||
});
|
||||
const targetClient = {
|
||||
sftp: targetSftp,
|
||||
client: { sftp: (callback) => callback(null, targetSftp) },
|
||||
async stat(remotePath) {
|
||||
if (String(remotePath) === targetPath && remote.length === 0) {
|
||||
throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
}
|
||||
return { size: remote.length };
|
||||
},
|
||||
rename: async () => { promoted = true; },
|
||||
delete: async () => {},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "config");
|
||||
t.after(async () => fs.promises.rm(localStage, { force: true }));
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId, sourcePath, targetPath,
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: payload.length, sameHost: true, resumable: false,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, [], "source symlinks and lstat failures must bypass cp -a");
|
||||
assert.ok(reads > 0, "fallback must download the followed source contents");
|
||||
assert.deepEqual(remote, payload, "fallback must upload target bytes rather than the link node");
|
||||
assert.equal(promoted, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const cpResult of [0, 127, 1, "exec-error"]) {
|
||||
test(`fresh same-host file cp result ${cpResult} preserves copy/fallback behavior (#4207)`, async (t) => {
|
||||
const transferId = `same-host-cp-${crypto.randomUUID()}`;
|
||||
const payload = Buffer.from("same-host ordinary SFTP copy");
|
||||
const commands = [];
|
||||
const chmods = [];
|
||||
let reads = 0;
|
||||
let remote = Buffer.alloc(0);
|
||||
let promoted = false;
|
||||
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
|
||||
const originalRead = sourceSftp.read;
|
||||
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
stat: async () => ({ size: payload.length }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
if (cpResult === "exec-error") { callback(new Error("exec denied")); return; }
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", cpResult));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetSftp = createFastSftp({
|
||||
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
|
||||
write(_handle, buffer, offset, length, position, callback) {
|
||||
if (remote.length < position + length) {
|
||||
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
|
||||
}
|
||||
buffer.copy(remote, position, offset, offset + length);
|
||||
callback(null);
|
||||
},
|
||||
close(_handle, callback) { callback(null); },
|
||||
});
|
||||
const targetClient = {
|
||||
sftp: targetSftp,
|
||||
client: { sftp: (callback) => callback(null, targetSftp) },
|
||||
stat: async (remotePath) => {
|
||||
if (String(remotePath) === "/target/payload.bin" && remote.length === 0) {
|
||||
throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
}
|
||||
return { size: remote.length };
|
||||
},
|
||||
chmod: async (...args) => { chmods.push(args); },
|
||||
rename: async () => { promoted = true; },
|
||||
delete: async () => {},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
|
||||
t.after(async () => fs.promises.rm(localStage, { force: true }));
|
||||
const sender = createSender();
|
||||
const result = await transferBridge.startTransfer({ sender }, {
|
||||
transferId,
|
||||
sourcePath: "/source/payload.bin",
|
||||
targetPath: "/target/payload.bin",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: payload.length,
|
||||
sameHost: true, resumable: false,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
|
||||
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
|
||||
if (cpResult === 0) {
|
||||
assert.deepEqual(chmods, [], "fresh cp must not restore destination permissions");
|
||||
assert.equal(reads, 0, "successful cp must not download the source");
|
||||
assert.equal(remote.length, 0, "successful cp must not upload through SFTP");
|
||||
assert.equal(fs.existsSync(localStage), false);
|
||||
} else {
|
||||
assert.ok(reads > 0, "cp failure must fall back to SFTP download");
|
||||
assert.deepEqual(remote, payload, "fallback must upload the downloaded contents");
|
||||
assert.equal(promoted, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
const { Transform } = require("node:stream");
|
||||
|
||||
const DEFAULT_HIDDEN_EXTENSIONS = Object.freeze(["DRI3", "MIT-SHM"]);
|
||||
const EMPTY = Buffer.alloc(0);
|
||||
|
||||
function readUInt16(buffer, offset, littleEndian) {
|
||||
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
|
||||
}
|
||||
|
||||
function readUInt32(buffer, offset, littleEndian) {
|
||||
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
|
||||
}
|
||||
|
||||
function pad4(length) {
|
||||
return Math.ceil(length / 4) * 4;
|
||||
}
|
||||
|
||||
// Retain only the header we need; large request/reply bodies stream through.
|
||||
function collectHeader(state, length, chunk, offset) {
|
||||
const count = Math.min(Math.max(0, length - state.header.length), chunk.length - offset);
|
||||
if (count > 0) {
|
||||
state.header = Buffer.concat([state.header, chunk.subarray(offset, offset + count)]);
|
||||
}
|
||||
return offset + count;
|
||||
}
|
||||
|
||||
class RequestTracker extends Transform {
|
||||
constructor(shared) {
|
||||
super();
|
||||
this.shared = shared;
|
||||
this.setup = true;
|
||||
this.header = EMPTY;
|
||||
this.remaining = 0;
|
||||
this.sequence = 0;
|
||||
}
|
||||
|
||||
_transform(chunk, _encoding, callback) {
|
||||
const shared = this.shared;
|
||||
let offset = 0;
|
||||
while (offset < chunk.length && shared.enabled) {
|
||||
if (this.remaining > 0) {
|
||||
const count = Math.min(this.remaining, chunk.length - offset);
|
||||
offset += count;
|
||||
this.remaining -= count;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.setup) {
|
||||
offset = collectHeader(this, 1, chunk, offset);
|
||||
if (this.header[0] !== 0x6c && this.header[0] !== 0x42) {
|
||||
shared.enabled = false;
|
||||
break;
|
||||
}
|
||||
shared.littleEndian = this.header[0] === 0x6c;
|
||||
offset = collectHeader(this, 12, chunk, offset);
|
||||
if (this.header.length < 12) break;
|
||||
this.remaining = pad4(readUInt16(this.header, 6, shared.littleEndian))
|
||||
+ pad4(readUInt16(this.header, 8, shared.littleEndian));
|
||||
this.header = EMPTY;
|
||||
this.setup = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
offset = collectHeader(this, 4, chunk, offset);
|
||||
if (this.header.length < 4) break;
|
||||
let words = readUInt16(this.header, 2, shared.littleEndian);
|
||||
const headerLength = words === 0 ? 8 : 4;
|
||||
if (headerLength === 8) {
|
||||
offset = collectHeader(this, 8, chunk, offset);
|
||||
if (this.header.length < 8) break;
|
||||
words = readUInt32(this.header, 4, shared.littleEndian);
|
||||
}
|
||||
const total = words * 4;
|
||||
if (total < headerLength) {
|
||||
shared.enabled = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let name;
|
||||
const nameOffset = headerLength + 4;
|
||||
if (this.header[0] === 98 && total >= nameOffset) { // QueryExtension
|
||||
offset = collectHeader(this, nameOffset, chunk, offset);
|
||||
if (this.header.length < nameOffset) break;
|
||||
const nameLength = readUInt16(this.header, headerLength, shared.littleEndian);
|
||||
if (nameLength <= shared.maxNameLength && nameOffset + pad4(nameLength) <= total) {
|
||||
offset = collectHeader(this, nameOffset + nameLength, chunk, offset);
|
||||
if (this.header.length < nameOffset + nameLength) break;
|
||||
name = this.header.toString("latin1", nameOffset, nameOffset + nameLength);
|
||||
}
|
||||
}
|
||||
|
||||
this.sequence = (this.sequence + 1) & 0xffff;
|
||||
// Do not let an unanswered query affect an unrelated request after wrap.
|
||||
shared.pending.delete(this.sequence);
|
||||
if (shared.hidden.has(name)) shared.pending.set(this.sequence, name);
|
||||
this.remaining = total - this.header.length;
|
||||
this.header = EMPTY;
|
||||
}
|
||||
callback(null, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
class ReplyFilter extends Transform {
|
||||
constructor(shared) {
|
||||
super();
|
||||
this.shared = shared;
|
||||
this.setup = true;
|
||||
this.header = EMPTY;
|
||||
this.remaining = 0;
|
||||
}
|
||||
|
||||
_transform(chunk, _encoding, callback) {
|
||||
const shared = this.shared;
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
if (!shared.enabled) {
|
||||
this.push(this.header);
|
||||
this.header = EMPTY;
|
||||
this.push(chunk.subarray(offset));
|
||||
break;
|
||||
}
|
||||
if (this.remaining > 0) {
|
||||
const count = Math.min(this.remaining, chunk.length - offset);
|
||||
this.push(chunk.subarray(offset, offset + count));
|
||||
offset += count;
|
||||
this.remaining -= count;
|
||||
continue;
|
||||
}
|
||||
|
||||
offset = collectHeader(this, this.setup ? 8 : 4, chunk, offset);
|
||||
if (this.header.length < (this.setup ? 8 : 4)) break;
|
||||
if (this.setup) {
|
||||
this.remaining = readUInt16(this.header, 6, shared.littleEndian) * 4;
|
||||
this.setup = false;
|
||||
} else {
|
||||
const type = this.header[0];
|
||||
let total = 32; // Errors and ordinary events are fixed size.
|
||||
if (type === 1 || (type & 0x7f) === 35) { // Reply or GenericEvent
|
||||
offset = collectHeader(this, 8, chunk, offset);
|
||||
if (this.header.length < 8) break;
|
||||
total += readUInt32(this.header, 4, shared.littleEndian) * 4;
|
||||
}
|
||||
if (type === 0 || type === 1) {
|
||||
const sequence = readUInt16(this.header, 2, shared.littleEndian);
|
||||
const name = shared.pending.get(sequence);
|
||||
if (name !== undefined) {
|
||||
if (type === 1) {
|
||||
offset = collectHeader(this, 9, chunk, offset);
|
||||
if (this.header.length < 9) break;
|
||||
if (this.header[8] !== 0) {
|
||||
this.header[8] = 0; // QueryExtension.present = False
|
||||
if (!shared.warned) {
|
||||
shared.warned = true;
|
||||
shared.logger.warn?.(`[X11] hiding ${name}: forwarding cannot carry file descriptors`);
|
||||
}
|
||||
}
|
||||
}
|
||||
shared.pending.delete(sequence);
|
||||
}
|
||||
}
|
||||
this.remaining = total - this.header.length;
|
||||
}
|
||||
this.push(this.header);
|
||||
this.header = EMPTY;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
callback(null, this.header);
|
||||
}
|
||||
}
|
||||
|
||||
// SCM_RIGHTS descriptors cannot travel over an SSH byte stream. Hide extensions
|
||||
// that require them so clients select a supported rendering path instead.
|
||||
function createX11FdGuard(options = {}) {
|
||||
const hidden = new Set(options.hiddenExtensions ?? DEFAULT_HIDDEN_EXTENSIONS);
|
||||
const shared = {
|
||||
enabled: hidden.size > 0,
|
||||
hidden,
|
||||
maxNameLength: Math.max(0, ...Array.from(hidden, (name) => name.length)),
|
||||
littleEndian: true,
|
||||
pending: new Map(),
|
||||
warned: false,
|
||||
logger: options.logger || console,
|
||||
};
|
||||
return { toServer: new RequestTracker(shared), toClient: new ReplyFilter(shared) };
|
||||
}
|
||||
|
||||
module.exports = { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS };
|
||||
@@ -0,0 +1,204 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { once } = require("node:events");
|
||||
const { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS } = require("./x11FdGuard.cjs");
|
||||
|
||||
function writeUInt16(buffer, value, offset, endian) {
|
||||
buffer[endian === "l" ? "writeUInt16LE" : "writeUInt16BE"](value, offset);
|
||||
}
|
||||
|
||||
function writeUInt32(buffer, value, offset, endian) {
|
||||
buffer[endian === "l" ? "writeUInt32LE" : "writeUInt32BE"](value, offset);
|
||||
}
|
||||
|
||||
function setupRequest(endian = "l") {
|
||||
const packet = Buffer.alloc(24, 0xab);
|
||||
packet[0] = endian.charCodeAt(0);
|
||||
writeUInt16(packet, 11, 2, endian);
|
||||
writeUInt16(packet, 3, 6, endian);
|
||||
writeUInt16(packet, 5, 8, endian);
|
||||
return packet;
|
||||
}
|
||||
|
||||
function setupReply(endian = "l", status = 1) {
|
||||
const packet = Buffer.alloc(20, 0xcd);
|
||||
packet[0] = status;
|
||||
writeUInt16(packet, 11, 2, endian);
|
||||
writeUInt16(packet, 3, 6, endian);
|
||||
return packet;
|
||||
}
|
||||
|
||||
function request(name, { endian = "l", big = false, opcode = 98 } = {}) {
|
||||
const nameBytes = Buffer.from(name, "latin1");
|
||||
const prefix = big ? 12 : 8;
|
||||
const packet = Buffer.alloc(prefix + Math.ceil(nameBytes.length / 4) * 4);
|
||||
packet[0] = opcode;
|
||||
writeUInt16(packet, big ? 0 : packet.length / 4, 2, endian);
|
||||
if (big) writeUInt32(packet, packet.length / 4, 4, endian);
|
||||
writeUInt16(packet, nameBytes.length, big ? 8 : 4, endian);
|
||||
nameBytes.copy(packet, prefix);
|
||||
return packet;
|
||||
}
|
||||
|
||||
function reply(sequence, { endian = "l", type = 1, present = 1, extra = 0 } = {}) {
|
||||
const packet = Buffer.alloc(32 + extra, 0xef);
|
||||
packet[0] = type;
|
||||
writeUInt16(packet, sequence, 2, endian);
|
||||
writeUInt32(packet, extra / 4, 4, endian);
|
||||
packet[8] = present;
|
||||
return packet;
|
||||
}
|
||||
|
||||
function withoutPresent(packet) {
|
||||
const copy = Buffer.from(packet);
|
||||
copy[8] = 0;
|
||||
return copy;
|
||||
}
|
||||
|
||||
function chunks(buffer, size) {
|
||||
if (!size) return [buffer];
|
||||
const result = [];
|
||||
for (let offset = 0; offset < buffer.length; offset += size) {
|
||||
result.push(buffer.subarray(offset, offset + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function exchange(uplink, downlink, { chunkSize, ...options } = {}) {
|
||||
const warnings = [];
|
||||
const guard = createX11FdGuard({ logger: { warn: (message) => warnings.push(message) }, ...options });
|
||||
const sent = [];
|
||||
const received = [];
|
||||
guard.toServer.on("data", (chunk) => sent.push(chunk));
|
||||
guard.toClient.on("data", (chunk) => received.push(chunk));
|
||||
const ended = Promise.all([once(guard.toServer, "end"), once(guard.toClient, "end")]);
|
||||
for (const chunk of chunks(uplink, chunkSize)) guard.toServer.write(chunk);
|
||||
for (const chunk of chunks(downlink, chunkSize)) guard.toClient.write(chunk);
|
||||
guard.toServer.end();
|
||||
guard.toClient.end();
|
||||
await ended;
|
||||
assert.deepEqual(Buffer.concat(sent), uplink, "uplink must stay byte-identical");
|
||||
return { received: Buffer.concat(received), warnings };
|
||||
}
|
||||
|
||||
for (const endian of ["l", "B"]) {
|
||||
for (const chunkSize of [undefined, 1]) {
|
||||
test(`default guard hides only DRI3 and MIT-SHM (${endian}, chunks ${chunkSize || "coalesced"})`, async () => {
|
||||
assert.deepEqual(DEFAULT_HIDDEN_EXTENSIONS, ["DRI3", "MIT-SHM"]);
|
||||
const names = ["DRI3", "GLX", "MIT-SHM", "RENDER", "dri3", "DRI3-extra", ""];
|
||||
const replies = names.map((_, index) => reply(index + 1, { endian }));
|
||||
const uplink = Buffer.concat([setupRequest(endian), ...names.map((name) => request(name, { endian }))]);
|
||||
const downlink = Buffer.concat([setupReply(endian), ...replies]);
|
||||
const expected = Buffer.concat([setupReply(endian), ...replies.map((packet, index) => (
|
||||
index === 0 || index === 2 ? withoutPresent(packet) : packet
|
||||
))]);
|
||||
const result = await exchange(uplink, downlink, { chunkSize });
|
||||
assert.deepEqual(result.received, expected);
|
||||
assert.equal(result.warnings.length, 1);
|
||||
assert.match(result.warnings[0], /^\[X11\] hiding DRI3/);
|
||||
assert.deepEqual(downlink, Buffer.concat([setupReply(endian), ...replies]), "input buffers must stay unchanged");
|
||||
});
|
||||
}
|
||||
|
||||
test(`BIG-REQUESTS use the extended header offsets (${endian})`, async () => {
|
||||
const queries = [
|
||||
request("DRI3", { endian, big: true }),
|
||||
request("MIT-SHM", { endian, big: true }),
|
||||
request("GLX", { endian, big: true }),
|
||||
request("DRI3", { endian }),
|
||||
];
|
||||
const replies = queries.map((_, index) => reply(index + 1, { endian }));
|
||||
const result = await exchange(
|
||||
Buffer.concat([setupRequest(endian), ...queries]),
|
||||
Buffer.concat([setupReply(endian), ...replies]),
|
||||
{ chunkSize: 1 },
|
||||
);
|
||||
assert.deepEqual(result.received, Buffer.concat([
|
||||
setupReply(endian), withoutPresent(replies[0]), withoutPresent(replies[1]), replies[2], withoutPresent(replies[3]),
|
||||
]));
|
||||
});
|
||||
}
|
||||
|
||||
test("an empty hide list is byte-identical and a custom list replaces the defaults", async () => {
|
||||
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM"), request("GLX")]);
|
||||
const replies = [reply(1), reply(2), reply(3)];
|
||||
const downlink = Buffer.concat([setupReply(), ...replies]);
|
||||
const disabled = await exchange(uplink, downlink, { hiddenExtensions: [], chunkSize: 1 });
|
||||
assert.deepEqual(disabled.received, downlink);
|
||||
assert.deepEqual(disabled.warnings, []);
|
||||
const custom = await exchange(uplink, downlink, { hiddenExtensions: ["GLX"], chunkSize: 1 });
|
||||
assert.deepEqual(custom.received, Buffer.concat([setupReply(), replies[0], replies[1], withoutPresent(replies[2])]));
|
||||
assert.equal(custom.warnings.length, 1);
|
||||
assert.match(custom.warnings[0], /hiding GLX/);
|
||||
});
|
||||
|
||||
test("errors, unrelated replies, ordinary events and variable GenericEvents preserve sequence isolation", async () => {
|
||||
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("GLX"), request("MIT-SHM")]);
|
||||
const error = reply(1, { type: 0 });
|
||||
const afterError = reply(1);
|
||||
const unrelated = reply(2, { extra: 64 });
|
||||
const event = reply(3, { type: 12 });
|
||||
const generic = reply(3, { type: 35, extra: 48 });
|
||||
const sentGeneric = reply(3, { type: 0x80 | 35, extra: 12 });
|
||||
const hidden = reply(3);
|
||||
const repeated = reply(3);
|
||||
const prefix = [setupReply(), error, afterError, unrelated, event, generic, sentGeneric];
|
||||
const result = await exchange(uplink, Buffer.concat([...prefix, hidden, repeated]), { chunkSize: 1 });
|
||||
assert.deepEqual(result.received, Buffer.concat([...prefix, withoutPresent(hidden), repeated]));
|
||||
assert.equal(result.warnings.length, 1);
|
||||
assert.match(result.warnings[0], /hiding MIT-SHM/);
|
||||
});
|
||||
|
||||
test("absent extensions and failed queries do not log", async () => {
|
||||
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM")]);
|
||||
const downlink = Buffer.concat([setupReply(), reply(1, { present: 0 }), reply(2, { type: 0 })]);
|
||||
const result = await exchange(uplink, downlink, { chunkSize: 1 });
|
||||
assert.deepEqual(result.received, downlink);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test("request bodies are skipped without mistaking embedded queries for requests", async () => {
|
||||
const body = Buffer.alloc(128 * 1024);
|
||||
request("DRI3").copy(body, 0);
|
||||
const large = Buffer.alloc(8 + body.length);
|
||||
large[0] = 18; // ChangeProperty, using BIG-REQUESTS framing
|
||||
large.writeUInt32LE(large.length / 4, 4);
|
||||
body.copy(large, 8);
|
||||
const uplink = Buffer.concat([setupRequest(), large, request("MIT-SHM")]);
|
||||
const visible = reply(1, { extra: 128 * 1024 });
|
||||
const hidden = reply(2);
|
||||
const result = await exchange(uplink, Buffer.concat([setupReply(), visible, hidden]), { chunkSize: 17 });
|
||||
assert.deepEqual(result.received, Buffer.concat([setupReply(), visible, withoutPresent(hidden)]));
|
||||
});
|
||||
|
||||
test("sequence wrap hides new queries and clears stale entries on reused sequences", async () => {
|
||||
const noOp = Buffer.from([127, 0, 1, 0]);
|
||||
const queries = [request("DRI3"), Buffer.concat(Array(65534).fill(noOp)), request("MIT-SHM"), noOp, request("DRI3")];
|
||||
const stale = reply(1);
|
||||
const wrapped = reply(0);
|
||||
const hidden = reply(2);
|
||||
const result = await exchange(
|
||||
Buffer.concat([setupRequest(), ...queries]),
|
||||
Buffer.concat([setupReply(), wrapped, stale, hidden]),
|
||||
{ chunkSize: 13 },
|
||||
);
|
||||
assert.deepEqual(result.received, Buffer.concat([setupReply(), withoutPresent(wrapped), stale, withoutPresent(hidden)]));
|
||||
});
|
||||
|
||||
test("setup failures and truncated headers flush unchanged", async () => {
|
||||
for (const status of [0, 2]) {
|
||||
const downlink = setupReply("B", status);
|
||||
const result = await exchange(setupRequest("B"), downlink, { chunkSize: 1 });
|
||||
assert.deepEqual(result.received, downlink);
|
||||
}
|
||||
const shortSetup = await exchange(setupRequest(), setupReply().subarray(0, 5), { chunkSize: 1 });
|
||||
assert.deepEqual(shortSetup.received, setupReply().subarray(0, 5));
|
||||
const partial = Buffer.concat([setupReply(), reply(1).subarray(0, 7)]);
|
||||
const result = await exchange(Buffer.concat([setupRequest(), request("DRI3")]), partial, { chunkSize: 1 });
|
||||
assert.deepEqual(result.received, partial);
|
||||
});
|
||||
|
||||
test("non-X11 payloads pass through unchanged", async () => {
|
||||
const result = await exchange(Buffer.from("remote"), Buffer.from("local"), { chunkSize: 1 });
|
||||
assert.equal(result.received.toString(), "local");
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const { Transform } = require("node:stream");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { createX11FdGuard } = require("./x11FdGuard.cjs");
|
||||
|
||||
const X11_PORT_BASE = 6000;
|
||||
const MIT_MAGIC_COOKIE_PROTOCOL = "MIT-MAGIC-COOKIE-1";
|
||||
@@ -253,11 +254,16 @@ function attachX11Forwarding(conn, options = {}) {
|
||||
const target = resolveX11DisplaySpec(display, { platform });
|
||||
const localSocket = createSocket();
|
||||
let acceptedChannel = null;
|
||||
let guard = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (acceptedChannel) destroyStream(acceptedChannel);
|
||||
destroyStream(localSocket);
|
||||
if (guard) {
|
||||
destroyStream(guard.toServer);
|
||||
destroyStream(guard.toClient);
|
||||
}
|
||||
};
|
||||
|
||||
localSocket.once("connect", () => {
|
||||
@@ -275,15 +281,19 @@ function attachX11Forwarding(conn, options = {}) {
|
||||
localSocket.on("error", () => cleanup());
|
||||
acceptedChannel.on("close", () => destroyStream(localSocket));
|
||||
localSocket.on("close", () => destroyStream(acceptedChannel));
|
||||
guard = createX11FdGuard({ hiddenExtensions: options.hiddenExtensions, logger });
|
||||
guard.toServer.on("error", cleanup);
|
||||
guard.toClient.on("error", cleanup);
|
||||
const realCookie = resolveLocalAuthCookie();
|
||||
if (realCookie && fakeCookie) {
|
||||
acceptedChannel
|
||||
.pipe(createX11AuthTransform({ fakeCookie, realCookie }))
|
||||
.pipe(localSocket)
|
||||
.pipe(acceptedChannel);
|
||||
.pipe(guard.toServer)
|
||||
.pipe(localSocket);
|
||||
} else {
|
||||
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
|
||||
acceptedChannel.pipe(guard.toServer).pipe(localSocket);
|
||||
}
|
||||
localSocket.pipe(guard.toClient).pipe(acceptedChannel);
|
||||
});
|
||||
|
||||
localSocket.once("error", (err) => {
|
||||
|
||||
@@ -181,6 +181,7 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
|
||||
attachX11Forwarding(conn, {
|
||||
display: ":0",
|
||||
createSocket: () => localSocket,
|
||||
readLocalAuthCookie: () => null,
|
||||
sendMessage: (message) => messages.push(message),
|
||||
platform: "linux",
|
||||
});
|
||||
@@ -203,6 +204,79 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
|
||||
assert.deepEqual(messages, []);
|
||||
});
|
||||
|
||||
for (const rewriteCookie of [false, true]) {
|
||||
test(`attachX11Forwarding hides fd extensions with cookie rewriting ${rewriteCookie ? "enabled" : "disabled"}`, async (t) => {
|
||||
const conn = new EventEmitter();
|
||||
const capturedLocal = [];
|
||||
const capturedRemote = [];
|
||||
const warnings = [];
|
||||
const makeDuplex = (captured) => new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
captured.push(Buffer.from(chunk));
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const localSocket = makeDuplex(capturedLocal);
|
||||
const acceptedChannel = makeDuplex(capturedRemote);
|
||||
t.after(() => {
|
||||
localSocket.destroy();
|
||||
acceptedChannel.destroy();
|
||||
});
|
||||
localSocket.connect = () => {
|
||||
queueMicrotask(() => localSocket.emit("connect"));
|
||||
return localSocket;
|
||||
};
|
||||
const fakeCookie = "11111111111111111111111111111111";
|
||||
const realCookie = "22222222222222222222222222222222";
|
||||
attachX11Forwarding(conn, {
|
||||
display: ":0",
|
||||
platform: "linux",
|
||||
createSocket: () => localSocket,
|
||||
fakeCookie: rewriteCookie ? fakeCookie : undefined,
|
||||
readLocalAuthCookie: () => rewriteCookie ? Buffer.from(realCookie, "hex") : null,
|
||||
logger: { warn: (message) => warnings.push(message) },
|
||||
});
|
||||
conn.emit("x11", {}, () => acceptedChannel, () => assert.fail("unexpected reject"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const setup = buildX11SetupPacket({ cookie: fakeCookie });
|
||||
const queries = ["DRI3", "MIT-SHM", "GLX"].map((name) => {
|
||||
const packet = Buffer.alloc(8 + Math.ceil(name.length / 4) * 4);
|
||||
packet[0] = 98;
|
||||
packet.writeUInt16LE(packet.length / 4, 2);
|
||||
packet.writeUInt16LE(name.length, 4);
|
||||
packet.write(name, 8, "ascii");
|
||||
return packet;
|
||||
});
|
||||
// Fragment the auth packet, coalescing its tail with the first requests.
|
||||
acceptedChannel.push(setup.subarray(0, 7));
|
||||
acceptedChannel.push(Buffer.concat([setup.subarray(7), ...queries]));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(Buffer.concat(capturedLocal), Buffer.concat([
|
||||
buildX11SetupPacket({ cookie: rewriteCookie ? realCookie : fakeCookie }), ...queries,
|
||||
]));
|
||||
|
||||
const setupReply = Buffer.from([1, 0, 11, 0, 0, 0, 0, 0]);
|
||||
const replies = queries.map((_, index) => {
|
||||
const packet = Buffer.alloc(32);
|
||||
packet[0] = 1;
|
||||
packet.writeUInt16LE(index + 1, 2);
|
||||
packet[8] = 1;
|
||||
return packet;
|
||||
});
|
||||
const response = Buffer.concat([setupReply, ...replies]);
|
||||
for (const byte of response) localSocket.push(Buffer.from([byte]));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const expected = Buffer.from(response);
|
||||
expected[8 + 8] = 0;
|
||||
expected[8 + 32 + 8] = 0;
|
||||
assert.deepEqual(Buffer.concat(capturedRemote), expected);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /^\[X11\] hiding DRI3/);
|
||||
});
|
||||
}
|
||||
|
||||
test("attachX11Forwarding rejects the remote channel and explains local display failures", async () => {
|
||||
const conn = new EventEmitter();
|
||||
const localSocket = new EventEmitter();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Pick Chromium's Linux password store before app.whenReady().
|
||||
*
|
||||
* Chromium sniffs XDG_CURRENT_DESKTOP and only enables gnome-libsecret /
|
||||
* kwallet for GNOME-family and KDE/Plasma names. Other compositors
|
||||
* (niri, sway, Hyprland, …) fall through to BASIC_TEXT even when
|
||||
* Secret Service is running, so safeStorage.isEncryptionAvailable()
|
||||
* is false (#4189).
|
||||
*
|
||||
* Must run before app.whenReady(); Electron ignores --password-store
|
||||
* afterwards.
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const SECRET_SERVICE_NAME = "org.freedesktop.secrets";
|
||||
const PASSWORD_STORE_SWITCH = "password-store";
|
||||
const GNOME_LIBSECRET = "gnome-libsecret";
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
const SECRET_SERVICE_PROBE_COMMANDS = [
|
||||
{
|
||||
file: "busctl",
|
||||
args: [
|
||||
"--user",
|
||||
"call",
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"NameHasOwner",
|
||||
"s",
|
||||
SECRET_SERVICE_NAME,
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "gdbus",
|
||||
args: [
|
||||
"call",
|
||||
"--session",
|
||||
"--dest",
|
||||
"org.freedesktop.DBus",
|
||||
"--object-path",
|
||||
"/org/freedesktop/DBus",
|
||||
"--method",
|
||||
"org.freedesktop.DBus.NameHasOwner",
|
||||
SECRET_SERVICE_NAME,
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "dbus-send",
|
||||
args: [
|
||||
"--session",
|
||||
"--print-reply=literal",
|
||||
"--dest=org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus.NameHasOwner",
|
||||
`string:${SECRET_SERVICE_NAME}`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function desktopTokens(env = process.env) {
|
||||
const raw = [env?.XDG_CURRENT_DESKTOP, env?.XDG_SESSION_DESKTOP]
|
||||
.filter((value) => typeof value === "string" && value.length > 0)
|
||||
.join(":");
|
||||
return raw
|
||||
.split(":")
|
||||
.map((token) => token.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isKdePlasmaDesktop(env = process.env) {
|
||||
return desktopTokens(env).some((token) => token === "kde" || token === "plasma");
|
||||
}
|
||||
|
||||
function parseNameHasOwnerOutput(stdout) {
|
||||
const text = String(stdout ?? "");
|
||||
if (/\bfalse\b/i.test(text)) return false;
|
||||
if (/\btrue\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function defaultProbeSecretService(options = {}) {
|
||||
const run = options.execFileSync ?? execFileSync;
|
||||
const timeout = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
for (const command of SECRET_SERVICE_PROBE_COMMANDS) {
|
||||
try {
|
||||
const stdout = run(command.file, command.args, {
|
||||
encoding: "utf8",
|
||||
timeout,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return parseNameHasOwnerOutput(stdout);
|
||||
} catch {
|
||||
// Missing binary, timeout, or D-Bus error: try the next helper.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyLinuxPasswordStore(app, options = {}) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== "linux") return false;
|
||||
if (!app?.commandLine) return false;
|
||||
|
||||
const env = options.env ?? process.env;
|
||||
const hasSwitch = options.hasSwitch
|
||||
?? ((name) => Boolean(app.commandLine.hasSwitch?.(name)));
|
||||
const appendSwitch = options.appendSwitch
|
||||
?? ((name, value) => app.commandLine.appendSwitch(name, value));
|
||||
|
||||
if (hasSwitch(PASSWORD_STORE_SWITCH)) return false;
|
||||
if (isKdePlasmaDesktop(env)) return false;
|
||||
|
||||
let available = false;
|
||||
try {
|
||||
const probe = options.probeSecretService ?? defaultProbeSecretService;
|
||||
available = Boolean(probe({
|
||||
execFileSync: options.execFileSync,
|
||||
timeoutMs: options.timeoutMs,
|
||||
}));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!available) return false;
|
||||
|
||||
appendSwitch(PASSWORD_STORE_SWITCH, GNOME_LIBSECRET);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SECRET_SERVICE_NAME,
|
||||
PASSWORD_STORE_SWITCH,
|
||||
GNOME_LIBSECRET,
|
||||
applyLinuxPasswordStore,
|
||||
isKdePlasmaDesktop,
|
||||
parseNameHasOwnerOutput,
|
||||
defaultProbeSecretService,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
GNOME_LIBSECRET,
|
||||
PASSWORD_STORE_SWITCH,
|
||||
applyLinuxPasswordStore,
|
||||
defaultProbeSecretService,
|
||||
isKdePlasmaDesktop,
|
||||
parseNameHasOwnerOutput,
|
||||
} = require("./linuxPasswordStore.cjs");
|
||||
|
||||
function createApp({ existingSwitch = false } = {}) {
|
||||
const appended = [];
|
||||
return {
|
||||
appended,
|
||||
app: {
|
||||
commandLine: {
|
||||
hasSwitch(name) {
|
||||
return existingSwitch && name === PASSWORD_STORE_SWITCH;
|
||||
},
|
||||
appendSwitch(name, value) {
|
||||
appended.push([name, value]);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function apply(overrides = {}, appOptions = {}) {
|
||||
const { app, appended } = createApp(appOptions);
|
||||
const applied = applyLinuxPasswordStore(app, {
|
||||
platform: "linux",
|
||||
env: { XDG_CURRENT_DESKTOP: "niri" },
|
||||
probeSecretService: () => true,
|
||||
...overrides,
|
||||
});
|
||||
return { applied, appended };
|
||||
}
|
||||
|
||||
test("niri with Secret Service appends gnome-libsecret", () => {
|
||||
const { applied, appended } = apply({
|
||||
env: { XDG_CURRENT_DESKTOP: "niri" },
|
||||
});
|
||||
assert.equal(applied, true);
|
||||
assert.deepEqual(appended, [[PASSWORD_STORE_SWITCH, GNOME_LIBSECRET]]);
|
||||
});
|
||||
|
||||
test("sway and Hyprland with Secret Service append gnome-libsecret", () => {
|
||||
for (const desktop of ["sway", "Hyprland"]) {
|
||||
const { applied, appended } = apply({
|
||||
env: { XDG_CURRENT_DESKTOP: desktop },
|
||||
});
|
||||
assert.equal(applied, true, desktop);
|
||||
assert.deepEqual(appended, [[PASSWORD_STORE_SWITCH, GNOME_LIBSECRET]], desktop);
|
||||
}
|
||||
});
|
||||
|
||||
test("KDE and Plasma desktops do not append even when Secret Service is present", () => {
|
||||
for (const env of [
|
||||
{ XDG_CURRENT_DESKTOP: "KDE" },
|
||||
{ XDG_CURRENT_DESKTOP: "plasma" },
|
||||
{ XDG_CURRENT_DESKTOP: "ubuntu:KDE" },
|
||||
{ XDG_SESSION_DESKTOP: "plasma" },
|
||||
]) {
|
||||
const { applied, appended } = apply({ env });
|
||||
assert.equal(applied, false, JSON.stringify(env));
|
||||
assert.deepEqual(appended, [], JSON.stringify(env));
|
||||
}
|
||||
});
|
||||
|
||||
test("existing --password-store switch is left alone", () => {
|
||||
const { applied, appended } = apply({}, { existingSwitch: true });
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(appended, []);
|
||||
});
|
||||
|
||||
test("absent, throwing, or empty Secret Service probe does not append", () => {
|
||||
for (const probeSecretService of [
|
||||
() => false,
|
||||
() => {
|
||||
throw new Error("probe failed");
|
||||
},
|
||||
]) {
|
||||
const { applied, appended } = apply({ probeSecretService });
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(appended, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("non-Linux platforms do not append", () => {
|
||||
for (const platform of ["darwin", "win32"]) {
|
||||
const { applied, appended } = apply({ platform });
|
||||
assert.equal(applied, false, platform);
|
||||
assert.deepEqual(appended, [], platform);
|
||||
}
|
||||
});
|
||||
|
||||
test("isKdePlasmaDesktop splits colon-separated desktop names", () => {
|
||||
assert.equal(isKdePlasmaDesktop({ XDG_CURRENT_DESKTOP: "niri" }), false);
|
||||
assert.equal(isKdePlasmaDesktop({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" }), false);
|
||||
assert.equal(isKdePlasmaDesktop({ XDG_CURRENT_DESKTOP: "ubuntu:KDE" }), true);
|
||||
assert.equal(isKdePlasmaDesktop({ XDG_SESSION_DESKTOP: "Plasma" }), true);
|
||||
});
|
||||
|
||||
test("parseNameHasOwnerOutput understands busctl, gdbus, and dbus-send", () => {
|
||||
assert.equal(parseNameHasOwnerOutput("b true\n"), true);
|
||||
assert.equal(parseNameHasOwnerOutput("b false\n"), false);
|
||||
assert.equal(parseNameHasOwnerOutput("(true,)"), true);
|
||||
assert.equal(parseNameHasOwnerOutput("(false,)"), false);
|
||||
assert.equal(parseNameHasOwnerOutput("boolean true"), true);
|
||||
assert.equal(parseNameHasOwnerOutput(" boolean false"), false);
|
||||
assert.equal(parseNameHasOwnerOutput(""), false);
|
||||
});
|
||||
|
||||
test("defaultProbeSecretService falls through missing binaries and reads the first reply", () => {
|
||||
const calls = [];
|
||||
const execFileSync = (file, args) => {
|
||||
calls.push(file);
|
||||
if (file === "busctl") {
|
||||
const err = new Error("not found");
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
if (file === "gdbus") return "(true,)\n";
|
||||
throw new Error(`unexpected ${file}`);
|
||||
};
|
||||
assert.equal(defaultProbeSecretService({ execFileSync }), true);
|
||||
assert.deepEqual(calls, ["busctl", "gdbus"]);
|
||||
});
|
||||
|
||||
test("defaultProbeSecretService is false when every helper fails", () => {
|
||||
assert.equal(defaultProbeSecretService({
|
||||
execFileSync() {
|
||||
throw new Error("missing");
|
||||
},
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("main.cjs applies the helper before app.whenReady()", () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
const helperIdx = source.indexOf('require("./linuxPasswordStore.cjs")');
|
||||
const callIdx = source.indexOf("applyLinuxPasswordStore(app)");
|
||||
const readyIdx = source.indexOf("app.whenReady()");
|
||||
assert.ok(helperIdx >= 0, "requires linuxPasswordStore.cjs");
|
||||
assert.ok(callIdx >= 0, "calls applyLinuxPasswordStore(app)");
|
||||
assert.ok(readyIdx >= 0, "has app.whenReady()");
|
||||
assert.ok(helperIdx < readyIdx, "require is before whenReady");
|
||||
assert.ok(callIdx < readyIdx, "call is before whenReady");
|
||||
});
|
||||
@@ -255,6 +255,12 @@ if (process.env.NETCATTY_COMPAT_GPU !== "1") {
|
||||
app.commandLine.appendSwitch("enable-zero-copy");
|
||||
}
|
||||
|
||||
// Linux: Chromium only auto-selects gnome-libsecret / kwallet from
|
||||
// GNOME/KDE desktop names. Other compositors (niri/sway/Hyprland) fall
|
||||
// through to BASIC_TEXT even when Secret Service is running (#4189).
|
||||
const { applyLinuxPasswordStore } = require("./linuxPasswordStore.cjs");
|
||||
applyLinuxPasswordStore(app);
|
||||
|
||||
// Silence noisy DevTools Autofill CDP errors (Electron's backend doesn't expose this domain)
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
if (contents.getType() !== "devtools") return;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
appimageTools,
|
||||
fetchurl,
|
||||
lib,
|
||||
openssh,
|
||||
release ? import ./release.nix,
|
||||
stdenv,
|
||||
writeShellScriptBin,
|
||||
}:
|
||||
|
||||
let
|
||||
@@ -22,10 +24,42 @@ let
|
||||
appimageContents = appimageTools.extractType2 {
|
||||
inherit pname version src;
|
||||
};
|
||||
|
||||
# home-manager's ~/.ssh/config is a root-owned symlink into the nix store.
|
||||
# Unprivileged bwrap (wrapType2 / buildFHSEnv) maps only the caller uid, so
|
||||
# that file appears as overflowuid 65534 and OpenSSH refuses the default
|
||||
# user config with "Bad owner or permissions". A -F path skips that check.
|
||||
# The wrapper is injected via extraPkgs so it lands on the FHS /usr/bin,
|
||||
# which buildFHSEnv prepends ahead of any bind-mounted host profile PATH.
|
||||
sshWrapped = writeShellScriptBin "ssh" ''
|
||||
real_ssh="${openssh}/bin/ssh"
|
||||
config="''${HOME}/.ssh/config"
|
||||
if [ -e "$config" ]; then
|
||||
owner="$(stat -c %u -L "$config" 2>/dev/null || true)"
|
||||
me="$(id -u)"
|
||||
if [ -n "$owner" ] && [ "$owner" != "$me" ] && [ "$owner" != 0 ]; then
|
||||
cache="''${XDG_CACHE_HOME:-$HOME/.cache}/netcatty"
|
||||
snap="$cache/ssh_config"
|
||||
mkdir -p "$cache"
|
||||
# Always refresh: nix store files have frozen old mtimes, so -nt
|
||||
# never sees a newer home-manager generation after the first copy.
|
||||
# Publish via a unique staging file + mv so a concurrent ssh -F
|
||||
# reader never sees a truncated snapshot (cp truncates first).
|
||||
snap_tmp="$snap.tmp.$$"
|
||||
cp -L "$config" "$snap_tmp"
|
||||
chmod 600 "$snap_tmp"
|
||||
mv -f "$snap_tmp" "$snap"
|
||||
exec "$real_ssh" -F "$snap" "$@"
|
||||
fi
|
||||
fi
|
||||
exec "$real_ssh" "$@"
|
||||
'';
|
||||
in
|
||||
appimageTools.wrapType2 {
|
||||
inherit pname version src;
|
||||
|
||||
extraPkgs = pkgs: [ sshWrapped ];
|
||||
|
||||
extraInstallCommands = ''
|
||||
desktopSource="$(find ${appimageContents} -maxdepth 3 -name '*.desktop' -print -quit)"
|
||||
if [ -n "$desktopSource" ]; then
|
||||
|
||||
+2
-1
@@ -111,7 +111,7 @@ export default defineConfig(() => {
|
||||
build: {
|
||||
// The shared runtime is also required by Electron; convert it for browser builds.
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
|
||||
include: [/node_modules/, /domain\/sshPublicKey\.shared\.cjs$/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
|
||||
},
|
||||
chunkSizeWarningLimit: 3000,
|
||||
target: 'esnext', // Required for top-level await in WASM modules
|
||||
@@ -134,6 +134,7 @@ export default defineConfig(() => {
|
||||
include: [
|
||||
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
|
||||
'@/domain/systemManager/containerRuntime.shared.cjs',
|
||||
'@/domain/sshPublicKey.shared.cjs',
|
||||
'@/domain/pluginRuntimePolicy.cjs',
|
||||
'@/domain/pluginGitHubRepository.cjs',
|
||||
'@/domain/pluginPermissionDescriptions.cjs',
|
||||
|
||||
Reference in new issue
Block a user