diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index be3b0fe88..539b73a67 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -16,7 +16,7 @@ import { materializeEmbeddableVectorLayers, } from "@geolibre/plugins"; import type { FeatureCollection } from "geojson"; -import { type FormEvent, useRef, useState } from "react"; +import { type FormEvent, useCallback, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createAppAPI, getPluginManager } from "./usePlugins"; import { pluginManifestUrlsForIds } from "../lib/external-plugins"; @@ -44,6 +44,13 @@ import { resolveShareBaseUrl } from "../lib/share-geolibre"; import { shareAuthorizedFetch } from "../lib/share-gallery"; import { normalizeProjectUrl } from "../lib/urls"; import { recordExplicitProjectSave } from "../lib/project-history-session"; +import { + rememberProjectSaveChoices, + reusableCredentialChoice, + reusableVectorDataChoice, + saveChoicesForProject, + type ProjectSaveChoices, +} from "../lib/project-save-choices"; import { resolveProjectXyzLayers } from "../lib/xyz-url"; import { importQgisProject, @@ -56,6 +63,8 @@ import type { MapControllerRef } from "../components/layout/toolbar/constants"; /** A pending "strip credentials before saving?" prompt. */ export interface CredentialStripPrompt { count: number; + /** Project generation that opened the prompt. */ + projectGeneration: number; resolve: (choice: "strip" | "keep" | "cancel") => void; } @@ -103,6 +112,8 @@ export interface EmbedVectorDataPrompt { * described differently than on the web (where it discards the data). */ desktop: boolean; + /** Project generation that opened the prompt. */ + projectGeneration: number; resolve: (choice: "embed" | "noembed" | "cancel") => void; } @@ -113,6 +124,8 @@ export interface EmbedVectorDataPrompt { * same component serves both. */ export interface SaveNamePrompt { + /** Project generation that opened the prompt. */ + projectGeneration: number; resolve: (name: string | null) => void; /** Dialog title. */ title: string; @@ -239,6 +252,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const rememberRecentProject = useAppStore((s) => s.rememberRecentProject); const forgetRecentProject = useAppStore((s) => s.forgetRecentProject); const markSaved = useAppStore((s) => s.markSaved); + const projectGeneration = useAppStore((s) => s.projectGeneration); const [actionError, setActionError] = useState(null); const [qgisImportWarnings, setQgisImportWarnings] = useState( @@ -264,11 +278,66 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // Separate from projectUrlAbortRef so a gallery open and an Open-from-URL // submit can't abort each other's in-flight fetch. const shareUrlAbortRef = useRef(null); + // Retain explicit, non-cancel save decisions for this project only. The + // generation check clears them synchronously when newProject/loadProject + // switches the store, including before React has rendered the new project. + const saveChoicesRef = useRef(null); // Guards against overlapping saves: a second save started while a prompt // dialog is open would overwrite the pending prompt and strand the first // call's unresolved promise. const isSavingRef = useRef(false); + // Settling a prompt means resolving its promise and clearing the dialog + // state. Each pattern lives here once so the dialog handlers further down and + // the generation-change cancellation below cannot drift apart. They close over + // nothing but their setters, so their identity is stable and the effect below + // still re-runs only when a prompt or the generation changes. + const settleCredentialStripPrompt = useCallback( + (prompt: CredentialStripPrompt | null, choice: "strip" | "keep" | "cancel") => { + // Resolve outside the state updater (updaters must be side-effect free). + prompt?.resolve(choice); + setCredentialStripPrompt(null); + }, + [], + ); + const settleEmbedVectorDataPrompt = useCallback( + (prompt: EmbedVectorDataPrompt | null, choice: "embed" | "noembed" | "cancel") => { + prompt?.resolve(choice); + setEmbedVectorDataPrompt(null); + }, + [], + ); + const settleSaveNamePrompt = useCallback((prompt: SaveNamePrompt | null, name: string | null) => { + prompt?.resolve(name); + setSaveNamePrompt(null); + setSaveNameInput(""); + }, []); + + // A project can be replaced by an external open action while a modal save + // prompt is visible. Cancel the stale promise immediately so its dialog does + // not cover the replacement project and its save guard is released. + // useLayoutEffect (not useEffect) so the stale dialog is gone in the same + // commit that swapped the project, rather than lingering for one paint. + useLayoutEffect(() => { + if (credentialStripPrompt && credentialStripPrompt.projectGeneration !== projectGeneration) { + settleCredentialStripPrompt(credentialStripPrompt, "cancel"); + } + if (embedVectorDataPrompt && embedVectorDataPrompt.projectGeneration !== projectGeneration) { + settleEmbedVectorDataPrompt(embedVectorDataPrompt, "cancel"); + } + if (saveNamePrompt && saveNamePrompt.projectGeneration !== projectGeneration) { + settleSaveNamePrompt(saveNamePrompt, null); + } + }, [ + credentialStripPrompt, + embedVectorDataPrompt, + projectGeneration, + saveNamePrompt, + settleCredentialStripPrompt, + settleEmbedVectorDataPrompt, + settleSaveNamePrompt, + ]); + const handleOpenFromFile = async () => { const result = await openProjectFile(); if (result) { @@ -724,28 +793,34 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // Ask whether to strip credentials (environment variables, geocoder keys, // layer tokens) before writing the file. The promise resolves when the user // picks an option in the dialog. - const askStripCredentials = (count: number) => + const askStripCredentials = (count: number, promptProjectGeneration: number) => new Promise<"strip" | "keep" | "cancel">((resolve) => { - setCredentialStripPrompt({ count, resolve }); + setCredentialStripPrompt({ count, projectGeneration: promptProjectGeneration, resolve }); }); - const resolveCredentialStripPrompt = (choice: "strip" | "keep" | "cancel") => { - // Resolve outside the state updater (updaters must be side-effect free). - credentialStripPrompt?.resolve(choice); - setCredentialStripPrompt(null); - }; + const resolveCredentialStripPrompt = (choice: "strip" | "keep" | "cancel") => + settleCredentialStripPrompt(credentialStripPrompt, choice); // Ask whether to embed local vector layers' data in the saved file. Resolves // when the user picks an option in the dialog. - const askEmbedVectorData = (count: number, bytes: number, desktop: boolean) => + const askEmbedVectorData = ( + count: number, + bytes: number, + desktop: boolean, + promptProjectGeneration: number, + ) => new Promise<"embed" | "noembed" | "cancel">((resolve) => { - setEmbedVectorDataPrompt({ count, bytes, desktop, resolve }); + setEmbedVectorDataPrompt({ + count, + bytes, + desktop, + projectGeneration: promptProjectGeneration, + resolve, + }); }); - const resolveEmbedVectorDataPrompt = (choice: "embed" | "noembed" | "cancel") => { - embedVectorDataPrompt?.resolve(choice); - setEmbedVectorDataPrompt(null); - }; + const resolveEmbedVectorDataPrompt = (choice: "embed" | "noembed" | "cancel") => + settleEmbedVectorDataPrompt(embedVectorDataPrompt, choice); // Builds the embed-mode layers: every local vector layer carries its own // features so the project is self-contained (portable to another machine or @@ -810,13 +885,55 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const resolveLayersForSave = async (): Promise<{ layers?: GeoLibreLayer[] } | "cancel"> => { const state = useAppStore.getState(); const embeddable = await materializeEmbeddableVectorLayers(state.layers); + if (useAppStore.getState().projectGeneration !== state.projectGeneration) return "cancel"; const localFileLayers = isTauri() ? state.layers.filter(isReloadableLocalFileLayer) : []; if (embeddable.size === 0 && localFileLayers.length === 0) return {}; const count = embeddable.size + localFileLayers.length; const bytes = estimateEmbedBytes(state.layers, embeddable); - const choice = await askEmbedVectorData(count, bytes, isTauri()); + const remembered = saveChoicesForProject(saveChoicesRef.current, state.projectGeneration); + saveChoicesRef.current = remembered; + // A remembered Embed choice stays silent until the project crosses the + // large-data warning threshold, and again whenever the data outgrows the + // size that was acknowledged. A remembered Save without data stays silent + // only for the layers whose data the user accepted losing. Both are + // material risks that deserve a fresh confirmation even though the ordinary + // per-project choice is remembered. On desktop that second case cannot + // arise: "without data" writes file references, so nothing is discarded. + const discardedLayerIds = isTauri() ? [] : [...embeddable.keys()]; + const rememberedVectorChoice = reusableVectorDataChoice(remembered, { + embedBytes: bytes, + warningBytes: LARGE_EMBED_WARNING_BYTES, + discardedLayerIds, + }); + const choice = + rememberedVectorChoice ?? + (await askEmbedVectorData(count, bytes, isTauri(), state.projectGeneration)); if (choice === "cancel") return "cancel"; + // A project can be opened while a prompt is visible. Do not apply that + // prompt's answer to the replacement project or continue saving stale data. + if (useAppStore.getState().projectGeneration !== state.projectGeneration) return "cancel"; + saveChoicesRef.current = rememberProjectSaveChoices( + saveChoicesRef.current, + state.projectGeneration, + { + vectorData: choice, + // Only a size the user was actually shown extends the allowance; a + // silent reuse must not ratchet it up (or down) on its own. + acknowledgedEmbedBytes: + rememberedVectorChoice === undefined && + choice === "embed" && + bytes >= LARGE_EMBED_WARNING_BYTES + ? bytes + : remembered.acknowledgedEmbedBytes, + // Likewise, only an answered prompt widens the set of layers the user + // has agreed to lose. + discardedVectorLayerIds: + rememberedVectorChoice === undefined && choice === "noembed" + ? discardedLayerIds + : remembered.discardedVectorLayerIds, + }, + ); if (choice === "embed") { // Reuse the map already materialized for the size estimate. @@ -870,30 +987,34 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // the user can control. The caller supplies the dialog copy so the same prompt // serves both project saves and HTML exports. Resolves with the name, or null // if cancelled. - const askSaveName = (defaultName: string, labels: Omit) => + const askSaveName = ( + defaultName: string, + labels: Omit, + promptProjectGeneration: number, + ) => new Promise((resolve) => { setSaveNameInput(defaultName); - setSaveNamePrompt({ resolve, ...labels }); + setSaveNamePrompt({ projectGeneration: promptProjectGeneration, resolve, ...labels }); }); const submitSaveNamePrompt = (event?: FormEvent) => { event?.preventDefault(); - saveNamePrompt?.resolve(saveNameInput); - setSaveNamePrompt(null); - setSaveNameInput(""); + settleSaveNamePrompt(saveNamePrompt, saveNameInput); }; - const cancelSaveNamePrompt = () => { - saveNamePrompt?.resolve(null); - setSaveNamePrompt(null); - setSaveNameInput(""); - }; + const cancelSaveNamePrompt = () => settleSaveNamePrompt(saveNamePrompt, null); const runSaveProject = async (options?: { saveAs?: boolean }): Promise => { + const saveProjectGeneration = useAppStore.getState().projectGeneration; // Offer to embed local vector data (or, on desktop, save file references) // first, so the serialized content below reflects the user's choice. const layersForSave = await resolveLayersForSave(); - if (layersForSave === "cancel") return false; + if ( + layersForSave === "cancel" || + useAppStore.getState().projectGeneration !== saveProjectGeneration + ) { + return false; + } const { project, defaultProjectName, projectPath } = buildCurrentProject( undefined, layersForSave.layers, @@ -905,8 +1026,30 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const projectToEgress = excludeHiddenFieldsFromProject(project); const redacted = redactProjectCredentials(projectToEgress); if (redacted.redactedPaths.length > 0) { - const choice = await askStripCredentials(redacted.redactedCount); + const remembered = saveChoicesForProject(saveChoicesRef.current, saveProjectGeneration); + saveChoicesRef.current = remembered; + const rememberedCredentialChoice = reusableCredentialChoice(remembered, { + fingerprints: redacted.redactedFingerprints, + hasUnfingerprintable: redacted.hasUnfingerprintableCredential, + }); + const choice = + rememberedCredentialChoice ?? + (await askStripCredentials(redacted.redactedCount, saveProjectGeneration)); if (choice === "cancel") return false; + if (useAppStore.getState().projectGeneration !== saveProjectGeneration) return false; + saveChoicesRef.current = rememberProjectSaveChoices( + saveChoicesRef.current, + saveProjectGeneration, + { + credentials: choice, + // Keep covers exactly the credentials the user was asked about, so a + // later save that would write a different secret asks again. + keptCredentialFingerprints: + rememberedCredentialChoice === undefined && choice === "keep" + ? redacted.redactedFingerprints + : remembered.keptCredentialFingerprints, + }, + ); contentToSave = serializeForSave(choice === "strip" ? redacted.project : projectToEgress); } else { contentToSave = serializeForSave(projectToEgress); @@ -923,15 +1066,20 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const promptForName = browserSaveFallsBackToDownload() && (options?.saveAs === true || !existingLocalPath); if (promptForName) { - const chosen = await askSaveName(saveName, { - title: t("toolbar.item.saveProjectAsTitle"), - description: t("toolbar.item.saveProjectAsDesc"), - label: t("toolbar.item.saveProjectFileName"), - placeholder: t("toolbar.item.saveProjectFileNamePlaceholder"), - }); + const chosen = await askSaveName( + saveName, + { + title: t("toolbar.item.saveProjectAsTitle"), + description: t("toolbar.item.saveProjectAsDesc"), + label: t("toolbar.item.saveProjectFileName"), + placeholder: t("toolbar.item.saveProjectFileNamePlaceholder"), + }, + saveProjectGeneration, + ); if (chosen === null) return false; saveName = ensureProjectFileName(chosen); } + if (useAppStore.getState().projectGeneration !== saveProjectGeneration) return false; let path: string | null; try { path = @@ -949,6 +1097,10 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { return false; } if (!path) return false; + // A native picker can remain open while another project arrives through an + // external action. The old project may have been written successfully, but + // never attach its path or saved state to the replacement project. + if (useAppStore.getState().projectGeneration !== saveProjectGeneration) return false; setProjectPath(path); rememberRecentProject({ path, @@ -980,6 +1132,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { if (isSavingRef.current) return false; isSavingRef.current = true; try { + const exportProjectGeneration = useAppStore.getState().projectGeneration; // Derive the default file name from the project name in the store first, // without materializing embedded data, so the prompt can appear right away // and a cancel discards no work. This snapshot is passed to @@ -999,12 +1152,16 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // saveTextFileWithFallback below instead. let defaultName = `${slug}.html`; if (browserSaveFallsBackToDownload()) { - const chosen = await askSaveName(defaultName, { - title: t("toolbar.item.exportHtmlAsTitle"), - description: t("toolbar.item.exportHtmlAsDesc"), - label: t("toolbar.item.exportHtmlFileName"), - placeholder: t("toolbar.item.exportHtmlFileNamePlaceholder"), - }); + const chosen = await askSaveName( + defaultName, + { + title: t("toolbar.item.exportHtmlAsTitle"), + description: t("toolbar.item.exportHtmlAsDesc"), + label: t("toolbar.item.exportHtmlFileName"), + placeholder: t("toolbar.item.exportHtmlFileNamePlaceholder"), + }, + exportProjectGeneration, + ); if (chosen === null) return false; defaultName = ensureHtmlFileName(chosen, slug); } @@ -1015,6 +1172,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // serve no purpose in a static viewer and are removed inside // buildProjectHtml, which runs the central redaction pass. const { project, defaultProjectName } = await buildEmbeddedProject(projectName); + if (useAppStore.getState().projectGeneration !== exportProjectGeneration) return false; const html = buildProjectHtml({ project, title: defaultProjectName, @@ -1032,6 +1190,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { ], mimeType: "text/html", }); + if (useAppStore.getState().projectGeneration !== exportProjectGeneration) return false; return savedPath !== null; } catch (error) { setActionError( diff --git a/apps/geolibre-desktop/src/lib/project-save-choices.ts b/apps/geolibre-desktop/src/lib/project-save-choices.ts new file mode 100644 index 000000000..20ddd36f8 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/project-save-choices.ts @@ -0,0 +1,152 @@ +/** How credentials should be handled when the current project is saved. */ +export type CredentialSaveChoice = "strip" | "keep"; + +/** How local vector data should be handled when the current project is saved. */ +export type VectorDataSaveChoice = "embed" | "noembed"; + +/** + * How far embedded data may grow past an acknowledged size before the + * large-embed warning is shown again. A project gains features between saves, + * so re-prompting on any growth would defeat the point of remembering the + * choice; doubling is a change of scale the user has not actually agreed to. + */ +export const EMBED_REACKNOWLEDGE_GROWTH_FACTOR = 2; + +/** Save choices remembered for one loaded project during the current session. */ +export interface ProjectSaveChoices { + projectGeneration: number; + credentials?: CredentialSaveChoice; + /** Credential fingerprints covered by the last explicit Keep choice. */ + keptCredentialFingerprints?: readonly string[]; + vectorData?: VectorDataSaveChoice; + /** Embedded size, in bytes, the user accepted after seeing the large-data warning. */ + acknowledgedEmbedBytes?: number; + /** Layers whose data the user accepted discarding with an explicit Save without data. */ + discardedVectorLayerIds?: readonly string[]; +} + +/** What the current save would write, measured against a remembered choice. */ +export interface VectorDataSaveRisk { + /** Estimated size of the data this save would embed. */ + embedBytes: number; + /** Threshold at which the large-embed warning applies. */ + warningBytes: number; + /** + * Ids of local vector layers whose data this save would drop outright. Only + * the web build can lose data this way: on desktop "Save without data" writes + * file references that reload from disk, so nothing is discarded there. + */ + discardedLayerIds: readonly string[]; +} + +/** + * Returns remembered choices only when they belong to the current project. + * + * The store increments `projectGeneration` for every new or loaded project, so + * this keeps potentially sensitive save decisions from leaking into another + * project while allowing repeated saves of the same project to stay silent. + * + * @param remembered - Choices retained by the project-file hook, if any. + * @param projectGeneration - Generation of the currently loaded project. + * @returns Existing choices for this project, or an empty choice set. + */ +export function saveChoicesForProject( + remembered: ProjectSaveChoices | null, + projectGeneration: number, +): ProjectSaveChoices { + return remembered?.projectGeneration === projectGeneration ? remembered : { projectGeneration }; +} + +/** + * Remembers one or more save choices for the current project. + * + * @param remembered - Choices retained by the project-file hook, if any. + * @param projectGeneration - Generation of the currently loaded project. + * @param choices - New choices to retain. + * @returns Updated choices scoped to the supplied project generation. + */ +export function rememberProjectSaveChoices( + remembered: ProjectSaveChoices | null, + projectGeneration: number, + choices: Partial>, +): ProjectSaveChoices { + return { + ...saveChoicesForProject(remembered, projectGeneration), + ...choices, + }; +} + +/** + * Returns a remembered vector-data choice when it still covers what this save + * would do. + * + * An Embed acknowledgement covers the size the user actually saw, plus the + * ordinary growth of a project being edited. Data that balloons past + * {@link EMBED_REACKNOWLEDGE_GROWTH_FACTOR} times that size is a materially + * different write and is confirmed again. + * + * Save without data is reused only for the layers the user accepted losing. + * Dismissing the prompt for one throwaway layer must not silently discard a + * layer added afterwards, which is the one branch here that destroys data. + * + * @param remembered - Choices scoped to the current project. + * @param risk - What the current save would embed or discard. + * @returns The reusable choice, or undefined when the user must confirm again. + */ +export function reusableVectorDataChoice( + remembered: ProjectSaveChoices, + risk: VectorDataSaveRisk, +): VectorDataSaveChoice | undefined { + if (remembered.vectorData === "embed") { + if (risk.embedBytes < risk.warningBytes) return remembered.vectorData; + const acknowledged = remembered.acknowledgedEmbedBytes; + return acknowledged != null && + risk.embedBytes <= acknowledged * EMBED_REACKNOWLEDGE_GROWTH_FACTOR + ? remembered.vectorData + : undefined; + } + if (remembered.vectorData === "noembed") { + if (risk.discardedLayerIds.length === 0) return remembered.vectorData; + const acknowledged = new Set(remembered.discardedVectorLayerIds ?? []); + return risk.discardedLayerIds.every((id) => acknowledged.has(id)) + ? remembered.vectorData + : undefined; + } + return remembered.vectorData; +} + +/** Which credentials the current save would write, from the redaction pass. */ +export interface CredentialSaveRisk { + /** Fingerprints of the credentials this save would keep. */ + fingerprints: readonly string[]; + /** Whether any credential could not be fingerprinted, and so cannot be compared. */ + hasUnfingerprintable: boolean; +} + +/** + * Returns a remembered credential choice when it covers the current risk. + * + * Stripping remains safe however the project changes. Keeping credentials is + * reused only while every credential this save would write is one the user + * explicitly accepted. Fingerprints rather than a count, because swapping one + * credentialed layer for another leaves the count unchanged while putting a + * secret the user never saw on disk. A credential that could not be + * fingerprinted cannot be shown to be unchanged, so it is confirmed again. + * + * @param remembered - Choices scoped to the current project. + * @param risk - The credentials this save would keep. + * @returns The reusable choice, or undefined when Keep must be confirmed again. + */ +export function reusableCredentialChoice( + remembered: ProjectSaveChoices, + risk: CredentialSaveRisk, +): CredentialSaveChoice | undefined { + if (remembered.credentials !== "keep") return remembered.credentials; + if (risk.hasUnfingerprintable) return undefined; + const acknowledged = remembered.keptCredentialFingerprints; + if (acknowledged == null) return undefined; + const covered = new Set(acknowledged); + return risk.fingerprints.every((fingerprint) => covered.has(fingerprint)) + ? remembered.credentials + : undefined; +} diff --git a/packages/core/src/credentials.ts b/packages/core/src/credentials.ts index 3dbc41a1d..2522ba20e 100644 --- a/packages/core/src/credentials.ts +++ b/packages/core/src/credentials.ts @@ -57,10 +57,87 @@ export interface CredentialRedactionResult { project: GeoLibreProject; /** Stable project paths removed or rewritten by the redaction pass. */ redactedPaths: string[]; + /** + * Opaque `path=hash` fingerprints identifying *which* secret sat at each + * redacted path. A caller that remembers an explicit "keep credentials" + * decision compares these between saves, so a different credential, such as + * a swapped layer or rotated token, is confirmed again instead of being + * written to disk under the earlier answer. Paths alone cannot carry that: + * they are positional, so a replacement layer inherits the path of the one it + * replaced. + */ + redactedFingerprints: string[]; + /** + * Whether any redacted value could not be serialized, and so has no entry in + * {@link redactedFingerprints}. Such a value cannot be compared with what the + * user accepted, so a caller reusing a remembered decision must ask again + * rather than assume the unfingerprintable credential is unchanged. + */ + hasUnfingerprintableCredential: boolean; /** Number of individual credential-bearing fields removed or rewritten. */ redactedCount: number; } +/** Collector threaded through the redaction pass. */ +interface RedactionAccumulator { + paths: string[]; + fingerprints: string[]; + count: number; + /** Whether some redacted value could not be serialized, and so not fingerprinted. */ + unfingerprintable: boolean; +} + +/** + * Hash a credential value to a short, stable 64-bit token: two independent + * FNV-1a-style lanes, the first with the canonical FNV prime and the second + * with a MurmurHash3 mixing constant so the lanes do not move together. + * + * The digest never leaves memory and is only ever compared for equality, so it + * needs to be stable and cheap rather than cryptographic. Hashing rather than + * retaining the value keeps the secret itself out of the remembered choices. + * + * A collision costs a *missed* confirmation, not a spurious one: a caller + * comparing fingerprints would treat a different secret as one the user already + * accepted. That is why this is 64 bits rather than 32, and why a value that + * cannot be serialized returns null rather than hashing a lossy `String(value)` + * that would collapse every such value onto `[object Object]`. + * + * @returns The digest, or null when the value cannot be serialized. + */ +function fingerprintValue(value: unknown): string | null { + let serialized: string; + try { + serialized = JSON.stringify(value) ?? String(value); + } catch { + return null; + } + let low = 0x811c9dc5; + let high = 0x27220a95; + for (let index = 0; index < serialized.length; index += 1) { + const code = serialized.charCodeAt(index); + low = Math.imul(low ^ code, 0x01000193); + high = Math.imul(high ^ code, 0x85ebca6b); + } + return `${(low >>> 0).toString(36)}.${(high >>> 0).toString(36)}`; +} + +/** Record one redaction, with the fingerprint of the value being removed. */ +function recordRedaction( + accumulator: RedactionAccumulator, + path: string, + value: unknown, + count = 1, +): void { + const digest = fingerprintValue(value); + accumulator.paths.push(path); + // A value that cannot be serialized fails closed: it is reported through + // `hasUnfingerprintableCredential` rather than fingerprinted, so a caller + // asks again instead of reusing an answer it cannot verify still applies. + if (digest === null) accumulator.unfingerprintable = true; + else accumulator.fingerprints.push(`${path}=${digest}`); + accumulator.count += count; +} + /** * Fold the spellings of one credential name together, so `apiKey`, `api_key`, * `api-key`, and `APIKEY` are a single registry entry on both the object-key @@ -175,29 +252,26 @@ function isGeoJsonPayload(value: Record): boolean { function redactConfigurationValue( value: unknown, path: string, - redactedPaths: string[], - redactedCount: { value: number }, + accumulator: RedactionAccumulator, depth = 0, ): unknown { if (depth >= MAX_REDACT_DEPTH) { // Fail closed. A deeply nested configuration shape is not needed to render // any built-in layer, and returning it unchanged would let a credential // bypass the invariant merely by exceeding the traversal cap. - redactedPaths.push(path); - redactedCount.value += 1; + recordRedaction(accumulator, path, value); return undefined; } if (typeof value === "string") { const redacted = redactUrlCredentials(value); if (redacted !== value) { - redactedPaths.push(path); - redactedCount.value += 1; + recordRedaction(accumulator, path, value); } return redacted; } if (Array.isArray(value)) { return value.map((item, index) => - redactConfigurationValue(item, `${path}[${index}]`, redactedPaths, redactedCount, depth + 1), + redactConfigurationValue(item, `${path}[${index}]`, accumulator, depth + 1), ); } if (!isPlainObject(value)) return value; @@ -207,17 +281,10 @@ function redactConfigurationValue( for (const [key, nested] of Object.entries(value)) { const nestedPath = path ? `${path}.${key}` : key; if (isCredentialFieldName(key)) { - redactedPaths.push(nestedPath); - redactedCount.value += 1; + recordRedaction(accumulator, nestedPath, nested); continue; } - result[key] = redactConfigurationValue( - nested, - nestedPath, - redactedPaths, - redactedCount, - depth + 1, - ); + result[key] = redactConfigurationValue(nested, nestedPath, accumulator, depth + 1); } return result; } @@ -248,15 +315,18 @@ function countLeafValues(value: unknown): number { * themselves. */ export function redactProjectCredentials(project: GeoLibreProject): CredentialRedactionResult { - const redactedPaths: string[] = []; - const redactedCount = { value: 0 }; + const accumulator: RedactionAccumulator = { + paths: [], + fingerprints: [], + count: 0, + unfingerprintable: false, + }; const basemapStyleUrl = typeof project.basemapStyleUrl === "string" ? redactUrlCredentials(project.basemapStyleUrl) : project.basemapStyleUrl; if (basemapStyleUrl !== project.basemapStyleUrl) { - redactedPaths.push("basemapStyleUrl"); - redactedCount.value += 1; + recordRedaction(accumulator, "basemapStyleUrl", project.basemapStyleUrl); } const geocoding = project.preferences?.geocoding ? { ...project.preferences.geocoding, apiKeys: {} } @@ -268,8 +338,7 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe const redacted = redactUrlCredentials(endpoint); if (redacted !== endpoint) { geocoding[field] = redacted; - redactedPaths.push(`preferences.geocoding.${field}`); - redactedCount.value += 1; + recordRedaction(accumulator, `preferences.geocoding.${field}`, endpoint); } } } @@ -277,15 +346,23 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe ? { ...project.preferences, environmentVariables: [], geocoding } : project.preferences; const populatedEnvironmentVariables = - project.preferences?.environmentVariables?.filter((variable) => variable.key.trim()).length ?? - 0; - if (populatedEnvironmentVariables > 0) { - redactedPaths.push("preferences.environmentVariables"); - redactedCount.value += populatedEnvironmentVariables; + project.preferences?.environmentVariables?.filter((variable) => variable.key.trim()) ?? []; + if (populatedEnvironmentVariables.length > 0) { + recordRedaction( + accumulator, + "preferences.environmentVariables", + populatedEnvironmentVariables, + populatedEnvironmentVariables.length, + ); } - if (Object.keys(project.preferences?.geocoding?.apiKeys ?? {}).length > 0) { - redactedPaths.push("preferences.geocoding.apiKeys"); - redactedCount.value += Object.keys(project.preferences?.geocoding?.apiKeys ?? {}).length; + const geocodingApiKeys = project.preferences?.geocoding?.apiKeys ?? {}; + if (Object.keys(geocodingApiKeys).length > 0) { + recordRedaction( + accumulator, + "preferences.geocoding.apiKeys", + geocodingApiKeys, + Object.keys(geocodingApiKeys).length, + ); } const layers = (project.layers ?? []).map((layer, index) => ({ @@ -293,22 +370,19 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe source: redactConfigurationValue( layer.source, `layers[${index}].source`, - redactedPaths, - redactedCount, + accumulator, ) as Record, metadata: redactConfigurationValue( layer.metadata, `layers[${index}].metadata`, - redactedPaths, - redactedCount, + accumulator, ) as Record, ...(typeof layer.sourcePath === "string" ? { sourcePath: redactConfigurationValue( layer.sourcePath, `layers[${index}].sourcePath`, - redactedPaths, - redactedCount, + accumulator, ) as string, } : {}), @@ -321,8 +395,7 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe connection: redactConfigurationValue( layer.connection, `layers[${index}].connection`, - redactedPaths, - redactedCount, + accumulator, ) as LayerConnection, } : {}), @@ -333,8 +406,7 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe const manifestUrls = plugins.manifestUrls.map((url, index) => { const redacted = redactUrlCredentials(url); if (redacted !== url) { - redactedPaths.push(`plugins.manifestUrls[${index}]`); - redactedCount.value += 1; + recordRedaction(accumulator, `plugins.manifestUrls[${index}]`, url); } return redacted; }); @@ -368,20 +440,17 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe if (Object.keys(rest).length > 0) dropped[id] = rest; } if (Object.keys(dropped).length > 0) { - redactedPaths.push("plugins.settings"); - redactedCount.value += countLeafValues(dropped); + recordRedaction(accumulator, "plugins.settings", dropped, countLeafValues(dropped)); } // What survives is still swept by the same pass layer configuration gets, // so a credentialed URL inside a kept blob is scrubbed rather than trusted. plugins = { ...plugins, manifestUrls, - settings: redactConfigurationValue( - kept, - "plugins.settings", - redactedPaths, - redactedCount, - ) as Record, + settings: redactConfigurationValue(kept, "plugins.settings", accumulator) as Record< + string, + unknown + >, }; } @@ -397,17 +466,17 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe ...(plugins ? { plugins } : {}), ...(project.metadata ? { - metadata: redactConfigurationValue( - project.metadata, - "metadata", - redactedPaths, - redactedCount, - ) as Record, + metadata: redactConfigurationValue(project.metadata, "metadata", accumulator) as Record< + string, + unknown + >, } : {}), }, - redactedPaths: [...new Set(redactedPaths)], - redactedCount: redactedCount.value, + redactedPaths: [...new Set(accumulator.paths)], + redactedFingerprints: [...new Set(accumulator.fingerprints)], + hasUnfingerprintableCredential: accumulator.unfingerprintable, + redactedCount: accumulator.count, }; } diff --git a/tests/project-credentials.test.ts b/tests/project-credentials.test.ts index 774fb4bf6..058bf9b0d 100644 --- a/tests/project-credentials.test.ts +++ b/tests/project-credentials.test.ts @@ -206,6 +206,47 @@ describe("project credential redaction", () => { assert.ok(redactedPaths.includes("layers[0].connection.lastError")); }); + it("fingerprints the credential values, not just their paths", () => { + // The save prompt reuses a remembered Keep only while the fingerprints + // match, so a different secret at an unchanged path has to change one. + const original = credentialProject(); + const baseline = redactProjectCredentials(original); + assert.equal(baseline.redactedFingerprints.length, baseline.redactedPaths.length); + assert.deepEqual( + redactProjectCredentials(credentialProject()).redactedFingerprints, + baseline.redactedFingerprints, + ); + assert.ok( + !baseline.redactedFingerprints.some((fingerprint) => fingerprint.includes("header-secret")), + ); + + const rotated = credentialProject(); + ( + rotated.layers[0].source.nested as { headers: { Authorization: string } } + ).headers.Authorization = "Bearer rotated-secret"; + const after = redactProjectCredentials(rotated); + assert.deepEqual(after.redactedPaths, baseline.redactedPaths); + assert.notDeepEqual(after.redactedFingerprints, baseline.redactedFingerprints); + }); + + it("fails closed for a credential it cannot fingerprint", () => { + // A fingerprint match skips the Keep confirmation, so a value that cannot + // be serialized is reported rather than hashed into something that could + // collide with an unrelated one. + const project = credentialProject(); + assert.equal(redactProjectCredentials(project).hasUnfingerprintableCredential, false); + + const circular: Record = {}; + circular.self = circular; + project.layers[0].source = { token: circular }; + const result = redactProjectCredentials(project); + + assert.equal(result.hasUnfingerprintableCredential, true); + assert.ok(result.redactedPaths.includes("layers[0].source.token")); + assert.ok(!result.redactedFingerprints.some((entry) => entry.startsWith("layers[0].source."))); + assert.ok(!serializeProject(result.project).includes("self")); + }); + it("fails closed when configuration exceeds the traversal depth", () => { let nested: Record = { arbitrary: "too-deep-secret" }; for (let index = 0; index < 12; index += 1) nested = { child: nested }; diff --git a/tests/project-save-choices.test.ts b/tests/project-save-choices.test.ts new file mode 100644 index 000000000..cbbbadd01 --- /dev/null +++ b/tests/project-save-choices.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + rememberProjectSaveChoices, + reusableCredentialChoice, + reusableVectorDataChoice, + saveChoicesForProject, +} from "../apps/geolibre-desktop/src/lib/project-save-choices"; + +describe("project save choices", () => { + it("remembers credential and vector-data choices for the current project", () => { + const credentials = rememberProjectSaveChoices(null, 4, { credentials: "strip" }); + const complete = rememberProjectSaveChoices(credentials, 4, { vectorData: "embed" }); + + assert.deepEqual(saveChoicesForProject(complete, 4), { + projectGeneration: 4, + credentials: "strip", + vectorData: "embed", + }); + }); + + it("clears remembered choices when the project generation changes", () => { + const remembered = rememberProjectSaveChoices(null, 4, { + credentials: "keep", + vectorData: "noembed", + acknowledgedEmbedBytes: 60_000_000, + }); + + assert.deepEqual(saveChoicesForProject(remembered, 5), { projectGeneration: 5 }); + }); + + it("does not restore choices from a previously loaded project", () => { + const firstProject = rememberProjectSaveChoices(null, 4, { credentials: "strip" }); + const secondProject = saveChoicesForProject(firstProject, 5); + + assert.deepEqual(saveChoicesForProject(secondProject, 4), { projectGeneration: 4 }); + }); + + it("retains a large-embed warning acknowledgement with the project choices", () => { + const warning = 50_000_000; + const risk = (embedBytes: number) => ({ + embedBytes, + warningBytes: warning, + discardedLayerIds: [], + }); + const vectorChoice = rememberProjectSaveChoices(null, 4, { vectorData: "embed" }); + assert.equal(reusableVectorDataChoice(vectorChoice, risk(1_000)), "embed"); + assert.equal(reusableVectorDataChoice(vectorChoice, risk(warning)), undefined); + + const acknowledged = rememberProjectSaveChoices(vectorChoice, 4, { + acknowledgedEmbedBytes: warning, + }); + + assert.deepEqual(acknowledged, { + projectGeneration: 4, + vectorData: "embed", + acknowledgedEmbedBytes: warning, + }); + assert.equal(reusableVectorDataChoice(acknowledged, risk(warning)), "embed"); + }); + + it("re-warns when embedded data outgrows the acknowledged size", () => { + const risk = (embedBytes: number) => ({ + embedBytes, + warningBytes: 50_000_000, + discardedLayerIds: [], + }); + const acknowledged = rememberProjectSaveChoices(null, 4, { + vectorData: "embed", + acknowledgedEmbedBytes: 51_000_000, + }); + + // Ordinary growth within the acknowledged scale stays silent. + assert.equal(reusableVectorDataChoice(acknowledged, risk(80_000_000)), "embed"); + assert.equal(reusableVectorDataChoice(acknowledged, risk(102_000_000)), "embed"); + // An order-of-magnitude larger write is confirmed again. + assert.equal(reusableVectorDataChoice(acknowledged, risk(500_000_000)), undefined); + }); + + it("reconfirms Save without data for a layer the user has not agreed to lose", () => { + const risk = (discardedLayerIds: string[]) => ({ + embedBytes: 900_000_000, + warningBytes: 50_000_000, + discardedLayerIds, + }); + const noembed = rememberProjectSaveChoices(null, 4, { + vectorData: "noembed", + discardedVectorLayerIds: ["scratch"], + }); + + // The size of data that is never written does not matter. + assert.equal(reusableVectorDataChoice(noembed, risk(["scratch"])), "noembed"); + assert.equal(reusableVectorDataChoice(noembed, risk([])), "noembed"); + // A layer added after the choice would be discarded without ever being + // mentioned, so the prompt comes back. + assert.equal(reusableVectorDataChoice(noembed, risk(["scratch", "survey"])), undefined); + assert.equal(reusableVectorDataChoice(noembed, risk(["survey"])), undefined); + + // Desktop discards nothing (it writes file references), so it stays silent. + const bare = rememberProjectSaveChoices(null, 4, { vectorData: "noembed" }); + assert.equal(reusableVectorDataChoice(bare, risk([])), "noembed"); + assert.equal(reusableVectorDataChoice(bare, risk(["survey"])), undefined); + }); + + it("reconfirms Keep when the project would write a credential the user has not seen", () => { + const risk = (fingerprints: string[]) => ({ fingerprints, hasUnfingerprintable: false }); + const keep = rememberProjectSaveChoices(null, 4, { + credentials: "keep", + keptCredentialFingerprints: ["layers[0].source.token=a1", "layers[1].source.token=b2"], + }); + assert.equal(reusableCredentialChoice(keep, risk(["layers[0].source.token=a1"])), "keep"); + assert.equal( + reusableCredentialChoice( + keep, + risk(["layers[0].source.token=a1", "layers[1].source.token=b2"]), + ), + "keep", + ); + // A third credential was never acknowledged. + assert.equal( + reusableCredentialChoice( + keep, + risk([ + "layers[0].source.token=a1", + "layers[1].source.token=b2", + "layers[2].source.token=c3", + ]), + ), + undefined, + ); + // Swapping one credentialed layer for another leaves the count unchanged, + // but puts a secret on disk that the user never approved. + assert.equal( + reusableCredentialChoice( + keep, + risk(["layers[0].source.token=b2", "layers[1].source.token=c3"]), + ), + undefined, + ); + // A rotated token at an acknowledged path is confirmed again too. + assert.equal(reusableCredentialChoice(keep, risk(["layers[0].source.token=z9"])), undefined); + // So is a credential that could not be fingerprinted at all. + assert.equal( + reusableCredentialChoice(keep, { + fingerprints: ["layers[0].source.token=a1"], + hasUnfingerprintable: true, + }), + undefined, + ); + + // Keep without a recorded acknowledgement cannot cover anything. + const bare = rememberProjectSaveChoices(null, 4, { credentials: "keep" }); + assert.equal(reusableCredentialChoice(bare, risk([])), undefined); + + const strip = rememberProjectSaveChoices(null, 4, { credentials: "strip" }); + assert.equal(reusableCredentialChoice(strip, risk(["basemapStyleUrl=q7"])), "strip"); + }); +});