From de582294d72d2743a1e313fc342319560c29dde5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:41:58 -0400 Subject: [PATCH 1/4] feat(openapi): add respect-parameter-content setting to type parameters declared with content (#17327) * feat(cli): add respect-parameter-content OpenAPI setting Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(openapi): limit content-based parameter schemas to headers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(internal): drop unrelated generated snapshots Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(openapi): respect parameter content in the v3 importer used by docs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: cade.sarkin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- fern-yml.schema.json | 6 + .../generators-yml/definition/generators.yml | 8 + generators-yml.schema.json | 22 + .../src/3.1/paths/ParameterConverter.ts | 37 +- .../converters/endpoint/convertParameters.ts | 30 +- .../openapi/openapi-ir-parser/src/options.ts | 11 +- .../openapi-ir-in-memory/anyOf.json | 3 +- .../application-json.json | 3 +- .../openapi-ir-in-memory/availability.json | 3 +- .../openapi-ir-in-memory/const.json | 3 +- .../openapi-ir-in-memory/dates.json | 3 +- .../inline-schema-reference.json | 3 +- .../preserve-single-schema-oneof.json | 3 +- .../openapi-ir-in-memory/url-reference.json | 3 +- .../openapi-ir/parameter-content.json | 272 ++++++ .../openapi/parameter-content.json | 173 ++++ .../parameter-content/fern/fern.config.json | 4 + .../parameter-content/fern/generators.yml | 6 + .../fixtures/parameter-content/openapi.yml | 62 ++ .../baseline-sdks/parameter-content.json | 817 ++++++++++++++++++ .../v3-sdks/parameter-content.json | 714 +++++++++++++++ .../parameter-content/fern/fern.config.json | 4 + .../parameter-content/fern/generators.yml | 6 + .../fixtures/parameter-content/openapi.yml | 62 ++ .../src/api/adapter/LegacyApiSpecAdapter.ts | 3 +- .../add-respect-parameter-content-setting.yml | 8 + .../schemas/settings/OpenApiSettingsSchema.ts | 10 +- .../convertGeneratorsConfiguration.ts | 6 +- .../generators-yml/GeneratorsConfiguration.ts | 1 + .../generators/types/OpenApiSettingsSchema.ts | 7 + .../generators/types/OpenApiSettingsSchema.ts | 2 + .../src/openapi/getAPIDefinitionSettings.ts | 3 +- 32 files changed, 2279 insertions(+), 19 deletions(-) create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/parameter-content.json create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/parameter-content.json create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/generators.yml create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/openapi.yml create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/parameter-content.json create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/parameter-content.json create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/generators.yml create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/openapi.yml create mode 100644 packages/cli/cli/changes/unreleased/add-respect-parameter-content-setting.yml diff --git a/fern-yml.schema.json b/fern-yml.schema.json index bd06a6f16571..9a4b28fbfc4a 100644 --- a/fern-yml.schema.json +++ b/fern-yml.schema.json @@ -4037,6 +4037,9 @@ }, "ignore-tags": { "type": "boolean" + }, + "respect-parameter-content": { + "type": "boolean" } }, "additionalProperties": false @@ -4623,6 +4626,9 @@ }, "ignore-tags": { "type": "boolean" + }, + "respect-parameter-content": { + "type": "boolean" } }, "additionalProperties": false diff --git a/fern/apis/generators-yml/definition/generators.yml b/fern/apis/generators-yml/definition/generators.yml index 08e35c1b377e..f9f794c44ee4 100644 --- a/fern/apis/generators-yml/definition/generators.yml +++ b/fern/apis/generators-yml/definition/generators.yml @@ -609,6 +609,14 @@ types: names are derived from each operation's operationId. Defaults to false. default: false + respect-parameter-content: + type: optional + docs: | + If true, header parameters that declare their schema under `content` (e.g. a header + whose value is a JSON-encoded object) are typed from that schema instead of falling + back to a string. + Defaults to false. + default: false ResolveAliases: discriminated: false diff --git a/generators-yml.schema.json b/generators-yml.schema.json index cd1991291d2a..e7c73534e66e 100644 --- a/generators-yml.schema.json +++ b/generators-yml.schema.json @@ -2710,6 +2710,17 @@ } ], "description": "If true, ignore operation-level tags when determining the SDK structure.\nEndpoints fall back to the root package (or their namespace) and method\nnames are derived from each operation's operationId.\nDefaults to false." + }, + "respect-parameter-content": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "If true, header parameters that declare their schema under `content` (e.g. a header\nwhose value is a JSON-encoded object) are typed from that schema instead of falling\nback to a string.\nDefaults to false." } }, "additionalProperties": false @@ -3393,6 +3404,17 @@ ], "description": "If true, ignore operation-level tags when determining the SDK structure.\nEndpoints fall back to the root package (or their namespace) and method\nnames are derived from each operation's operationId.\nDefaults to false." }, + "respect-parameter-content": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "If true, header parameters that declare their schema under `content` (e.g. a header\nwhose value is a JSON-encoded object) are typed from that schema instead of falling\nback to a string.\nDefaults to false." + }, "respect-nullable-schemas": { "oneOf": [ { diff --git a/packages/cli/api-importers/openapi-to-ir/src/3.1/paths/ParameterConverter.ts b/packages/cli/api-importers/openapi-to-ir/src/3.1/paths/ParameterConverter.ts index 6b2b98a25fc1..de8ef4ae205a 100644 --- a/packages/cli/api-importers/openapi-to-ir/src/3.1/paths/ParameterConverter.ts +++ b/packages/cli/api-importers/openapi-to-ir/src/3.1/paths/ParameterConverter.ts @@ -1,3 +1,4 @@ +import { MediaType } from "@fern-api/core-utils"; import { TypeId, TypeReference } from "@fern-api/ir-sdk"; import { Converters } from "@fern-api/v3-importer-commons"; import { OpenAPIV3_1 } from "openapi-types"; @@ -16,13 +17,15 @@ export class ParameterConverter extends Converters.AbstractConverters let typeReference: TypeReference | undefined; let inlinedTypes: Record = {}; - if (this.parameter.schema != null) { + const schema = this.getSchema(); + + if (schema != null) { const schemaIdOverride = this.context.convertBreadcrumbsToName([...this.breadcrumbs, this.parameter.name]); const schemaOrReferenceConverter = new Converters.SchemaConverters.SchemaOrReferenceConverter({ context: this.context, breadcrumbs: [...this.breadcrumbs, this.parameter.name, "schema"], - schemaOrReference: this.parameter.schema, + schemaOrReference: schema, wrapAsOptional: this.parameter.required == null || !this.parameter.required, schemaIdOverride }); @@ -34,9 +37,37 @@ export class ParameterConverter extends Converters.AbstractConverters } return this.convertToOutput({ - schema: this.parameter.schema ?? { type: "string" }, + schema: schema ?? { type: "string" }, typeReference, inlinedTypes }); } + + /** + * Resolves the schema describing the parameter's value. Parameters normally declare `schema` + * directly, but the OpenAPI spec also allows a `content` map for values serialized in a media + * type — most commonly a header holding a JSON-encoded object. + * + * Only headers are resolved from `content`: header values are JSON-encoded when sent, whereas + * an object query parameter is serialized as separate key/value pairs rather than as a single + * JSON-encoded value, which is not what `content: application/json` describes. + */ + private getSchema(): OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject | undefined { + if (this.parameter.schema != null) { + return this.parameter.schema; + } + if ( + !this.context.settings.respectParameterContent || + this.parameter.in !== "header" || + this.parameter.content == null + ) { + return undefined; + } + for (const [contentType, mediaTypeObject] of Object.entries(this.parameter.content)) { + if (mediaTypeObject.schema != null && MediaType.parse(contentType)?.isJSON()) { + return mediaTypeObject.schema; + } + } + return undefined; + } } diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertParameters.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertParameters.ts index 7edd84358d38..a36ff5ea55cc 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertParameters.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertParameters.ts @@ -20,6 +20,7 @@ import { AbstractOpenAPIV3ParserContext } from "../../AbstractOpenAPIV3ParserCon import { FernOpenAPIExtension } from "../../extensions/fernExtensions.js"; import { getParameterName } from "../../extensions/getParameterName.js"; import { getVariableReference } from "../../extensions/getVariableReference.js"; +import { findApplicationJsonRequest } from "./getApplicationJsonSchema.js"; export interface ConvertedParameters { pathParameters: PathParameterWithExample[]; @@ -77,10 +78,12 @@ export function convertParameters({ const [isOptional, isNullable] = context.options.coerceOptionalSchemasToNullable && !isHeader ? [false, !isRequired] : [!isRequired, false]; + const parameterSchema = getParameterSchema(resolvedParameter, context); + let schema = - resolvedParameter.schema != null + parameterSchema != null ? convertSchema( - resolvedParameter.schema, + parameterSchema, isOptional, isNullable, context, @@ -212,6 +215,29 @@ export function convertParameters({ return convertedParameters; } +/** + * Resolves the schema describing a parameter's value. Parameters normally declare `schema` + * directly, but the OpenAPI spec also allows a `content` map for values that are serialized + * in a media type — most commonly a header holding a JSON-encoded object. + * + * Only headers are resolved from `content`: header values are JSON-encoded when sent, whereas + * an object query parameter is serialized as separate key/value pairs rather than as a single + * JSON-encoded value, which is not what `content: application/json` describes. + */ +function getParameterSchema( + parameter: OpenAPIV3.ParameterObject, + context: AbstractOpenAPIV3ParserContext +): OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject | undefined { + if (parameter.schema != null) { + return parameter.schema; + } + if (!context.options.respectParameterContent || parameter.in !== "header" || parameter.content == null) { + return undefined; + } + const jsonMediaType = findApplicationJsonRequest({ content: parameter.content, context }); + return jsonMediaType?.[1].schema; +} + const HEADERS_TO_SKIP = new Set([ "user-agent", "content-length", diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/options.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/options.ts index 4091cd6fbba2..15e481d2308b 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/options.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/options.ts @@ -164,6 +164,14 @@ export interface ParseOpenAPIOptions { * Defaults to false. */ ignoreTags: boolean; + + /** + * If true, header parameters that declare their schema under `content` (e.g. a header + * whose value is a JSON-encoded object) are typed from that schema instead of falling + * back to a string. + * Defaults to false. + */ + respectParameterContent: boolean; } export const DEFAULT_PARSE_OPENAPI_SETTINGS: ParseOpenAPIOptions = { @@ -206,7 +214,8 @@ export const DEFAULT_PARSE_OPENAPI_SETTINGS: ParseOpenAPIOptions = { respectByteFormat: false, shouldInferDiscriminatedUnionBaseProperties: false, disambiguateRequestNames: true, - ignoreTags: false + ignoreTags: false, + respectParameterContent: false }; function mergeOptions(params: { diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/anyOf.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/anyOf.json index db33f5511d7b..4cdf8e098bf7 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/anyOf.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/anyOf.json @@ -111,6 +111,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/application-json.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/application-json.json index 9393ef12326a..e4b08a7a2fd0 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/application-json.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/application-json.json @@ -91,6 +91,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/availability.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/availability.json index 5869fd44b15b..8cfeb1d69ace 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/availability.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/availability.json @@ -422,6 +422,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/const.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/const.json index 91f90c050000..b8b93c3c9c2f 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/const.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/const.json @@ -125,6 +125,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/dates.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/dates.json index a8c2d2a24049..a1ff7cb51225 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/dates.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/dates.json @@ -135,6 +135,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/inline-schema-reference.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/inline-schema-reference.json index 79f0328b26e2..ccc1a66d1b7e 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/inline-schema-reference.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/inline-schema-reference.json @@ -123,6 +123,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/preserve-single-schema-oneof.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/preserve-single-schema-oneof.json index 49190e499b6b..52590bfc80a3 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/preserve-single-schema-oneof.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/preserve-single-schema-oneof.json @@ -90,6 +90,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/url-reference.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/url-reference.json index 52a47775b476..ef171719ef61 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/url-reference.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir-in-memory/url-reference.json @@ -78,6 +78,7 @@ "respectByteFormat": false, "shouldInferDiscriminatedUnionBaseProperties": false, "disambiguateRequestNames": true, - "ignoreTags": false + "ignoreTags": false, + "respectParameterContent": false } } \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/parameter-content.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/parameter-content.json new file mode 100644 index 000000000000..10347676bd0b --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/parameter-content.json @@ -0,0 +1,272 @@ +{ + "specVersion": "1.0.0", + "title": "content-parameters", + "servers": [], + "websocketServers": [], + "tags": { + "tagsById": {} + }, + "hasEndpointsMarkedInternal": false, + "endpoints": [ + { + "audiences": [], + "operationId": "getAccountPhoto", + "tags": [], + "pathParameters": [], + "queryParameters": [], + "headers": [ + { + "description": "JSON-encoded arguments.", + "name": "Api-Arg", + "schema": { + "generatedName": "GetAccountPhotoRequestApiArg", + "schema": "AccountPhotoGetArg", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "generatedRequestName": "GetAccountPhotoRequest", + "response": { + "description": "ok", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "GetAccountPhotoResponse", + "groupName": [], + "type": "primitive" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "GET", + "path": "/account/photo", + "examples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + }, + { + "audiences": [], + "operationId": "getAccountSettings", + "tags": [], + "pathParameters": [], + "queryParameters": [ + { + "name": "Api-Filter", + "schema": { + "generatedName": "GetAccountSettingsRequestApiFilter", + "value": { + "schema": { + "type": "string" + }, + "generatedName": "GetAccountSettingsRequestApiFilter", + "type": "primitive" + }, + "type": "optional" + }, + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "headers": [], + "generatedRequestName": "GetAccountSettingsRequest", + "response": { + "description": "ok", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "GetAccountSettingsResponse", + "groupName": [], + "type": "primitive" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "GET", + "path": "/account/settings", + "examples": [ + { + "pathParameters": [], + "queryParameters": [], + "headers": [], + "response": { + "value": { + "value": { + "value": "string", + "type": "string" + }, + "type": "primitive" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "webhooks": [], + "channels": {}, + "groupedSchemas": { + "rootSchemas": { + "AccountPhotoGetArg": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "accountPhotoGetArgCircleCrop", + "key": "circle_crop", + "schema": { + "generatedName": "AccountPhotoGetArgCircleCrop", + "value": { + "schema": { + "type": "boolean" + }, + "generatedName": "AccountPhotoGetArgCircleCrop", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "accountPhotoGetArgDbxAccountId", + "key": "dbx_account_id", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "AccountPhotoGetArgDbxAccountId", + "groupName": [], + "type": "primitive" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "accountPhotoGetArgExpectAccountPhoto", + "key": "expect_account_photo", + "schema": { + "generatedName": "AccountPhotoGetArgExpectAccountPhoto", + "value": { + "schema": { + "type": "boolean" + }, + "generatedName": "AccountPhotoGetArgExpectAccountPhoto", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "accountPhotoGetArgSize", + "key": "size", + "schema": { + "generatedName": "AccountPhotoGetArgSize", + "value": { + "schema": { + "type": "string" + }, + "generatedName": "AccountPhotoGetArgSize", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "AccountPhotoGetArg", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "AccountSettingsFilter": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "accountSettingsFilterIncludeDeleted", + "key": "include_deleted", + "schema": { + "generatedName": "AccountSettingsFilterIncludeDeleted", + "value": { + "schema": { + "type": "boolean" + }, + "generatedName": "AccountSettingsFilterIncludeDeleted", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "AccountSettingsFilter", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + } + }, + "namespacedSchemas": {} + }, + "variables": {}, + "nonRequestReferencedSchemas": {}, + "securitySchemes": {}, + "globalHeaders": [], + "idempotencyHeaders": [], + "groups": {} +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/parameter-content.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/parameter-content.json new file mode 100644 index 000000000000..94793ce512ee --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/parameter-content.json @@ -0,0 +1,173 @@ +{ + "absoluteFilePath": "/DUMMY_PATH", + "importedDefinitions": {}, + "namedDefinitionFiles": { + "__package__.yml": { + "absoluteFilepath": "/DUMMY_PATH", + "contents": { + "service": { + "auth": false, + "base-path": "", + "endpoints": { + "getAccountPhoto": { + "auth": undefined, + "docs": undefined, + "method": "GET", + "pagination": undefined, + "path": "/account/photo", + "request": { + "headers": { + "Api-Arg": { + "docs": "JSON-encoded arguments.", + "name": "apiArg", + "type": "AccountPhotoGetArg", + }, + }, + "name": "GetAccountPhotoRequest", + }, + "response": { + "docs": "ok", + "status-code": 200, + "type": "string", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "getAccountSettings": { + "auth": undefined, + "docs": undefined, + "examples": [ + { + "response": { + "body": "string", + }, + }, + ], + "method": "GET", + "pagination": undefined, + "path": "/account/settings", + "request": { + "name": "GetAccountSettingsRequest", + "query-parameters": { + "Api-Filter": "optional", + }, + }, + "response": { + "docs": "ok", + "status-code": 200, + "type": "string", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "types": { + "AccountPhotoGetArg": { + "docs": undefined, + "inline": undefined, + "properties": { + "circle_crop": "optional", + "dbx_account_id": "string", + "expect_account_photo": "optional", + "size": "optional", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "AccountSettingsFilter": { + "docs": undefined, + "inline": undefined, + "properties": { + "include_deleted": "optional", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + }, + "rawContents": "service: + auth: false + base-path: '' + endpoints: + getAccountPhoto: + path: /account/photo + method: GET + source: + openapi: ../openapi.yml + request: + name: GetAccountPhotoRequest + headers: + Api-Arg: + type: AccountPhotoGetArg + name: apiArg + docs: JSON-encoded arguments. + response: + docs: ok + type: string + status-code: 200 + getAccountSettings: + path: /account/settings + method: GET + source: + openapi: ../openapi.yml + request: + name: GetAccountSettingsRequest + query-parameters: + Api-Filter: optional + response: + docs: ok + type: string + status-code: 200 + examples: + - response: + body: string + source: + openapi: ../openapi.yml +types: + AccountPhotoGetArg: + properties: + circle_crop: optional + dbx_account_id: string + expect_account_photo: optional + size: optional + source: + openapi: ../openapi.yml + AccountSettingsFilter: + properties: + include_deleted: optional + source: + openapi: ../openapi.yml +", + }, + }, + "packageMarkers": {}, + "rootApiFile": { + "contents": { + "display-name": "content-parameters", + "error-discrimination": { + "strategy": "status-code", + }, + "imports": { + "root": "__package__.yml", + }, + "name": "api", + }, + "defaultUrl": undefined, + "rawContents": "name: api +error-discrimination: + strategy: status-code +display-name: content-parameters +imports: + root: __package__.yml +", + }, + "specVersion": "1.0.0", +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json new file mode 100644 index 000000000000..ecb7133e2645 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "fern", + "version": "*" +} diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/generators.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/generators.yml new file mode 100644 index 000000000000..b9f4d09f4270 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/fern/generators.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../openapi.yml + settings: + respect-parameter-content: true diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/openapi.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/openapi.yml new file mode 100644 index 000000000000..0db1b72399e9 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/parameter-content/openapi.yml @@ -0,0 +1,62 @@ +openapi: 3.0.3 +info: + title: content-parameters + version: 1.0.0 +paths: + /account/photo: + get: + operationId: getAccountPhoto + parameters: + - name: Api-Arg + in: header + required: true + description: JSON-encoded arguments. + content: + application/json: + schema: + $ref: "#/components/schemas/AccountPhotoGetArg" + responses: + "200": + description: ok + content: + application/json: + schema: + type: string + /account/settings: + get: + operationId: getAccountSettings + parameters: + - name: Api-Filter + in: query + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AccountSettingsFilter" + responses: + "200": + description: ok + content: + application/json: + schema: + type: string +components: + schemas: + AccountPhotoGetArg: + type: object + required: + - dbx_account_id + properties: + circle_crop: + type: boolean + dbx_account_id: + type: string + expect_account_photo: + type: boolean + size: + type: string + AccountSettingsFilter: + type: object + properties: + include_deleted: + type: boolean diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/parameter-content.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/parameter-content.json new file mode 100644 index 000000000000..dfee1fedc3fc --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/parameter-content.json @@ -0,0 +1,817 @@ +{ + "selfHosted": false, + "specVersion": "1.0.0", + "apiName": "api", + "apiDisplayName": "content-parameters", + "auth": { + "requirement": "ALL", + "schemes": [] + }, + "headers": [], + "idempotencyHeaders": [], + "types": { + "type_:AccountPhotoGetArg": { + "name": { + "name": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:AccountPhotoGetArg" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "circle_crop", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "dbx_account_id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "expect_account_photo", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "size", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:AccountSettingsFilter": { + "name": { + "name": "AccountSettingsFilter", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:AccountSettingsFilter" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "include_deleted", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + } + }, + "errors": {}, + "services": { + "service_": { + "name": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "encoding": { + "json": {} + }, + "transport": { + "type": "http" + }, + "endpoints": [ + { + "id": "endpoint_.getAccountPhoto", + "name": "getAccountPhoto", + "auth": false, + "idempotent": false, + "method": "GET", + "path": { + "head": "/account/photo", + "parts": [] + }, + "fullPath": { + "head": "account/photo", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [ + { + "docs": "JSON-encoded arguments.", + "name": { + "wireValue": "Api-Arg", + "name": "apiArg" + }, + "valueType": { + "name": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:AccountPhotoGetArg", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "sdkRequest": { + "shape": { + "wrapperName": "GetAccountPhotoRequest", + "bodyKey": "body", + "includePathParameters": false, + "onlyPathParameters": false, + "type": "wrapper" + }, + "requestParameterName": "request" + }, + "response": { + "body": { + "value": { + "docs": "ok", + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "ok" + }, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + }, + { + "id": "endpoint_.getAccountSettings", + "name": "getAccountSettings", + "auth": false, + "idempotent": false, + "method": "GET", + "path": { + "head": "/account/settings", + "parts": [] + }, + "fullPath": { + "head": "account/settings", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [ + { + "name": "Api-Filter", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "allowMultiple": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "headers": [], + "sdkRequest": { + "shape": { + "wrapperName": "GetAccountSettingsRequest", + "bodyKey": "body", + "includePathParameters": false, + "onlyPathParameters": false, + "type": "wrapper" + }, + "requestParameterName": "request" + }, + "response": { + "body": { + "value": { + "docs": "ok", + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "ok" + }, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + } + ] + } + }, + "constants": { + "errorInstanceIdKey": "errorInstanceId" + }, + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "pathParameters": [], + "variables": [], + "serviceTypeReferenceInfo": { + "typesReferencedOnlyByService": { + "service_": [ + "type_:AccountPhotoGetArg" + ] + }, + "sharedTypes": [ + "type_:AccountSettingsFilter" + ] + }, + "webhookGroups": {}, + "websocketChannels": {}, + "dynamic": { + "version": "1.0.0", + "types": { + "type_:AccountPhotoGetArg": { + "declaration": { + "name": { + "originalName": "AccountPhotoGetArg", + "camelCase": { + "unsafeName": "accountPhotoGetArg", + "safeName": "accountPhotoGetArg" + }, + "snakeCase": { + "unsafeName": "account_photo_get_arg", + "safeName": "account_photo_get_arg" + }, + "screamingSnakeCase": { + "unsafeName": "ACCOUNT_PHOTO_GET_ARG", + "safeName": "ACCOUNT_PHOTO_GET_ARG" + }, + "pascalCase": { + "unsafeName": "AccountPhotoGetArg", + "safeName": "AccountPhotoGetArg" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "circle_crop", + "name": { + "originalName": "circle_crop", + "camelCase": { + "unsafeName": "circleCrop", + "safeName": "circleCrop" + }, + "snakeCase": { + "unsafeName": "circle_crop", + "safeName": "circle_crop" + }, + "screamingSnakeCase": { + "unsafeName": "CIRCLE_CROP", + "safeName": "CIRCLE_CROP" + }, + "pascalCase": { + "unsafeName": "CircleCrop", + "safeName": "CircleCrop" + } + } + }, + "typeReference": { + "value": { + "value": "BOOLEAN", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "dbx_account_id", + "name": { + "originalName": "dbx_account_id", + "camelCase": { + "unsafeName": "dbxAccountID", + "safeName": "dbxAccountID" + }, + "snakeCase": { + "unsafeName": "dbx_account_id", + "safeName": "dbx_account_id" + }, + "screamingSnakeCase": { + "unsafeName": "DBX_ACCOUNT_ID", + "safeName": "DBX_ACCOUNT_ID" + }, + "pascalCase": { + "unsafeName": "DbxAccountID", + "safeName": "DbxAccountID" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "expect_account_photo", + "name": { + "originalName": "expect_account_photo", + "camelCase": { + "unsafeName": "expectAccountPhoto", + "safeName": "expectAccountPhoto" + }, + "snakeCase": { + "unsafeName": "expect_account_photo", + "safeName": "expect_account_photo" + }, + "screamingSnakeCase": { + "unsafeName": "EXPECT_ACCOUNT_PHOTO", + "safeName": "EXPECT_ACCOUNT_PHOTO" + }, + "pascalCase": { + "unsafeName": "ExpectAccountPhoto", + "safeName": "ExpectAccountPhoto" + } + } + }, + "typeReference": { + "value": { + "value": "BOOLEAN", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "size", + "name": { + "originalName": "size", + "camelCase": { + "unsafeName": "size", + "safeName": "size" + }, + "snakeCase": { + "unsafeName": "size", + "safeName": "size" + }, + "screamingSnakeCase": { + "unsafeName": "SIZE", + "safeName": "SIZE" + }, + "pascalCase": { + "unsafeName": "Size", + "safeName": "Size" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:AccountSettingsFilter": { + "declaration": { + "name": { + "originalName": "AccountSettingsFilter", + "camelCase": { + "unsafeName": "accountSettingsFilter", + "safeName": "accountSettingsFilter" + }, + "snakeCase": { + "unsafeName": "account_settings_filter", + "safeName": "account_settings_filter" + }, + "screamingSnakeCase": { + "unsafeName": "ACCOUNT_SETTINGS_FILTER", + "safeName": "ACCOUNT_SETTINGS_FILTER" + }, + "pascalCase": { + "unsafeName": "AccountSettingsFilter", + "safeName": "AccountSettingsFilter" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "include_deleted", + "name": { + "originalName": "include_deleted", + "camelCase": { + "unsafeName": "includeDeleted", + "safeName": "includeDeleted" + }, + "snakeCase": { + "unsafeName": "include_deleted", + "safeName": "include_deleted" + }, + "screamingSnakeCase": { + "unsafeName": "INCLUDE_DELETED", + "safeName": "INCLUDE_DELETED" + }, + "pascalCase": { + "unsafeName": "IncludeDeleted", + "safeName": "IncludeDeleted" + } + } + }, + "typeReference": { + "value": { + "value": "BOOLEAN", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + } + }, + "headers": [], + "endpoints": { + "endpoint_.getAccountPhoto": { + "declaration": { + "name": { + "originalName": "getAccountPhoto", + "camelCase": { + "unsafeName": "getAccountPhoto", + "safeName": "getAccountPhoto" + }, + "snakeCase": { + "unsafeName": "get_account_photo", + "safeName": "get_account_photo" + }, + "screamingSnakeCase": { + "unsafeName": "GET_ACCOUNT_PHOTO", + "safeName": "GET_ACCOUNT_PHOTO" + }, + "pascalCase": { + "unsafeName": "GetAccountPhoto", + "safeName": "GetAccountPhoto" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "location": { + "method": "GET", + "path": "/account/photo" + }, + "request": { + "declaration": { + "name": { + "originalName": "GetAccountPhotoRequest", + "camelCase": { + "unsafeName": "getAccountPhotoRequest", + "safeName": "getAccountPhotoRequest" + }, + "snakeCase": { + "unsafeName": "get_account_photo_request", + "safeName": "get_account_photo_request" + }, + "screamingSnakeCase": { + "unsafeName": "GET_ACCOUNT_PHOTO_REQUEST", + "safeName": "GET_ACCOUNT_PHOTO_REQUEST" + }, + "pascalCase": { + "unsafeName": "GetAccountPhotoRequest", + "safeName": "GetAccountPhotoRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "pathParameters": [], + "queryParameters": [], + "headers": [ + { + "name": { + "wireValue": "Api-Arg", + "name": { + "originalName": "apiArg", + "camelCase": { + "unsafeName": "apiArg", + "safeName": "apiArg" + }, + "snakeCase": { + "unsafeName": "api_arg", + "safeName": "api_arg" + }, + "screamingSnakeCase": { + "unsafeName": "API_ARG", + "safeName": "API_ARG" + }, + "pascalCase": { + "unsafeName": "APIArg", + "safeName": "APIArg" + } + } + }, + "typeReference": { + "value": "type_:AccountPhotoGetArg", + "type": "named" + } + } + ], + "metadata": { + "includePathParameters": false, + "onlyPathParameters": false + }, + "type": "inlined" + }, + "response": { + "type": "json" + }, + "examples": [] + }, + "endpoint_.getAccountSettings": { + "declaration": { + "name": { + "originalName": "getAccountSettings", + "camelCase": { + "unsafeName": "getAccountSettings", + "safeName": "getAccountSettings" + }, + "snakeCase": { + "unsafeName": "get_account_settings", + "safeName": "get_account_settings" + }, + "screamingSnakeCase": { + "unsafeName": "GET_ACCOUNT_SETTINGS", + "safeName": "GET_ACCOUNT_SETTINGS" + }, + "pascalCase": { + "unsafeName": "GetAccountSettings", + "safeName": "GetAccountSettings" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "location": { + "method": "GET", + "path": "/account/settings" + }, + "request": { + "declaration": { + "name": { + "originalName": "GetAccountSettingsRequest", + "camelCase": { + "unsafeName": "getAccountSettingsRequest", + "safeName": "getAccountSettingsRequest" + }, + "snakeCase": { + "unsafeName": "get_account_settings_request", + "safeName": "get_account_settings_request" + }, + "screamingSnakeCase": { + "unsafeName": "GET_ACCOUNT_SETTINGS_REQUEST", + "safeName": "GET_ACCOUNT_SETTINGS_REQUEST" + }, + "pascalCase": { + "unsafeName": "GetAccountSettingsRequest", + "safeName": "GetAccountSettingsRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "pathParameters": [], + "queryParameters": [ + { + "name": { + "wireValue": "Api-Filter", + "name": { + "originalName": "Api-Filter", + "camelCase": { + "unsafeName": "apiFilter", + "safeName": "apiFilter" + }, + "snakeCase": { + "unsafeName": "api_filter", + "safeName": "api_filter" + }, + "screamingSnakeCase": { + "unsafeName": "API_FILTER", + "safeName": "API_FILTER" + }, + "pascalCase": { + "unsafeName": "APIFilter", + "safeName": "APIFilter" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "headers": [], + "metadata": { + "includePathParameters": false, + "onlyPathParameters": false + }, + "type": "inlined" + }, + "response": { + "type": "json" + }, + "examples": [] + } + }, + "pathParameters": [] + }, + "apiPlayground": true, + "casingsConfig": { + "smartCasing": true + }, + "subpackages": {}, + "rootPackage": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "service": "service_", + "types": [ + "type_:AccountPhotoGetArg", + "type_:AccountSettingsFilter" + ], + "errors": [], + "subpackages": [], + "hasEndpointsInTree": true, + "hasWebSocketInTree": false + }, + "sdkConfig": { + "isAuthMandatory": false, + "hasStreamingEndpoints": false, + "hasPaginatedEndpoints": false, + "hasFileDownloadEndpoints": false, + "platformHeaders": { + "language": "X-Fern-Language", + "sdkName": "X-Fern-SDK-Name", + "sdkVersion": "X-Fern-SDK-Version" + } + } +} \ No newline at end of file diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/parameter-content.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/parameter-content.json new file mode 100644 index 000000000000..d18e78f870a7 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/parameter-content.json @@ -0,0 +1,714 @@ +{ + "auth": { + "requirement": "ALL", + "schemes": [] + }, + "selfHosted": false, + "types": { + "AccountPhotoGetArg": { + "name": { + "typeId": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountPhotoGetArg" + }, + "shape": { + "properties": [ + { + "name": "circle_crop", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AccountPhotoGetArgCircleCrop_example_autogenerated": true + } + } + }, + { + "name": "dbx_account_id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AccountPhotoGetArgDbxAccountId_example_autogenerated": "string" + } + } + }, + { + "name": "expect_account_photo", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AccountPhotoGetArgExpectAccountPhoto_example_autogenerated": true + } + } + }, + { + "name": "size", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AccountPhotoGetArgSize_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AccountPhotoGetArg_example_autogenerated": { + "dbx_account_id": "string" + } + } + } + }, + "AccountSettingsFilter": { + "name": { + "typeId": "AccountSettingsFilter", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountSettingsFilter" + }, + "shape": { + "properties": [ + { + "name": "include_deleted", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AccountSettingsFilterIncludeDeleted_example_autogenerated": true + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AccountSettingsFilter_example_autogenerated": {} + } + } + } + }, + "services": { + "service_": { + "name": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "endpoints": [ + { + "method": "GET", + "path": { + "head": "/account/photo", + "parts": [] + }, + "pathParameters": [], + "queryParameters": [], + "headers": [ + { + "name": "Api-Arg", + "docs": "JSON-encoded arguments.", + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountPhotoGetArg", + "typeId": "AccountPhotoGetArg", + "inline": false, + "displayName": "AccountPhotoGetArg", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "Api-Arg_example": { + "dbx_account_id": "string" + } + } + } + } + ], + "responseHeaders": [], + "errors": [], + "auth": false, + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "4c17090b", + "url": "/account/photo", + "endpointHeaders": [ + { + "name": "Api-Arg", + "value": { + "jsonExample": { + "dbx_account_id": "dbx_account_id" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "circle_crop", + "originalTypeDeclaration": { + "typeId": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountPhotoGetArg" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "dbx_account_id", + "originalTypeDeclaration": { + "typeId": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountPhotoGetArg" + }, + "value": { + "jsonExample": "dbx_account_id", + "shape": { + "primitive": { + "string": { + "original": "dbx_account_id" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "expect_account_photo", + "originalTypeDeclaration": { + "typeId": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountPhotoGetArg" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "size", + "originalTypeDeclaration": { + "typeId": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountPhotoGetArg" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "AccountPhotoGetArg", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AccountPhotoGetArg" + }, + "type": "named" + } + } + } + ], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "response": { + "value": { + "value": { + "jsonExample": "string", + "shape": { + "primitive": { + "string": { + "original": "string" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "type": "body" + }, + "type": "ok" + } + } + } + ], + "idempotent": false, + "fullPath": { + "head": "/account/photo", + "parts": [] + }, + "allPathParameters": [], + "source": { + "type": "openapi" + }, + "audiences": [], + "id": "endpoint_.getAccountPhoto", + "name": "getAccountPhoto", + "v2RequestBodies": {}, + "response": { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "ok", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "getAccountPhotoExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "ok" + }, + "v2Examples": { + "autogeneratedExamples": { + "base_getAccountPhotoExample_200": { + "displayName": "getAccountPhotoExample", + "request": { + "endpoint": { + "method": "GET", + "path": "/account/photo" + }, + "pathParameters": {}, + "queryParameters": {}, + "headers": { + "Api-Arg": { + "dbx_account_id": "string" + } + } + }, + "response": { + "statusCode": 200, + "body": { + "value": "string", + "type": "json" + } + } + } + }, + "userSpecifiedExamples": {} + }, + "v2Responses": { + "responses": [ + { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "ok", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "getAccountPhotoExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "ok" + } + ] + } + }, + { + "method": "GET", + "path": { + "head": "/account/settings", + "parts": [] + }, + "pathParameters": [], + "queryParameters": [ + { + "name": "Api-Filter", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "allowMultiple": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "Api-Filter_example": "Api-Filter" + } + } + } + ], + "headers": [], + "responseHeaders": [], + "errors": [], + "auth": false, + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "d9186361", + "url": "/account/settings", + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "response": { + "value": { + "value": { + "jsonExample": "string", + "shape": { + "primitive": { + "string": { + "original": "string" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "type": "body" + }, + "type": "ok" + } + } + } + ], + "idempotent": false, + "fullPath": { + "head": "/account/settings", + "parts": [] + }, + "allPathParameters": [], + "source": { + "type": "openapi" + }, + "audiences": [], + "id": "endpoint_.getAccountSettings", + "name": "getAccountSettings", + "v2RequestBodies": {}, + "response": { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "ok", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "getAccountSettingsExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "ok" + }, + "v2Examples": { + "autogeneratedExamples": { + "base_getAccountSettingsExample_200": { + "displayName": "getAccountSettingsExample", + "request": { + "endpoint": { + "method": "GET", + "path": "/account/settings" + }, + "pathParameters": {}, + "queryParameters": {}, + "headers": {} + }, + "response": { + "statusCode": 200, + "body": { + "value": "string", + "type": "json" + } + } + } + }, + "userSpecifiedExamples": {} + }, + "v2Responses": { + "responses": [ + { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "ok", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "getAccountSettingsExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "ok" + } + ] + } + } + ] + } + }, + "errors": {}, + "webhookGroups": {}, + "headers": [], + "idempotencyHeaders": [], + "apiDisplayName": "content-parameters", + "pathParameters": [], + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "variables": [], + "serviceTypeReferenceInfo": { + "sharedTypes": [], + "typesReferencedOnlyByService": {} + }, + "rootPackage": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "service": "service_", + "types": [ + "AccountPhotoGetArg", + "AccountSettingsFilter" + ], + "errors": [], + "subpackages": [], + "hasEndpointsInTree": false + }, + "subpackages": {}, + "sdkConfig": { + "hasFileDownloadEndpoints": false, + "hasPaginatedEndpoints": false, + "hasStreamingEndpoints": false, + "isAuthMandatory": true, + "platformHeaders": { + "language": "", + "sdkName": "", + "sdkVersion": "" + } + }, + "apiName": "content-parameters", + "constants": { + "errorInstanceIdKey": "errorInstanceId" + } +} \ No newline at end of file diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json new file mode 100644 index 000000000000..ecb7133e2645 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "fern", + "version": "*" +} diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/generators.yml b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/generators.yml new file mode 100644 index 000000000000..b9f4d09f4270 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/fern/generators.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../openapi.yml + settings: + respect-parameter-content: true diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/openapi.yml b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/openapi.yml new file mode 100644 index 000000000000..0db1b72399e9 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/parameter-content/openapi.yml @@ -0,0 +1,62 @@ +openapi: 3.0.3 +info: + title: content-parameters + version: 1.0.0 +paths: + /account/photo: + get: + operationId: getAccountPhoto + parameters: + - name: Api-Arg + in: header + required: true + description: JSON-encoded arguments. + content: + application/json: + schema: + $ref: "#/components/schemas/AccountPhotoGetArg" + responses: + "200": + description: ok + content: + application/json: + schema: + type: string + /account/settings: + get: + operationId: getAccountSettings + parameters: + - name: Api-Filter + in: query + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AccountSettingsFilter" + responses: + "200": + description: ok + content: + application/json: + schema: + type: string +components: + schemas: + AccountPhotoGetArg: + type: object + required: + - dbx_account_id + properties: + circle_crop: + type: boolean + dbx_account_id: + type: string + expect_account_photo: + type: boolean + size: + type: string + AccountSettingsFilter: + type: object + properties: + include_deleted: + type: boolean diff --git a/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts b/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts index 7bb7619254b5..5e20aac4c260 100644 --- a/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts +++ b/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts @@ -207,7 +207,8 @@ export class LegacyApiSpecAdapter { coerceConstsTo: settings.coerceConstsTo, shouldInferDiscriminatedUnionBaseProperties: settings.inferDiscriminatedUnionBaseProperties, disambiguateRequestNames: settings["disambiguate-request-names"], - ignoreTags: settings["ignore-tags"] + ignoreTags: settings["ignore-tags"], + respectParameterContent: settings["respect-parameter-content"] }; const hasSettings = Object.values(result).some((v) => v != null); diff --git a/packages/cli/cli/changes/unreleased/add-respect-parameter-content-setting.yml b/packages/cli/cli/changes/unreleased/add-respect-parameter-content-setting.yml new file mode 100644 index 000000000000..867abf4e1b26 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/add-respect-parameter-content-setting.yml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Add the `respect-parameter-content` OpenAPI setting. When enabled, header parameters that + declare their schema under `content` (e.g. a header whose value is a JSON-encoded + object) are typed from that schema instead of falling back to a string, so the + referenced object's fields are preserved in SDKs and the API reference playground. + type: feat diff --git a/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts b/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts index 2578703c09ee..f61f7b9ec976 100644 --- a/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts +++ b/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts @@ -119,7 +119,15 @@ export const OpenApiSettingsSchema = BaseApiSettingsSchema.extend({ * names are derived from each operation's operationId. * Defaults to false. */ - "ignore-tags": z.boolean().optional() + "ignore-tags": z.boolean().optional(), + + /** + * If true, header parameters that declare their schema under `content` (e.g. a header + * whose value is a JSON-encoded object) are typed from that schema instead of falling + * back to a string. + * Defaults to false. + */ + "respect-parameter-content": z.boolean().optional() }); export type OpenApiSettingsSchema = z.infer; diff --git a/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts b/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts index 4efa63c1d26f..d745b522a996 100644 --- a/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts +++ b/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts @@ -75,7 +75,8 @@ const UNDEFINED_API_DEFINITION_SETTINGS: generatorsYml.APIDefinitionSettings = { coerceConstsTo: undefined, shouldInferDiscriminatedUnionBaseProperties: undefined, disambiguateRequestNames: undefined, - ignoreTags: undefined + ignoreTags: undefined, + respectParameterContent: undefined }; export async function convertGeneratorsConfiguration({ @@ -187,7 +188,8 @@ function parseOpenApiDefinitionSettingsSchema( pathParameterOrder: settings?.["path-parameter-order"], shouldInferDiscriminatedUnionBaseProperties: settings?.["infer-discriminated-union-base-properties"], disambiguateRequestNames: settings?.["disambiguate-request-names"], - ignoreTags: settings?.["ignore-tags"] + ignoreTags: settings?.["ignore-tags"], + respectParameterContent: settings?.["respect-parameter-content"] }; } diff --git a/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts b/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts index 395dbb407d9f..cbe4a2ec5394 100644 --- a/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts +++ b/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts @@ -103,6 +103,7 @@ export interface APIDefinitionSettings { shouldInferDiscriminatedUnionBaseProperties: boolean | undefined; disambiguateRequestNames: boolean | undefined; ignoreTags: boolean | undefined; + respectParameterContent: boolean | undefined; } export interface GitSource { diff --git a/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts index a75e7c6675a2..898c837bc6b9 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts @@ -90,4 +90,11 @@ export interface OpenApiSettingsSchema extends GeneratorsYml.BaseApiSettingsSche * Defaults to false. */ "ignore-tags"?: boolean; + /** + * If true, header parameters that declare their schema under `content` (e.g. a header + * whose value is a JSON-encoded object) are typed from that schema instead of falling + * back to a string. + * Defaults to false. + */ + "respect-parameter-content"?: boolean; } diff --git a/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts index 25d555914393..bd4bdd7f0eed 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts @@ -38,6 +38,7 @@ export const OpenApiSettingsSchema: core.serialization.ObjectSchema< "infer-discriminated-union-base-properties": core.serialization.boolean().optional(), "disambiguate-request-names": core.serialization.boolean().optional(), "ignore-tags": core.serialization.boolean().optional(), + "respect-parameter-content": core.serialization.boolean().optional(), }) .extend(BaseApiSettingsSchema); @@ -66,5 +67,6 @@ export declare namespace OpenApiSettingsSchema { "infer-discriminated-union-base-properties"?: boolean | null; "disambiguate-request-names"?: boolean | null; "ignore-tags"?: boolean | null; + "respect-parameter-content"?: boolean | null; } } diff --git a/packages/commons/api-workspace-commons/src/openapi/getAPIDefinitionSettings.ts b/packages/commons/api-workspace-commons/src/openapi/getAPIDefinitionSettings.ts index b88bac71d772..fb59970be788 100644 --- a/packages/commons/api-workspace-commons/src/openapi/getAPIDefinitionSettings.ts +++ b/packages/commons/api-workspace-commons/src/openapi/getAPIDefinitionSettings.ts @@ -70,7 +70,8 @@ const FIELD_MAPPINGS: Partial = { coerceConstsTo: "coerceConstsTo", shouldInferDiscriminatedUnionBaseProperties: "shouldInferDiscriminatedUnionBaseProperties", disambiguateRequestNames: "disambiguateRequestNames", - ignoreTags: "ignoreTags" + ignoreTags: "ignoreTags", + respectParameterContent: "respectParameterContent" }; function setIfDefined( From 6f7600b9182347ff23ccc1de1797d37a6ff06d55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Aug 2026 13:46:16 +0000 Subject: [PATCH 2/4] chore(cli): release 5.99.0 --- .../add-respect-parameter-content-setting.yml | 0 packages/cli/cli/versions.yml | 10 ++++++++++ 2 files changed, 10 insertions(+) rename packages/cli/cli/changes/{unreleased => 5.99.0}/add-respect-parameter-content-setting.yml (100%) diff --git a/packages/cli/cli/changes/unreleased/add-respect-parameter-content-setting.yml b/packages/cli/cli/changes/5.99.0/add-respect-parameter-content-setting.yml similarity index 100% rename from packages/cli/cli/changes/unreleased/add-respect-parameter-content-setting.yml rename to packages/cli/cli/changes/5.99.0/add-respect-parameter-content-setting.yml diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index 2814a2d8ff77..ec95b153413f 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,14 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.99.0 + changelogEntry: + - summary: | + Add the `respect-parameter-content` OpenAPI setting. When enabled, header parameters that + declare their schema under `content` (e.g. a header whose value is a JSON-encoded + object) are typed from that schema instead of falling back to a string, so the + referenced object's fields are preserved in SDKs and the API reference playground. + type: feat + createdAt: "2026-08-19" + irVersion: 67 - version: 5.98.3 changelogEntry: - summary: | From eae4b24076abc95b683d92ace08dc45bcfaa47b6 Mon Sep 17 00:00:00 2001 From: Rishabh Dhadda Date: Wed, 19 Aug 2026 12:33:29 -0400 Subject: [PATCH 3/4] fix(cli-generator): make credential temp-file writes unique per writer (#17457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli-generator): make credential temp-file writes unique per writer `atomic_write` derived its temp file name from the target alone, so every concurrent writer used the same sibling path (`auth-keyring.tmp`). Whichever process renamed first moved it away and the rest failed with error[auth]: Failed to rename .../auth-keyring.tmp: No such file or directory The temp name now carries the writer's pid plus a process-local counter. The pid covers the case that actually bit us — separate CLI processes — and the counter covers two writers inside one process, which is also what makes the behavior testable without spawning subprocesses. Found via a generated wire-test suite on a ~680-case workspace: the harness seeds a token with `auth login --with-token` per authenticated case, cargo runs those in parallel, and a different subset of cases died on each run. Two runs of the identical commit failed with 1 and then 2 casualties respectively, which is what ruled out a deterministic break. Small seed fixtures have too little concurrency to reach it, so it surfaced on a real workspace before ours. `rename` was already atomic for readers, so no partially written credential file was ever observable — only writer-vs-writer collisions on the temp path. `FileKeyringStore::set` remains an unlocked read-modify-write of the whole map, so simultaneous writers can still clobber one another's entries; that needs file locking and is deliberately left alone. The new test fails against the old derivation with the exact production error (15 of 16 writers), so it pins the behavior rather than merely exercising it. Co-Authored-By: Claude * fix(cli-generator): unlink credential temp files on failed writes Unique-per-writer temp names removed the self-limiting property of the old shared `auth-keyring.tmp`: a failed write used to leave one stale file that the next write reused, whereas unique names leak a distinct credential-bearing file per failure in a directory nothing prunes. `TempFileGuard` unlinks on drop, covering every early return and unwinding panic between `write` and `rename` (a SIGKILL still leaks — no in-process guard can cover that). Also switches the temp-name counter from `AtomicU64` to `AtomicUsize`, which is available on targets lacking 64-bit atomics, and records the `$XDG_CONFIG_HOME` root cause in the changelog: the wire harness sets a per-case `HOME`, but `config_dir()` reads `$XDG_CONFIG_HOME` first on Linux, so CI runners funneled every authenticated case into one shared credential store. Co-Authored-By: Claude * fix(cli-generator): reattach atomic_write docs to the function `TempFileGuard` was inserted between `atomic_write`'s doc comment and the function, so rustdoc attached the whole rationale to the guard struct and left `atomic_write` undocumented. It compiles either way, which is why nothing flagged it. Guard now precedes the doc block, and the doc block notes that the guard is what keeps unique temp names from accumulating as orphans. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../unreleased/keyring-atomic-write-race.yml | 30 ++++ generators/cli/sdk/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../cli/allof-inline/src/auth/oauth_common.rs | 168 +++++++++++++++++- seed/cli/allof/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../no-custom-config/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../no-custom-config/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- seed/cli/imdb/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- seed/cli/null-type/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../openapi-subtitle/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../github-npm/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../no-custom-config/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../with-wire-tests/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../src/auth/oauth_common.rs | 168 +++++++++++++++++- .../url-form-encoded/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../webhook-audience/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../x-fern-default/src/auth/oauth_common.rs | 168 +++++++++++++++++- .../no-custom-config/src/auth/oauth_common.rs | 168 +++++++++++++++++- 48 files changed, 7550 insertions(+), 376 deletions(-) create mode 100644 generators/cli/changes/unreleased/keyring-atomic-write-race.yml diff --git a/generators/cli/changes/unreleased/keyring-atomic-write-race.yml b/generators/cli/changes/unreleased/keyring-atomic-write-race.yml new file mode 100644 index 000000000000..a0332ab6442a --- /dev/null +++ b/generators/cli/changes/unreleased/keyring-atomic-write-race.yml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=../../../../fern-changes-yml.schema.json + +- summary: | + Fix a race in credential storage that made `auth login --with-token` fail + intermittently when several CLI processes ran at once. + + `atomic_write` derived its temp file name from the target alone, so every + concurrent writer used the same sibling path (`auth-keyring.tmp`). Whichever + process renamed first moved it away and the rest failed with + `error[auth]: Failed to rename .../auth-keyring.tmp: No such file or + directory (os error 2)`. The temp name now carries the writer's pid plus a + process-local counter, and a drop guard unlinks it on any early return or + panic so unique names can't accumulate orphaned credential files. + + This surfaced as flaky generated wire-test suites. The harness gives each + case its own temp `HOME`, but `config_dir()` consults `$XDG_CONFIG_HOME` + first on Linux and CI runners set it to the real config directory — so the + `HOME` override was inert, every authenticated case shared one + `auth-keyring.json`, and `cargo test` ran them in parallel. On a workspace + with ~680 cases a different subset died on each run. macOS never consults + `$XDG_CONFIG_HOME`, so the isolation holds there and the bug cannot + reproduce locally on a Mac, which is why it reached a real workspace before + ours. + + `rename` was already atomic for readers, so no partially written credential + file was ever observable — only writer-vs-writer collisions were affected. + `FileKeyringStore::set` is still an unlocked read-modify-write of the whole + map, so simultaneous writers can clobber one another's entries; that needs + file locking and is left alone here. + type: fix diff --git a/generators/cli/sdk/src/auth/oauth_common.rs b/generators/cli/sdk/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/generators/cli/sdk/src/auth/oauth_common.rs +++ b/generators/cli/sdk/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/allof-inline/src/auth/oauth_common.rs b/seed/cli/allof-inline/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/allof-inline/src/auth/oauth_common.rs +++ b/seed/cli/allof-inline/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/allof/src/auth/oauth_common.rs b/seed/cli/allof/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/allof/src/auth/oauth_common.rs +++ b/seed/cli/allof/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/api-wide-base-path-with-default/src/auth/oauth_common.rs b/seed/cli/api-wide-base-path-with-default/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/api-wide-base-path-with-default/src/auth/oauth_common.rs +++ b/seed/cli/api-wide-base-path-with-default/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/auth/oauth_common.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/auth/oauth_common.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/cli-header-auth/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/oauth_common.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/oauth_common.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/auth/oauth_common.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/auth/oauth_common.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/auth/oauth_common.rs b/seed/cli/cli-multi-spec/no-custom-config/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/auth/oauth_common.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-oauth/client-credentials/src/auth/oauth_common.rs b/seed/cli/cli-oauth/client-credentials/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-oauth/client-credentials/src/auth/oauth_common.rs +++ b/seed/cli/cli-oauth/client-credentials/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/auth/oauth_common.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/auth/oauth_common.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/auth/oauth_common.rs b/seed/cli/discriminated-union-with-nested-oneof/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/auth/oauth_common.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/imdb/src/auth/oauth_common.rs b/seed/cli/imdb/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/imdb/src/auth/oauth_common.rs +++ b/seed/cli/imdb/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/inline-enum-type-name-override/src/auth/oauth_common.rs b/seed/cli/inline-enum-type-name-override/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/inline-enum-type-name-override/src/auth/oauth_common.rs +++ b/seed/cli/inline-enum-type-name-override/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/multi-content-type-examples/src/auth/oauth_common.rs b/seed/cli/multi-content-type-examples/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/multi-content-type-examples/src/auth/oauth_common.rs +++ b/seed/cli/multi-content-type-examples/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/multi-url-environment-reference/src/auth/oauth_common.rs b/seed/cli/multi-url-environment-reference/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/multi-url-environment-reference/src/auth/oauth_common.rs +++ b/seed/cli/multi-url-environment-reference/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/no-content-response/src/auth/oauth_common.rs b/seed/cli/no-content-response/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/no-content-response/src/auth/oauth_common.rs +++ b/seed/cli/no-content-response/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/null-type/src/auth/oauth_common.rs b/seed/cli/null-type/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/null-type/src/auth/oauth_common.rs +++ b/seed/cli/null-type/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/nullable-allof-extends/src/auth/oauth_common.rs b/seed/cli/nullable-allof-extends/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/nullable-allof-extends/src/auth/oauth_common.rs +++ b/seed/cli/nullable-allof-extends/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/nullable-request-body/src/auth/oauth_common.rs b/seed/cli/nullable-request-body/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/nullable-request-body/src/auth/oauth_common.rs +++ b/seed/cli/nullable-request-body/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/openapi-request-body-ref/src/auth/oauth_common.rs b/seed/cli/openapi-request-body-ref/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/openapi-request-body-ref/src/auth/oauth_common.rs +++ b/seed/cli/openapi-request-body-ref/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/openapi-subtitle/src/auth/oauth_common.rs b/seed/cli/openapi-subtitle/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/openapi-subtitle/src/auth/oauth_common.rs +++ b/seed/cli/openapi-subtitle/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-param-name-conflict/src/auth/oauth_common.rs b/seed/cli/query-param-name-conflict/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-param-name-conflict/src/auth/oauth_common.rs +++ b/seed/cli/query-param-name-conflict/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-parameters-openapi-as-objects/src/auth/oauth_common.rs b/seed/cli/query-parameters-openapi-as-objects/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/auth/oauth_common.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/auth/oauth_common.rs b/seed/cli/query-parameters-openapi/github-distribution/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/auth/oauth_common.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/auth/oauth_common.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/auth/oauth_common.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/oauth_common.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/auth/oauth_common.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/auth/oauth_common.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/auth/oauth_common.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/auth/oauth_common.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/auth/oauth_common.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/auth/oauth_common.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/auth/oauth_common.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/respect-optional-request-body/src/auth/oauth_common.rs b/seed/cli/respect-optional-request-body/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/respect-optional-request-body/src/auth/oauth_common.rs +++ b/seed/cli/respect-optional-request-body/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/schemaless-request-body-examples/src/auth/oauth_common.rs b/seed/cli/schemaless-request-body-examples/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/schemaless-request-body-examples/src/auth/oauth_common.rs +++ b/seed/cli/schemaless-request-body-examples/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/server-sent-events-openapi/src/auth/oauth_common.rs b/seed/cli/server-sent-events-openapi/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/server-sent-events-openapi/src/auth/oauth_common.rs +++ b/seed/cli/server-sent-events-openapi/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/server-url-templating-single-url/src/auth/oauth_common.rs b/seed/cli/server-url-templating-single-url/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/server-url-templating-single-url/src/auth/oauth_common.rs +++ b/seed/cli/server-url-templating-single-url/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/server-url-templating/src/auth/oauth_common.rs b/seed/cli/server-url-templating/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/server-url-templating/src/auth/oauth_common.rs +++ b/seed/cli/server-url-templating/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/url-form-encoded/src/auth/oauth_common.rs b/seed/cli/url-form-encoded/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/url-form-encoded/src/auth/oauth_common.rs +++ b/seed/cli/url-form-encoded/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/webhook-audience/src/auth/oauth_common.rs b/seed/cli/webhook-audience/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/webhook-audience/src/auth/oauth_common.rs +++ b/seed/cli/webhook-audience/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/x-fern-default/src/auth/oauth_common.rs b/seed/cli/x-fern-default/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/x-fern-default/src/auth/oauth_common.rs +++ b/seed/cli/x-fern-default/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/auth/oauth_common.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/auth/oauth_common.rs index 080bcbe3149f..5188eaff5f78 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/auth/oauth_common.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/auth/oauth_common.rs @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option { } } +/// Unlinks a temp file on drop unless [`TempFileGuard::disarm`]ed. Covers +/// every early return *and* a panic between creating the temp file and +/// renaming it into place. +/// +/// This exists because the temp name is unique per writer. The old shared +/// `auth-keyring.tmp` was self-limiting — a failed write left one stale file +/// that the next write reused — whereas unique names would leak a distinct +/// credential-bearing file on every failure, in a directory nothing prunes. A +/// `SIGKILL` still leaks, since no in-process guard can cover that. +struct TempFileGuard { + path: PathBuf, + armed: bool, +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + /// Relinquish the file — call once `rename` has moved it into place. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + /// Write `data` to `path` atomically: sibling temp file → owner-only /// permissions (0600 on Unix) → rename into place. +/// +/// The temp file name is unique per writer — pid plus a process-local +/// counter. Deriving it from the target alone meant every concurrent writer +/// used the *same* sibling (`auth-keyring.tmp`): whichever one renamed first +/// moved it away, and the rest failed with `ENOENT` from `rename`. That +/// surfaced as intermittent `auth login --with-token` failures across a +/// wire-test suite large enough to run many CLI processes at once, killing a +/// different subset of cases on each run. +/// +/// The pid covers the case that actually bit us (separate CLI processes); the +/// counter covers two writers inside one process, and is what makes the +/// behavior unit-testable without spawning subprocesses. +/// +/// A [`TempFileGuard`] unlinks the temp file if anything between creating it +/// and renaming it fails, so unique names cannot accumulate as orphans. +/// +/// `rename` is still atomic for readers, which is what keeps a partially +/// written credential file unobservable. This only fixes writer-vs-writer +/// collisions on the temp path. `FileKeyringStore::set` remains a +/// read-modify-write of the whole map with no lock, so simultaneous writers +/// can still clobber one another's *entries*; making that safe needs file +/// locking, which is a larger change. pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data).map_err(|e| { - CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) - })?; + static TMP_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), seq)); + let mut guard = TempFileGuard::new(tmp.clone()); + std::fs::write(&tmp, data) + .map_err(|e| CliError::Auth(format!("Failed to write {}: {e}", tmp.display())))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(&tmp, perms); } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) - }) + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())))?; + guard.disarm(); + Ok(()) } // --------------------------------------------------------------------------- @@ -251,6 +308,101 @@ pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { mod tests { use super::*; + /// Regression test for the temp-file collision that made + /// `auth login --with-token` fail intermittently: every writer derived the + /// same sibling path from the target, so the first `rename` moved it away + /// and the rest got `ENOENT`. + /// + /// Reverting `atomic_write` to a target-derived temp name fails this with + /// "Failed to rename ...: No such file or directory". + #[test] + fn atomic_write_tolerates_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("auth-keyring.json"); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| { + let target = target.clone(); + scope.spawn(move || atomic_write(&target, format!(r#"{{"writer":{i}}}"#).as_bytes())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let failed: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); + assert!(failed.is_empty(), "concurrent writers failed: {failed:?}"); + + // Last writer wins, but the file must always be one writer's complete + // payload — never a mix, and never absent. + let contents = std::fs::read_to_string(&target).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("target is not valid JSON after concurrent writes: {e} in {contents:?}")); + assert!(parsed.get("writer").is_some(), "unexpected payload: {contents}"); + + // No temp files orphaned in the directory. + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + /// A failed write must not leave its temp file behind. Renaming a file onto + /// an existing directory fails on every platform, which drives the error + /// path without mocking the filesystem. + /// + /// The pre-`TempFileGuard` code already cleaned up on a `rename` error, so + /// this pins an invariant rather than catching a regression — see + /// `temp_file_guard_unlinks_unless_disarmed` for the guard's own coverage. + #[test] + fn atomic_write_cleans_up_temp_file_on_failure() { + let dir = tempfile::tempdir().unwrap(); + + // The target is a *directory*, so `rename` cannot replace it. + let target = dir.path().join("auth-keyring.json"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("occupant"), b"x").unwrap(); + + let result = atomic_write(&target, br#"{"writer":0}"#); + assert!(result.is_err(), "expected rename onto a directory to fail"); + + let leftovers: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked after a failed write: {leftovers:?}"); + } + + /// Pins [`TempFileGuard`]: armed drops unlink, disarmed drops don't. Drop + /// running on an armed guard is what covers early returns and unwinding + /// panics between `write` and `rename` — paths the old `rename`-only + /// cleanup missed. Deleting the guard or the `disarm()` call fails this. + #[test] + fn temp_file_guard_unlinks_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-keyring.tmp.0.0"); + + std::fs::write(&path, b"x").unwrap(); + drop(TempFileGuard::new(path.clone())); + assert!(!path.exists(), "armed guard must unlink on drop"); + + // The post-`rename` case: the file has been moved away, so the guard + // must not touch whatever now sits at that path. + std::fs::write(&path, b"x").unwrap(); + let mut guard = TempFileGuard::new(path.clone()); + guard.disarm(); + drop(guard); + assert!(path.exists(), "disarmed guard must not unlink"); + } + #[test] fn token_bundle_roundtrip() { let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); From 65eb98dc35da288b139cba1b6e7418203583fbb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Aug 2026 16:38:09 +0000 Subject: [PATCH 4/4] chore(cli-generator): release 0.35.1 --- .../keyring-atomic-write-race.yml | 0 generators/cli/versions.yml | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+) rename generators/cli/changes/{unreleased => 0.35.1}/keyring-atomic-write-race.yml (100%) diff --git a/generators/cli/changes/unreleased/keyring-atomic-write-race.yml b/generators/cli/changes/0.35.1/keyring-atomic-write-race.yml similarity index 100% rename from generators/cli/changes/unreleased/keyring-atomic-write-race.yml rename to generators/cli/changes/0.35.1/keyring-atomic-write-race.yml diff --git a/generators/cli/versions.yml b/generators/cli/versions.yml index d555c01bcab3..852ab462de51 100644 --- a/generators/cli/versions.yml +++ b/generators/cli/versions.yml @@ -1,4 +1,36 @@ # yaml-language-server: $schema=../../fern-versions-yml.schema.json +- version: 0.35.1 + changelogEntry: + - summary: | + Fix a race in credential storage that made `auth login --with-token` fail + intermittently when several CLI processes ran at once. + + `atomic_write` derived its temp file name from the target alone, so every + concurrent writer used the same sibling path (`auth-keyring.tmp`). Whichever + process renamed first moved it away and the rest failed with + `error[auth]: Failed to rename .../auth-keyring.tmp: No such file or + directory (os error 2)`. The temp name now carries the writer's pid plus a + process-local counter, and a drop guard unlinks it on any early return or + panic so unique names can't accumulate orphaned credential files. + + This surfaced as flaky generated wire-test suites. The harness gives each + case its own temp `HOME`, but `config_dir()` consults `$XDG_CONFIG_HOME` + first on Linux and CI runners set it to the real config directory — so the + `HOME` override was inert, every authenticated case shared one + `auth-keyring.json`, and `cargo test` ran them in parallel. On a workspace + with ~680 cases a different subset died on each run. macOS never consults + `$XDG_CONFIG_HOME`, so the isolation holds there and the bug cannot + reproduce locally on a Mac, which is why it reached a real workspace before + ours. + + `rename` was already atomic for readers, so no partially written credential + file was ever observable — only writer-vs-writer collisions were affected. + `FileKeyringStore::set` is still an unlocked read-modify-write of the whole + map, so simultaneous writers can clobber one another's entries; that needs + file locking and is left alone here. + type: fix + createdAt: "2026-08-19" + irVersion: 67 - version: 0.35.0 changelogEntry: - summary: |