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/generators/cli/changes/0.35.1/keyring-atomic-write-race.yml b/generators/cli/changes/0.35.1/keyring-atomic-write-race.yml new file mode 100644 index 000000000000..a0332ab6442a --- /dev/null +++ b/generators/cli/changes/0.35.1/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/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: | 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/5.99.0/add-respect-parameter-content-setting.yml b/packages/cli/cli/changes/5.99.0/add-respect-parameter-content-setting.yml new file mode 100644 index 000000000000..867abf4e1b26 --- /dev/null +++ b/packages/cli/cli/changes/5.99.0/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/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: | 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( 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));