Skip to content

Commit 77b22e2

Browse files
committed
feat(sdk/cpp): provider 侧入站 payload 校验(validate_input_payloads)
- ClientConfig.validate_input_payloads(默认关) - MSG_INVOKE_REQUEST 派发前按函数 input schema 校验(复用 JsonUtils::ValidateJsonSchema),失败回 {"error":"payload validation failed"},handler 不调用; schema 缺失跳过(服务端权威校验) - 4 例真实 TCP 帧回路测试(违规/合法/关闭兼容/无 schema)
1 parent a5ab8b9 commit 77b22e2

3 files changed

Lines changed: 153 additions & 0 deletions

File tree

sdks/cpp/include/croupier/sdk/croupier_client.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ struct ClientConfig {
7575

7676
bool insecure = true; // For development; set false for production with TLS
7777

78+
// ========== Inbound Payload Validation ==========
79+
// When true, provider-side dispatch validates incoming invoke payloads
80+
// against the function's declared input schema before invoking the
81+
// handler. Server remains the authoritative validator.
82+
bool validate_input_payloads = false;
83+
7884
// ========== Optional TLS Configuration ==========
7985
std::string cert_file; // Client certificate file path
8086
std::string key_file; // Client private key file path

sdks/cpp/src/croupier_client.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,28 @@ class CroupierClient::Impl {
487487
}
488488
std::string context = "{}";
489489
std::string payload(req.payload().begin(), req.payload().end());
490+
491+
// Provider 侧入站校验(可选):按函数声明的 input schema 校验
492+
// payload,失败回错误响应,handler 不被调用(服务端仍是权威
493+
// 校验方)。schema 缺失/为空跳过。
494+
if (config_.validate_input_payloads) {
495+
std::string input_schema;
496+
{
497+
std::lock_guard<std::mutex> lock(transport_mutex_);
498+
auto descIt = descriptors_.find(req.function_id());
499+
if (descIt != descriptors_.end()) {
500+
input_schema = descIt->second.input_schema;
501+
}
502+
}
503+
if (!input_schema.empty() &&
504+
!::croupier::sdk::utils::JsonUtils::ValidateJsonSchema(payload, input_schema)) {
505+
SDK_LOG_ERROR("Agent invoke: payload validation failed: " + req.function_id());
506+
::croupier::sdk::v1::InvokeResponse errResp;
507+
errResp.set_payload("{\"error\":\"payload validation failed\"}");
508+
return SerializeMessage(errResp);
509+
}
510+
}
511+
490512
std::string result = it->second(context, payload);
491513
::croupier::sdk::v1::InvokeResponse resp;
492514
resp.set_payload(result);

sdks/cpp/tests/test_provider_inbound.cpp

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,3 +429,128 @@ TEST(ProviderInboundTest, AgentDrainIsIdempotent) {
429429

430430
} // namespace
431431
} // namespace croupier::sdk::test
432+
433+
namespace croupier::sdk::test {
434+
435+
// ===== F:Provider 侧入站 payload 校验 =====
436+
437+
TEST(ProviderInboundTest, InputValidationRejectsInvalidPayload) {
438+
RawFakeAgent agent;
439+
std::thread agent_thread([&] { agent.AcceptAndHandshake(); });
440+
441+
ClientConfig config = ProviderConfig(agent.address());
442+
config.validate_input_payloads = true;
443+
CroupierClient client(config);
444+
FunctionDescriptor desc;
445+
desc.id = "player.ban";
446+
desc.version = "1.0.0";
447+
desc.capability = "action";
448+
desc.risk = "high";
449+
desc.input_schema =
450+
R"({"type":"object","properties":{"id":{"type":"string"}},"required":["id"]})";
451+
std::atomic<int> calls{0};
452+
ASSERT_TRUE(client.RegisterFunction(
453+
desc, [&](const std::string&, const std::string&) {
454+
calls.fetch_add(1);
455+
return std::string("ok");
456+
}));
457+
ASSERT_TRUE(client.Connect());
458+
agent_thread.join();
459+
460+
// 缺 required 字段:回错误 payload,handler 不被调用
461+
agent.PushRequest(protocol::MSG_INVOKE_REQUEST, 9101, InvokeBody("player.ban", "{}"));
462+
auto resp = agent.ReadResponseFor(9101);
463+
v1::InvokeResponse parsed;
464+
ASSERT_TRUE(parsed.ParseFromArray(resp.body.data(), static_cast<int>(resp.body.size())));
465+
EXPECT_NE(parsed.payload().find("payload validation failed"), std::string::npos)
466+
<< "payload=" << parsed.payload();
467+
EXPECT_EQ(calls.load(), 0);
468+
469+
client.Close();
470+
}
471+
472+
TEST(ProviderInboundTest, InputValidationPassesValidPayload) {
473+
RawFakeAgent agent;
474+
std::thread agent_thread([&] { agent.AcceptAndHandshake(); });
475+
476+
ClientConfig config = ProviderConfig(agent.address());
477+
config.validate_input_payloads = true;
478+
CroupierClient client(config);
479+
FunctionDescriptor desc;
480+
desc.id = "player.ban";
481+
desc.version = "1.0.0";
482+
desc.input_schema =
483+
R"({"type":"object","properties":{"id":{"type":"string"}},"required":["id"]})";
484+
ASSERT_TRUE(client.RegisterFunction(
485+
desc, [&](const std::string&, const std::string& payload) {
486+
return "ban:" + payload;
487+
}));
488+
ASSERT_TRUE(client.Connect());
489+
agent_thread.join();
490+
491+
agent.PushRequest(protocol::MSG_INVOKE_REQUEST, 9102,
492+
InvokeBody("player.ban", R"({"id":"p1"})"));
493+
auto resp = agent.ReadResponseFor(9102);
494+
v1::InvokeResponse parsed;
495+
ASSERT_TRUE(parsed.ParseFromArray(resp.body.data(), static_cast<int>(resp.body.size())));
496+
EXPECT_EQ(parsed.payload(), "ban:{\"id\":\"p1\"}");
497+
498+
client.Close();
499+
}
500+
501+
TEST(ProviderInboundTest, InputValidationDisabledKeepsLegacyBehavior) {
502+
RawFakeAgent agent;
503+
std::thread agent_thread([&] { agent.AcceptAndHandshake(); });
504+
505+
ClientConfig config = ProviderConfig(agent.address());
506+
// validate_input_payloads 默认 false
507+
CroupierClient client(config);
508+
FunctionDescriptor desc;
509+
desc.id = "player.ban";
510+
desc.version = "1.0.0";
511+
desc.input_schema =
512+
R"({"type":"object","properties":{"id":{"type":"string"}},"required":["id"]})";
513+
std::atomic<int> calls{0};
514+
ASSERT_TRUE(client.RegisterFunction(
515+
desc, [&](const std::string&, const std::string&) {
516+
calls.fetch_add(1);
517+
return std::string("ok");
518+
}));
519+
ASSERT_TRUE(client.Connect());
520+
agent_thread.join();
521+
522+
agent.PushRequest(protocol::MSG_INVOKE_REQUEST, 9103, InvokeBody("player.ban", "{}"));
523+
auto resp = agent.ReadResponseFor(9103);
524+
v1::InvokeResponse parsed;
525+
ASSERT_TRUE(parsed.ParseFromArray(resp.body.data(), static_cast<int>(resp.body.size())));
526+
EXPECT_EQ(parsed.payload(), "ok");
527+
EXPECT_GE(calls.load(), 1);
528+
529+
client.Close();
530+
}
531+
532+
TEST(ProviderInboundTest, InputValidationSkipsWhenSchemaMissing) {
533+
RawFakeAgent agent;
534+
std::thread agent_thread([&] { agent.AcceptAndHandshake(); });
535+
536+
ClientConfig config = ProviderConfig(agent.address());
537+
config.validate_input_payloads = true;
538+
CroupierClient client(config);
539+
FunctionDescriptor desc;
540+
desc.id = "player.free"; // 未声明 input_schema
541+
desc.version = "1.0.0";
542+
ASSERT_TRUE(client.RegisterFunction(
543+
desc, [](const std::string&, const std::string&) { return std::string("ok"); }));
544+
ASSERT_TRUE(client.Connect());
545+
agent_thread.join();
546+
547+
agent.PushRequest(protocol::MSG_INVOKE_REQUEST, 9104, InvokeBody("player.free", "{}"));
548+
auto resp = agent.ReadResponseFor(9104);
549+
v1::InvokeResponse parsed;
550+
ASSERT_TRUE(parsed.ParseFromArray(resp.body.data(), static_cast<int>(resp.body.size())));
551+
EXPECT_EQ(parsed.payload(), "ok");
552+
553+
client.Close();
554+
}
555+
556+
} // namespace croupier::sdk::test

0 commit comments

Comments
 (0)