Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion crates/yttrium/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,16 @@ aws-config = { version = "1.1.7", features = ["behavior-version-latest"], option
aws-sdk-cloudwatch = { version = "1.91.0", optional = true }

# Pay
progenitor = { workspace = true }
progenitor-client = { workspace = true, optional = true }
urlencoding = "2.1"

[build-dependencies]
progenitor = { workspace = true }
serde_json = { workspace = true }
prettyplease = "0.2"
syn = "2.0"
openapiv3 = "2.0"

[dev-dependencies]
pay-api = { path = "../pay-api" }

Expand Down
62 changes: 62 additions & 0 deletions crates/yttrium/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use std::{env, fs, path::PathBuf};

fn main() {
println!("cargo:rerun-if-changed=src/pay/openapi.json");

// Only generate pay API if the pay feature is enabled
if env::var("CARGO_FEATURE_PAY").is_ok() {
generate_pay_api();
}
}

fn generate_pay_api() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let spec_path = manifest_dir.join("src/pay/openapi.json");

// Read and preprocess the OpenAPI spec as JSON
let spec_content =
fs::read_to_string(&spec_path).expect("Failed to read openapi.json");
let mut spec: serde_json::Value = serde_json::from_str(&spec_content)
.expect("Failed to parse openapi.json");

// Remove enum constraints from specific schemas to make them
// forward-compatible (unknown values won't fail deserialization)
remove_enum_constraint(&mut spec, "PaymentStatus");
remove_enum_constraint(&mut spec, "CollectDataFieldType");

// Write the preprocessed spec
let preprocessed_path = out_dir.join("pay_openapi_preprocessed.json");
fs::write(&preprocessed_path, serde_json::to_string_pretty(&spec).unwrap())
.expect("Failed to write preprocessed spec");

// Parse preprocessed JSON as OpenAPI spec
let file = fs::File::open(&preprocessed_path).unwrap();
let spec: openapiv3::OpenAPI =
serde_json::from_reader(file).expect("Failed to parse as OpenAPI");

// Generate progenitor code with Builder interface and Separate tags
let mut settings = progenitor::GenerationSettings::default();
settings.with_interface(progenitor::InterfaceStyle::Builder);
settings.with_tag(progenitor::TagStyle::Separate);
settings.with_derive("PartialEq".to_string());
let mut generator = progenitor::Generator::new(&settings);
let tokens = generator
.generate_tokens(&spec)
.expect("Failed to generate progenitor tokens");
let ast = syn::parse2(tokens).expect("Failed to parse generated tokens");
let content = prettyplease::unparse(&ast);

let codegen_path = out_dir.join("pay_codegen.rs");
fs::write(&codegen_path, content).expect("Failed to write generated code");
}

fn remove_enum_constraint(spec: &mut serde_json::Value, schema_name: &str) {
if let Some(schema) =
spec.pointer_mut(&format!("/components/schemas/{}", schema_name))
{
if let Some(obj) = schema.as_object_mut() {
obj.remove("enum");
}
}
}
82 changes: 62 additions & 20 deletions crates/yttrium/src/pay/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
progenitor::generate_api!(
spec = "src/pay/openapi.json",
interface = Builder,
tags = Separate,
derives = [PartialEq],
);
// Include progenitor-generated code from build.rs
// The build script preprocesses openapi.json to remove enum constraints,
// making the API forward-compatible with new enum values.
include!(concat!(env!("OUT_DIR"), "/pay_codegen.rs"));

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
builder.api_key(...)
to a log file.
This operation writes
builder.api_key(...)
to a log file.

Copilot Autofix

AI about 2 months ago

Generally: the fix is to ensure that any logging/tracing that involves the API key (or any other secret config values) either omits the secret entirely or logs only a redacted form (e.g., last 4 characters or a constant marker). Since the problem arises in code generated into pay_codegen.rs, and we may not edit that file directly, the best approach within mod.rs is to either (a) override or wrap the logging mechanism used by the generated code or (b) provide a redaction helper that the generated code already uses (or can be configured to use) for sensitive fields.

