diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52029992..4ce301d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,6 @@ on: branches: ["main"] types: [opened, synchronize, reopened, ready_for_review] -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig @@ -44,6 +43,7 @@ jobs: echo "stellar-scaffold already installed. Clear cache to force reinstall." fi - name: Build with Scaffold and build client packages + shell: bash run: STELLAR_SCAFFOLD_ENV=development stellar-scaffold build --build-clients 2>&1 | tee build_clients.log - name: Install official Stellar CLI run: | @@ -113,6 +113,3 @@ jobs: - name: Run Tests working-directory: ./frontend run: npm test --if-present - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 2655fcbc..cca80486 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -5,7 +5,6 @@ on: tags: - "v*" -# Retention policy: Keep successful runs for 90 days, failed/cancelled for 14 days permissions: # required permissions for the workflow id-token: write contents: write # in order to create releases @@ -27,6 +26,3 @@ jobs: package: "..." secrets: release_token: ${{ secrets.GITHUB_TOKEN }} - -# Workflow run retention settings -retention-days: 90 \ No newline at end of file diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 29d16e55..5887cc7e 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -9,7 +9,6 @@ on: workflow_dispatch: -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true @@ -65,6 +64,3 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "- CID: ${{ steps.storacha.outputs.cid }}" >> "$GITHUB_STEP_SUMMARY" echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index b8d3b7e8..1cea1b28 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -5,12 +5,15 @@ on: branches: ["main"] paths: - "k8s/**" + - ".github/workflows/secrets-check.yml" + - "scripts/check-k8s-secrets.sh" pull_request: branches: ["main"] paths: - "k8s/**" + - ".github/workflows/secrets-check.yml" + - "scripts/check-k8s-secrets.sh" -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days jobs: check-secrets-placeholders: name: Verify no real secrets in k8s manifests @@ -20,6 +23,3 @@ jobs: - name: Check backend-secret.yaml for non-placeholder values run: ./scripts/check-k8s-secrets.sh - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/contracts/revenue_split/src/arithmetic_test.rs b/contracts/revenue_split/src/arithmetic_test.rs new file mode 100644 index 00000000..cd82526f --- /dev/null +++ b/contracts/revenue_split/src/arithmetic_test.rs @@ -0,0 +1,98 @@ +use crate::{ContractError, RecipientShare, RevenueSplitContract, RevenueSplitContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, FromVal, Map, Symbol, Val, Vec, +}; + +#[test] +fn overflowing_share_total_is_rejected_at_init() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &id); + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + let invalid = Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: u32::MAX }, + RecipientShare { destination: last.clone(), basis_points: 10001 }, + ]); + assert_eq!(client.try_init(&admin, &invalid), Err(Ok(ContractError::SharesMustSumToTotal))); + // A failed validation must not leave the contract initialized. + let valid = Vec::from_array(&env, [ + RecipientShare { destination: first, basis_points: 6000 }, + RecipientShare { destination: last, basis_points: 4000 }, + ]); + client.init(&admin, &valid); +} + +#[test] +fn overflowing_share_update_preserves_active_split() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &id); + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + client.init(&admin, &Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: 5000 }, + RecipientShare { destination: last.clone(), basis_points: 5000 }, + ])); + let invalid = Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: u32::MAX }, + RecipientShare { destination: last.clone(), basis_points: 10001 }, + ]); + assert_eq!(client.try_update_recipients(&invalid), Err(Ok(ContractError::SharesMustSumToTotal))); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let token = TokenClient::new(&env, &token_id); + let sender = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&sender, &100); + client.distribute(&sender, &Vec::from_array(&env, [(token_id.clone(), 100)])); + assert_eq!(token.balance(&first), 50); + assert_eq!(token.balance(&last), 50); + assert_eq!(token.balance(&sender), 0); +} + +#[test] +fn large_distribution_preserves_balances_and_event_payload() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &id); + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + client.init(&admin, &Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: 6000 }, + RecipientShare { destination: last.clone(), basis_points: 4000 }, + ])); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let token = TokenClient::new(&env, &token_id); + let sender = Address::generate(&env); + let amount = i128::MAX / 2; + StellarAssetClient::new(&env, &token_id).mint(&sender, &amount); + assert_eq!(client.try_distribute(&sender, &Vec::from_array(&env, [(token_id.clone(), amount)])), Ok(Ok(()))); + + // Read the event before later token calls replace the invocation's log. + let topic = Symbol::new(&env, "distribution_executed_event"); + let mut count = 0; + for (contract, topics, data) in env.events().all().iter() { + if contract != id || topics.first().map(|value| Symbol::from_val(&env, &value)) != Some(topic.clone()) { + continue; + } + count += 1; + let fields = Map::::from_val(&env, &data); + assert_eq!(i128::from_val(&env, &fields.get(Symbol::new(&env, "total_amount")).unwrap()), amount); + assert_eq!(u32::from_val(&env, &fields.get(Symbol::new(&env, "recipient_count")).unwrap()), 2); + assert_eq!(Address::from_val(&env, &fields.get(Symbol::new(&env, "asset")).unwrap()), token_id); + assert_eq!(Vec::::from_val(&env, &fields.get(Symbol::new(&env, "split_percentages")).unwrap()), Vec::from_array(&env, [6000, 4000])); + } + assert_eq!(count, 1); + // Independent reduced-ratio oracle, avoiding the original multiply overflow. + let first_amount = (amount / 5) * 3 + ((amount % 5) * 3) / 5; + assert_eq!(token.balance(&sender), 0); + assert_eq!(token.balance(&first), first_amount); + assert_eq!(token.balance(&last), amount - first_amount); +} diff --git a/contracts/revenue_split/src/lib.rs b/contracts/revenue_split/src/lib.rs index c3158981..ea3c3a3c 100644 --- a/contracts/revenue_split/src/lib.rs +++ b/contracts/revenue_split/src/lib.rs @@ -1,10 +1,12 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Vec, token, Symbol}; +use soroban_sdk::{contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, Vec, token}; use common::CommonError; #[cfg(test)] mod test; +#[cfg(test)] +mod arithmetic_test; #[contracttype] pub enum DataKey { @@ -44,6 +46,19 @@ pub struct RecipientShare { pub const TOTAL_BASIS_POINTS: u32 = 10000; // 100% +// ── Events ──────────────────────────────────────────────────────────────────── + +/// Emitted once per asset distribution so off-chain indexers can track totals, +/// recipient counts, and the active split weights (basis points, 10000 = 100%). +#[contractevent] +pub struct DistributionExecutedEvent { + pub asset: Address, + pub total_amount: i128, + pub recipient_count: u32, + /// Share weights in basis points (10000 = 100%). + pub split_percentages: Vec, +} + #[contract] pub struct RevenueSplitContract; @@ -96,6 +111,10 @@ impl RevenueSplitContract { .get(&DataKey::Recipients) .ok_or(ContractError::NotInitialized)?; + // Build event weights only after the first successful asset distribution. + // Later events reuse the immutable host vector through cloned handles. + let mut event_split_percentages: Option> = None; + for asset_pair in assets.iter() { let token = asset_pair.0; let amount = asset_pair.1; @@ -109,25 +128,44 @@ impl RevenueSplitContract { let mut amount_distributed = 0; for (i, share) in shares.iter().enumerate() { - // Calculate slice of the total amount using basis points - // Formula: amount * basis_points / 10000 - let recipient_amount = (amount as i128 * share.basis_points as i128) / TOTAL_BASIS_POINTS as i128; - - if recipient_amount > 0 { - // To avoid precision loss dust, the last recipient takes any minor remainders. - if i as u32 == shares.len() - 1 { - let final_amount = amount - amount_distributed; - if final_amount > 0 { - client.transfer(&from, &share.destination, &final_amount); - } - } else { + // The last recipient receives the remainder even when its own + // rounded share is zero. Otherwise an event could claim that + // dust was distributed while the tokens remained with the sender. + if i as u32 == shares.len() - 1 { + let final_amount = amount - amount_distributed; + if final_amount > 0 { + client.transfer(&from, &share.destination, &final_amount); + } + } else { + // Divide first without losing the fractional contribution. + // Validated weights are <= 10000, so neither product nor + // their sum can exceed the positive input amount. + let divisor = TOTAL_BASIS_POINTS as i128; + let weight = share.basis_points as i128; + let recipient_amount = (amount / divisor) * weight + + ((amount % divisor) * weight) / divisor; + if recipient_amount > 0 { client.transfer(&from, &share.destination, &recipient_amount); amount_distributed += recipient_amount; } } } - env.events().publish((Symbol::new(&env, "distribute"), token.clone()), amount); + let split_percentages = event_split_percentages.get_or_insert_with(|| { + let mut percentages = Vec::new(&env); + for share in shares.iter() { + percentages.push_back(share.basis_points); + } + percentages + }); + + DistributionExecutedEvent { + asset: token.clone(), + total_amount: amount, + recipient_count: shares.len(), + split_percentages: split_percentages.clone(), + } + .publish(&env); } Ok(()) @@ -142,7 +180,9 @@ impl RevenueSplitContract { let mut seen: Vec
= Vec::new(env); for share in shares.iter() { - total_bp = total_bp.wrapping_add(share.basis_points); + total_bp = total_bp + .checked_add(share.basis_points) + .ok_or(ContractError::SharesMustSumToTotal)?; // Prevent duplicates; duplicates create ambiguity and can cause unexpected dust behavior. for addr in seen.iter() { diff --git a/contracts/revenue_split/src/test.rs b/contracts/revenue_split/src/test.rs index 46edc104..3e15a020 100644 --- a/contracts/revenue_split/src/test.rs +++ b/contracts/revenue_split/src/test.rs @@ -1,9 +1,11 @@ #![cfg(test)] use crate::{RevenueSplitContract, RevenueSplitContractClient, RecipientShare}; -use soroban_sdk::{testutils::{Address as _}, Address, Env, Vec}; -use soroban_sdk::token::Client as TokenClient; -use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, FromVal, Symbol, Vec, +}; fn create_token_contract<'a>(e: &Env, admin: &Address) -> (Address, StellarAssetClient<'a>, TokenClient<'a>) { e.mock_all_auths(); @@ -240,3 +242,132 @@ fn test_update_recipients() { client.update_recipients(&new_shares); } + +// ── Event emission ──────────────────────────────────────────────────────────── + +fn count_named_events(env: &Env, contract_addr: &Address, event_name: &str) -> u32 { + let target_sym = Symbol::new(env, event_name); + let mut n = 0u32; + for (addr, topics, _data) in env.events().all().iter() { + if addr != *contract_addr { + continue; + } + if topics.iter().any(|t| Symbol::from_val(env, &t) == target_sym) { + n += 1; + } + } + n +} + +#[test] +fn test_distribute_emits_distribution_executed_event() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let (token_id, stellar_asset_client, token_client) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient1 = Address::generate(&env); + let recipient2 = Address::generate(&env); + + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 6000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 4000, + }, + ], + ); + client.init(&admin, &shares); + + let sender = Address::generate(&env); + stellar_asset_client.mint(&sender, &1000); + + let assets = Vec::from_array(&env, [(token_id.clone(), 1000i128)]); + client.distribute(&sender, &assets); + + // Check events before any further host reads (balance calls can clear the log). + let n = count_named_events(&env, &client.address, "distribution_executed_event"); + assert_eq!(n, 1, "expected one DistributionExecutedEvent"); + + assert_eq!(token_client.balance(&recipient1), 600); + assert_eq!(token_client.balance(&recipient2), 400); +} + +#[test] +fn test_distribute_emits_one_event_per_asset() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let (token_id1, stellar_asset_client1, _) = create_token_contract(&env, &token_admin); + let (token_id2, stellar_asset_client2, _) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient1 = Address::generate(&env); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient1.clone(), + basis_points: 10000, + }], + ); + client.init(&admin, &shares); + + let sender = Address::generate(&env); + stellar_asset_client1.mint(&sender, &100); + stellar_asset_client2.mint(&sender, &200); + + let assets = Vec::from_array( + &env, + [(token_id1.clone(), 100i128), (token_id2.clone(), 200i128)], + ); + client.distribute(&sender, &assets); + + let n = count_named_events(&env, &client.address, "distribution_executed_event"); + assert_eq!(n, 2, "expected one DistributionExecutedEvent per asset"); +} + +#[test] +fn test_distribution_emits_truthful_total_when_last_share_rounds_to_zero() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let (token_id, stellar_asset_client, token_client) = create_token_contract(&env, &token_admin); + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + let shares = Vec::from_array( + &env, + [ + RecipientShare { destination: first.clone(), basis_points: 5000 }, + RecipientShare { destination: last.clone(), basis_points: 5000 }, + ], + ); + client.init(&admin, &shares); + + let sender = Address::generate(&env); + stellar_asset_client.mint(&sender, &1); + client.distribute(&sender, &Vec::from_array(&env, [(token_id, 1i128)])); + + assert_eq!(count_named_events(&env, &client.address, "distribution_executed_event"), 1); + assert_eq!(token_client.balance(&sender), 0); + assert_eq!(token_client.balance(&first), 0); + assert_eq!(token_client.balance(&last), 1); +} diff --git a/docs/revenue-split-arithmetic-0d09b36c.md b/docs/revenue-split-arithmetic-0d09b36c.md new file mode 100644 index 00000000..c15b4285 --- /dev/null +++ b/docs/revenue-split-arithmetic-0d09b36c.md @@ -0,0 +1,61 @@ +# Revenue split arithmetic validation — 2026-10-04 + +Product source: `0d09b36c2e732d372ebdf484d9b7c31f4f139933`. +Baseline: `e04c31bd6e594b24b4bcb7c56c5a2132e1e43921`. +Existing submission: [Protocol-Guild/PayD PR 636](https://github.com/Protocol-Guild/PayD/pull/636), issue 515. + +## Executed result + +[Native replay run 37190079744](https://github.com/woahwhattheheck/PayD/actions/runs/37190079744), job `111400243183`, completed successfully. The command below ran the actual contract and Soroban token implementation, with synthetic balances and the repository's existing authentication mocks: + +```sh +cargo test --locked -p revenue_split --lib +``` + +Result: **15 passed, 0 failed, 0 ignored, 0 filtered out**, reported test time **0.07 s**. This comprises all 12 existing package tests, unchanged, plus three arithmetic regressions. This is a correctness result, not a performance benchmark. + +| Regression | Original implementation | Repaired implementation | +| --- | --- | --- | +| Overflowing share total at initialization | Accepted `[u32::MAX, 10001]`: `Ok(Ok(()))`; regression failed, exit 101 | Returns `SharesMustSumToTotal`; subsequent valid initialization succeeds | +| Overflowing share total at update | Accepted the invalid split: `Ok(Ok(()))`; regression failed, exit 101 | Rejects update; subsequent distribution retains the prior 50/50 balances | +| Large positive distribution, `i128::MAX / 2`, 60/40 split | Returns `Err(Err(Abort))`; regression failed, exit 101 | Correct sender/recipient balances and exactly one event with the correct asset, total, count and split weights | + +Baseline execution used the original `lib.rs` plus only the test-module declaration, with the same new regression file. Each case ran separately with `--exact --nocapture`; all three reached execution and failed their assertions. The workflow then restored and hash-checked the exact repaired production file and ran all 15 tests. The lockfile and existing test source remained unchanged. + +## Repair + +Share totals use checked addition instead of modular addition. Non-final recipient amounts use the exact identity + +```text +floor(amount * weight / 10000) += (amount / 10000) * weight + floor((amount % 10000) * weight / 10000) +``` + +For positive amounts and validated weights in `0..=10000`, both intermediates and their sum fit within the input amount. Final-recipient remainder handling and the event schema are unchanged. + +## Runtime and provenance + +Ubuntu 24.04.5, x86_64; Rust 1.89.0 (`29483883e`), Cargo 1.89.0; locked Soroban SDK 23.5.2 and host 23.0.1. The repository's pinned toolchain and Cargo.lock were used; no dependency changes were made. Compilation of the test target took 58.25 s, separately from the test-execution time above. + +Git blob identities: + +- Production `lib.rs`: `9a9540dee5ffc07b251e2f335d1dcbd44ab82159`. +- New `arithmetic_test.rs`: `cd82526f8a19ff019886003617f9dc83e3bd9cf8`. +- Unchanged `test.rs`: `3e15a0202d13cce144d3ac00bcf14287373e1d17`. +- Unchanged `Cargo.lock`: `927dac874854b3fb1fb4fd697b479d2b2fcdbbbc`. + +Replay workflow source: `777ff7d754ee15a72df6801833ccd1ad93596c6d` on the separate `validation/payd-636-arithmetic-rivet76f4-20261004` branch. It has read-only repository permission, no deployment step, and is not part of the product change. Logs were uploaded as artifact `11297804221`, SHA256 `fb3dc829be1d36997de0d27792886db5fdac4f90abbcc489c1086f247d499dd3`, with one-day artifact retention; the run log also contains the results. + +The first attempt, run 37189908720, stopped at compilation because the new test compared raw SDK `Val` objects. The test-only correction compares typed `Symbol` values; no successful runtime result is attributed to that first attempt. + +## Limits + +This does not claim a full-workspace build, WASM deployment, live-chain transaction, indexer integration, maintainer acceptance or reward payment. Existing unrelated workflow failures are not declared green by this focused replay. At the validated product source above, `contract-release.yml` still contained unsupported workflow-root `retention-days`; its tag-release behavior was not exercised or changed by that replay. + +### Subsequent workflow follow-through — 2026-10-04 + +The original PR now includes the released workflow corrections through [`ba8161f9d1723c34f2fb1531457e6f5897c4a18a`](https://github.com/woahwhattheheck/PayD/commit/ba8161f9d1723c34f2fb1531457e6f5897c4a18a). The unsupported root retention settings have been removed from `contract-release.yml` and `ipfs-deploy.yml`. These are source/schema repairs; neither a production release nor an IPFS deployment was dispatched or demonstrated. Their existing triggers do not match this feature-branch push, so the absence of a run is not a successful deployment. + +The earlier [`e04c31bd6e594b24b4bcb7c56c5a2132e1e43921`](https://github.com/woahwhattheheck/PayD/commit/e04c31bd6e594b24b4bcb7c56c5a2132e1e43921) repaired the build and secrets-check workflows, restored their executable checker, and preserved failed Scaffold producer status through `tee`. The corresponding sponsor runs [37187697785](https://github.com/Protocol-Guild/PayD/actions/runs/37187697785) and [37187697799](https://github.com/Protocol-Guild/PayD/actions/runs/37187697799) were observed as `action_required`, not completed builds. + +These subsequent workflow and documentation changes leave the contract source, all 15 tests and dependency lockfile from the completed arithmetic result unchanged. That result remains pinned to `0d09b36c2e732d372ebdf484d9b7c31f4f139933`; no new execution, sponsor acceptance or reward payment is asserted. diff --git a/scripts/check-k8s-secrets.sh b/scripts/check-k8s-secrets.sh new file mode 100755 index 00000000..edc76dac --- /dev/null +++ b/scripts/check-k8s-secrets.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# check-k8s-secrets.sh — verify that k8s/base/backend-secret.yaml contains +# only the expected CHANGE_ME placeholders and no real secret values. +# +# Exit codes: +# 0 — file is clean (only placeholders) +# 1 — file contains values that are NOT known placeholders (possible leak) +# 2 — file not found or parse error +# +# Usage: +# ./scripts/check-k8s-secrets.sh # standalone +# # Also wired into .husky/pre-commit and .github/workflows/secrets-check.yml + +set -euo pipefail + +SECRET_FILE="k8s/base/backend-secret.yaml" + +if [[ ! -f "$SECRET_FILE" ]]; then + echo "ERROR: $SECRET_FILE not found." + exit 2 +fi + +# Allowed placeholder values. Any stringData value not in this set is flagged. +ALLOWED_VALUES=( + "CHANGE_ME" + "CHANGE_ME_TO_A_SECURE_RANDOM_STRING" + "postgresql://payd_user:CHANGE_ME@postgres:5432/payd_db" + "payd_user" +) + +# Read the expected flat stringData mapping. Unsupported syntax fails closed +# instead of silently stopping before an entry that the checker must inspect. +values=$(python3 - "$SECRET_FILE" <<'PY' +from pathlib import Path +import re +import sys + +try: + content = Path(sys.argv[1]).read_text() +except (OSError, UnicodeError): + sys.exit(2) + +lines = content.splitlines() +headers = [ + i for i, line in enumerate(lines) + if re.fullmatch(r'stringData:[ \t]*(?:#.*)?', line) +] +if len(headers) != 1 or re.search(r'^[ \t]*(?:data|"data"|\'data\')[ \t]*:', content, re.MULTILINE): + sys.exit(2) + +# This guard supports one Secret document, not lists or nested Secret objects. +for line in lines[:headers[0]]: + if not line.strip() or line.lstrip().startswith('#'): + continue + if line.startswith((' ', '\t')): + if re.match(r'^[ \t]*(?:stringData|"stringData"|\'stringData\')[ \t]*:', line): + sys.exit(2) + continue + if not re.fullmatch(r'(?:apiVersion:[ \t]+v1|kind:[ \t]+Secret|metadata:[ \t]*|type:[ \t]+Opaque)(?:[ \t]+#.*)?', line): + sys.exit(2) + +values = [] +keys = set() +indent = None +for line in lines[headers[0] + 1:]: + if not line.strip() or line.lstrip().startswith('#'): + continue + entry = re.fullmatch(r'( +)([A-Za-z0-9_.-]+):[ \t]*(.*)', line) + if not entry: + sys.exit(2) + spaces, key, value = entry.groups() + if indent is None: + indent = len(spaces) + if len(spaces) != indent or key in keys: + sys.exit(2) + keys.add(key) + value = value.strip() + if value.startswith('"') or value.endswith('"'): + if len(value) < 2 or not (value.startswith('"') and value.endswith('"')) or '"' in value[1:-1]: + sys.exit(2) + value = value[1:-1] + values.append(value) + +if not values: + sys.exit(2) +print('\n'.join(values)) +PY +) + +if [[ -z "$values" ]]; then + echo "ERROR: Could not parse stringData values from $SECRET_FILE" + exit 2 +fi + +failed=0 +while IFS= read -r value; do + matched=0 + for allowed in "${ALLOWED_VALUES[@]}"; do + if [[ "$value" == "$allowed" ]]; then + matched=1 + break + fi + done + if [[ $matched -eq 0 ]]; then + echo "FAIL: $SECRET_FILE contains a non-placeholder value." + echo " Real secrets must not be committed. See k8s/README.md for safe alternatives." + failed=1 + fi +done <<< "$values" + +if [[ $failed -eq 1 ]]; then + exit 1 +fi + +echo "OK: $SECRET_FILE contains only placeholder values." +exit 0