Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions fern-yml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4037,6 +4037,9 @@
},
"ignore-tags": {
"type": "boolean"
},
"respect-parameter-content": {
"type": "boolean"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -4623,6 +4626,9 @@
},
"ignore-tags": {
"type": "boolean"
},
"respect-parameter-content": {
"type": "boolean"
}
},
"additionalProperties": false
Expand Down
8 changes: 8 additions & 0 deletions fern/apis/generators-yml/definition/generators.yml
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,14 @@ types:
names are derived from each operation's operationId.
Defaults to false.
default: false
respect-parameter-content:
type: optional<boolean>
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
Expand Down
22 changes: 22 additions & 0 deletions generators-yml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": [
{
Expand Down
30 changes: 30 additions & 0 deletions generators/cli/changes/0.35.1/keyring-atomic-write-race.yml
Original file line number Diff line number Diff line change
@@ -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
168 changes: 160 additions & 8 deletions generators/cli/sdk/src/auth/oauth_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,23 +224,80 @@ pub(crate) fn config_dir() -> Option<PathBuf> {
}
}

/// 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(())
}

// ---------------------------------------------------------------------------
Expand All @@ -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<Result<(), CliError>> = 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<String> = 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<String> = 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<String> = 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));
Expand Down
32 changes: 32 additions & 0 deletions generators/cli/versions.yml
Original file line number Diff line number Diff line change
@@ -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: |
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,13 +17,15 @@ export class ParameterConverter extends Converters.AbstractConverters
let typeReference: TypeReference | undefined;
let inlinedTypes: Record<TypeId, Converters.SchemaConverters.SchemaConverter.ConvertedSchema> = {};

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
});
Expand All @@ -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;
}
}
Loading
Loading