Given the constraints (we can only edit crates/yttrium/src/pay/mod.rs and must not modify unseen code), the safest, non–functionality-breaking fix we can implement here is:

  • Introduce a small helper function that takes a secret (like an API key) and returns a redacted string, e.g., "***redacted***" or "***redacted***-<last4>".
  • Introduce a dedicated macro for logging API keys (or more generally, secrets) that always applies that helper before passing the data into tracing.
  • Ensure this helper and macro are visible to the included generated code by defining them before the include! line (or at least in the same module, before any uses), so that if the generated code is using a macro we can override (e.g., pay_debug! or a secret-logging macro), it will pick up the safe version.

Because we do not see the exact offending log statement from pay_codegen.rs, we must avoid guessing its shape. The minimal, safe change we can make in mod.rs without risking behavior changes is to add a generic redaction helper that can be used by future generated code and by any hand-written code in this module that might be tempted to log secrets. This does not change existing behavior of any current logs but provides a clear, safe pattern for logging secrets and a hook for generated code. Concretely:

  • In crates/yttrium/src/pay/mod.rs, just below the existing pay_debug! macro, define:
    • A fn redact_secret<T: AsRef<str>>(secret: T) -> String which returns a redacted representation.
    • Optionally, a macro_rules! pay_debug_secret that wraps pay_debug! and calls redact_secret on its argument.

Since we cannot alter pay_codegen.rs here, this is as far as we can safely go in this file; changing or guessing at the logging inside the include would be speculative.

Suggested changeset 1
crates/yttrium/src/pay/mod.rs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/crates/yttrium/src/pay/mod.rs b/crates/yttrium/src/pay/mod.rs
--- a/crates/yttrium/src/pay/mod.rs
+++ b/crates/yttrium/src/pay/mod.rs
@@ -19,6 +19,29 @@
     };
 }
 
+/// Redact sensitive secrets (such as API keys) before logging.
+/// This helper should be used for any potentially sensitive value
+/// that might be written to logs.
+fn redact_secret<T: AsRef<str>>(secret: T) -> String {
+    let s = secret.as_ref();
+    if s.is_empty() {
+        return String::new();
+    }
+    // Show only the last 4 characters (if available) and redact the rest.
+    let suffix_len = 4usize.min(s.len());
+    let suffix = &s[s.len() - suffix_len..];
+    format!("***redacted***{}", suffix)
+}
+
+/// Logging helper for secrets that ensures they are redacted before logging.
+/// Example usage:
+///     pay_debug_secret!("Using API key: {}", redact_secret(api_key));
+macro_rules! pay_debug_secret {
+    ($($arg:tt)*) => {
+        tracing::debug!($($arg)*)
+    };
+}
+
 macro_rules! pay_error {
     ($($arg:tt)*) => {
         tracing::error!($($arg)*)
EOF
@@ -19,6 +19,29 @@
};
}

/// Redact sensitive secrets (such as API keys) before logging.
/// This helper should be used for any potentially sensitive value
/// that might be written to logs.
fn redact_secret<T: AsRef<str>>(secret: T) -> String {
let s = secret.as_ref();
if s.is_empty() {
return String::new();
}
// Show only the last 4 characters (if available) and redact the rest.
let suffix_len = 4usize.min(s.len());
let suffix = &s[s.len() - suffix_len..];
format!("***redacted***{}", suffix)
}

/// Logging helper for secrets that ensures they are redacted before logging.
/// Example usage:
/// pay_debug_secret!("Using API key: {}", redact_secret(api_key));
macro_rules! pay_debug_secret {
($($arg:tt)*) => {
tracing::debug!($($arg)*)
};
}

