fix(app): identify Android certificate failures as TLS errors (#7429)

Recognize Android certificate-path validation failures before the generic not-found classification, so Direct connection no longer tells users to debug DNS for an untrusted certificate. Keep the existing TLS copy and technical details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-20 00:53:37 +02:00
committed by GitHub
parent 0eee4fd9b0
commit 0d61cca50c
3 changed files with 173 additions and 90 deletions
+1 -90
View File
@@ -11,10 +11,7 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
invalidConnection: "invalid connection",
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
timedOut: "timeout",
refused: "refused",
hostNotFound: "DNS failure",
hostUnreachable: "unreachable",
tlsError: "TLS failure",
unableToConnect: "connection failure",
};
function failure(message: string) {
return buildConnectionFailureCopy({
endpoint: "tcp://example.test:443?ssl=true",
error: new DaemonConnectionTestError(message, {
reason: "transport error",
lastError: message,
}),
labels,
});
}
describe("direct connection failure copy", () => {
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.tlsError,
raw: `transport error — ${message}`,
});
});
it.each([
["certificate not found", labels.tlsError],
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
["EHOSTUNREACH", labels.hostUnreachable],
["unexpected transport failure", labels.unableToConnect],
])("classifies %s", (message, expected) => {
expect(failure(message).detail).toBe(expected);
});
it("preserves authentication failures and their technical details", () => {
const message = "Invalid daemon password";
expect(
buildConnectionFailureCopy({
endpoint: "example.test:6767",
error: new DaemonConnectionTestError(message, {
reason: message,
lastError: message,
authFailureReason: "incorrect_password",
}),
labels,
}),
).toMatchObject({ detail: message, raw: message });
});
});
@@ -0,0 +1,103 @@
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
interface ConnectionFailureLabels {
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: ConnectionFailureLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
export function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: ConnectionFailureLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
rawLower.includes("trust anchor") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}