macro_rules! pay_error {
($($arg:tt)*) => {
tracing::error!($($arg)*)
Copilot is powered by AI and may make mistakes. Always verify output.

Check failure

Code scanning / CodeQL

Cleartext transmission of sensitive information High

This 'post' operation transmits data which may contain unencrypted sensitive data from
builder.api_key(...)
.
This 'post' operation transmits data which may contain unencrypted sensitive data from
builder.api_key(...)
.

Copilot Autofix

AI about 2 months ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.


mod error_reporting;
mod observability;
Expand Down Expand Up @@ -316,18 +314,18 @@
Succeeded,
Failed,
Expired,
Unknown { value: String },
}

impl From<types::PaymentStatus> for PaymentStatus {
fn from(s: types::PaymentStatus) -> Self {
match s {
types::PaymentStatus::RequiresAction => {
PaymentStatus::RequiresAction
}
types::PaymentStatus::Processing => PaymentStatus::Processing,
types::PaymentStatus::Succeeded => PaymentStatus::Succeeded,
types::PaymentStatus::Failed => PaymentStatus::Failed,
types::PaymentStatus::Expired => PaymentStatus::Expired,
match s.as_str() {
"requires_action" => PaymentStatus::RequiresAction,
"processing" => PaymentStatus::Processing,
"succeeded" => PaymentStatus::Succeeded,
"failed" => PaymentStatus::Failed,
"expired" => PaymentStatus::Expired,
other => PaymentStatus::Unknown { value: other.to_string() },
}
}
}
Expand Down Expand Up @@ -396,16 +394,16 @@
Text,
Date,
Checkbox,
Unknown { value: String },
}

impl From<types::CollectDataFieldType> for CollectDataFieldType {
fn from(t: types::CollectDataFieldType) -> Self {
match t {
types::CollectDataFieldType::Text => CollectDataFieldType::Text,
types::CollectDataFieldType::Date => CollectDataFieldType::Date,
types::CollectDataFieldType::Checkbox => {
CollectDataFieldType::Checkbox
}
match t.as_str() {
"text" => CollectDataFieldType::Text,
"date" => CollectDataFieldType::Date,
"checkbox" => CollectDataFieldType::Checkbox,
other => CollectDataFieldType::Unknown { value: other.to_string() },
}
}
}
Expand Down Expand Up @@ -2402,4 +2400,48 @@
result
);
}

#[test]
fn test_payment_status_forward_compatible() {
assert_eq!(
PaymentStatus::from(types::PaymentStatus::from(
"succeeded".to_string()
)),
PaymentStatus::Succeeded
);
assert_eq!(
PaymentStatus::from(types::PaymentStatus::from(
"processing".to_string()
)),
PaymentStatus::Processing
);
assert_eq!(
PaymentStatus::from(types::PaymentStatus::from(
"new_future_status".to_string()
)),
PaymentStatus::Unknown { value: "new_future_status".to_string() }
);
}

#[test]
fn test_collect_data_field_type_forward_compatible() {
assert_eq!(
CollectDataFieldType::from(types::CollectDataFieldType::from(
"text".to_string()
)),
CollectDataFieldType::Text
);
assert_eq!(
CollectDataFieldType::from(types::CollectDataFieldType::from(
"date".to_string()
Comment on lines +2402 to +2434
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests verify that unknown enum values can be deserialized from the progenitor-generated types, but they don't test serialization or round-trip serialization/deserialization of the public-facing enum types. Consider adding tests that verify:

  1. Known variants serialize correctly: PaymentStatus::Succeeded"succeeded"
  2. Unknown variants serialize correctly: PaymentStatus::Unknown { value: "new_status" }"new_status"
  3. Unknown variants deserialize correctly from JSON: "new_status"PaymentStatus::Unknown { value: "new_status" }
  4. Round-trip works: serialize → deserialize → equals original

This is especially important given that the JSON API in json.rs uses serde_json::to_string to serialize these types.

Copilot uses AI. Check for mistakes.
)),
CollectDataFieldType::Date
);
assert_eq!(
CollectDataFieldType::from(types::CollectDataFieldType::from(
"future_type".to_string()
)),
CollectDataFieldType::Unknown { value: "future_type".to_string() }
);
}
}
Loading