1use std::{collections::BTreeMap, fs, path::Path};
2
3use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6
7use crate::{
8 Client, SdkError,
9 local::{
10 LocalProtectionRequest, LocalSigningKey, LocalSymmetricKey, LocalSymmetricKeySource,
11 LocalVerifyingKey,
12 },
13 models::{
14 ArtifactProfile, EvidenceEventType, KeyAccessOperation, KeyTransportGuidance,
15 KeyTransportMode, ProtectionOperation, ResourceDescriptor, SdkArtifactRegisterRequest,
16 SdkEvidenceIngestRequest, SdkKeyAccessPlanRequest, SdkPolicyResolveRequest,
17 SdkProtectionPlanRequest, WorkloadDescriptor,
18 },
19};
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct IntegrationFullScenarioStep {
23 pub name: String,
24 pub description: String,
25 #[serde(default)]
26 pub sdk_methods: Vec<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct IntegrationFullManifest {
31 pub name: String,
32 pub version: u32,
33 pub description: String,
34 pub tenant_id: String,
35 pub principal_id: String,
36 pub subject: String,
37 #[serde(default)]
38 pub default_required_scopes: Vec<String>,
39 pub policy_scope: String,
40 pub workload: WorkloadDescriptor,
41 pub resource: ResourceDescriptor,
42 pub purpose: String,
43 pub plaintext_utf8: String,
44 pub content_digest: String,
45 #[serde(default)]
46 pub labels: Vec<String>,
47 #[serde(default)]
48 pub attributes: BTreeMap<String, String>,
49 pub inline_key_b64: String,
50 pub rewrap_key_b64: String,
51 pub managed_key_provider_name: String,
52 pub managed_key_reference: String,
53 pub managed_key_b64: String,
54 pub managed_rewrap_key_reference: String,
55 pub managed_rewrap_key_b64: String,
56 pub signing_key_b64: String,
57 pub verifying_key_b64: String,
58 #[serde(default)]
59 pub steps: Vec<IntegrationFullScenarioStep>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct IntegrationFullRunSummary {
64 pub runner: String,
65 pub scenario: String,
66 pub version: u32,
67 pub tenant_id: String,
68 pub steps: Vec<Value>,
69}
70
71pub fn load_integration_full_manifest(
72 path: impl AsRef<Path>,
73) -> Result<IntegrationFullManifest, SdkError> {
74 let contents = fs::read_to_string(path.as_ref()).map_err(|error| {
75 SdkError::InvalidInput(format!(
76 "failed to read integration-full manifest {}: {error}",
77 path.as_ref().display()
78 ))
79 })?;
80 serde_json::from_str(&contents).map_err(|error| {
81 SdkError::Serialization(format!(
82 "failed to decode integration-full manifest {}: {error}",
83 path.as_ref().display()
84 ))
85 })
86}
87
88fn uses_managed_key_transport(key_transport: Option<&KeyTransportGuidance>) -> bool {
89 !matches!(
90 key_transport.map(|guidance| guidance.mode),
91 None | Some(KeyTransportMode::LocalProvided)
92 )
93}
94
95fn requires_managed_envelope_key(error: &SdkError) -> bool {
96 matches!(
97 error,
98 SdkError::InvalidInput(message)
99 if message.contains(
100 "local envelope protection requires a managed key reference when key_transport.mode is wrapped_key_reference"
101 )
102 )
103}
104
105pub fn run_integration_full_scenario(
106 client: &Client,
107 manifest: &IntegrationFullManifest,
108) -> Result<IntegrationFullRunSummary, SdkError> {
109 let plaintext = manifest.plaintext_utf8.as_bytes();
110 let inline_key =
111 LocalSymmetricKey::new(decode_fixed_32("inline_key_b64", &manifest.inline_key_b64)?);
112 let rewrap_key =
113 LocalSymmetricKey::new(decode_fixed_32("rewrap_key_b64", &manifest.rewrap_key_b64)?);
114 let signing_key = LocalSigningKey::new(decode_fixed_32(
115 "signing_key_b64",
116 &manifest.signing_key_b64,
117 )?);
118 let verifying_key = LocalVerifyingKey::from(decode_fixed_32(
119 "verifying_key_b64",
120 &manifest.verifying_key_b64,
121 )?);
122
123 let workload = manifest.workload.clone();
124 let resource = manifest.resource.clone();
125 let labels = manifest.labels.clone();
126 let attributes = manifest.attributes.clone();
127
128 let envelope_request = LocalProtectionRequest {
129 workload: workload.clone(),
130 resource: resource.clone(),
131 preferred_artifact_profile: Some(ArtifactProfile::Envelope),
132 purpose: Some(manifest.purpose.clone()),
133 labels: labels.clone(),
134 attributes: attributes.clone(),
135 };
136 let tdf_request = LocalProtectionRequest {
137 workload: workload.clone(),
138 resource: resource.clone(),
139 preferred_artifact_profile: Some(ArtifactProfile::Tdf),
140 purpose: Some(manifest.purpose.clone()),
141 labels: labels.clone(),
142 attributes: attributes.clone(),
143 };
144 let detached_signature_request = LocalProtectionRequest {
145 workload: workload.clone(),
146 resource: resource.clone(),
147 preferred_artifact_profile: Some(ArtifactProfile::DetachedSignature),
148 purpose: Some(manifest.purpose.clone()),
149 labels: labels.clone(),
150 attributes: attributes.clone(),
151 };
152
153 let capabilities = client.capabilities()?;
154 let bootstrap = client.bootstrap()?;
155 let whoami = client.whoami()?;
156 let prepared = client.prepare_local_protection(plaintext, envelope_request.clone())?;
157 let cid_binding = client.generate_cid_binding(plaintext, envelope_request.clone())?;
158 let protection_plan = client.protection_plan(&SdkProtectionPlanRequest {
159 operation: ProtectionOperation::Protect,
160 workload: workload.clone(),
161 resource: resource.clone(),
162 preferred_artifact_profile: Some(ArtifactProfile::Tdf),
163 content_digest: Some(manifest.content_digest.clone()),
164 content_size_bytes: Some(plaintext.len() as u64),
165 purpose: Some(manifest.purpose.clone()),
166 labels: labels.clone(),
167 attributes: attributes.clone(),
168 })?;
169 let policy_resolution = client.policy_resolve(&SdkPolicyResolveRequest {
170 operation: ProtectionOperation::Protect,
171 workload: workload.clone(),
172 resource: resource.clone(),
173 content_digest: Some(manifest.content_digest.clone()),
174 content_size_bytes: Some(plaintext.len() as u64),
175 purpose: Some(manifest.purpose.clone()),
176 labels: labels.clone(),
177 attributes: attributes.clone(),
178 })?;
179 let key_access_plan = client.key_access_plan(&SdkKeyAccessPlanRequest {
180 operation: KeyAccessOperation::Wrap,
181 workload: workload.clone(),
182 resource: resource.clone(),
183 artifact_profile: Some(ArtifactProfile::Tdf),
184 key_reference: Some(manifest.managed_key_reference.clone()),
185 content_digest: Some(manifest.content_digest.clone()),
186 purpose: Some(manifest.purpose.clone()),
187 labels: labels.clone(),
188 attributes: attributes.clone(),
189 })?;
190 let envelope_key_access_plan = client.key_access_plan(&SdkKeyAccessPlanRequest {
191 operation: KeyAccessOperation::Wrap,
192 workload: workload.clone(),
193 resource: resource.clone(),
194 artifact_profile: Some(ArtifactProfile::Envelope),
195 key_reference: Some(manifest.managed_key_reference.clone()),
196 content_digest: Some(manifest.content_digest.clone()),
197 purpose: Some(manifest.purpose.clone()),
198 labels: labels.clone(),
199 attributes: attributes.clone(),
200 })?;
201 let mut use_managed_envelope_keys =
202 uses_managed_key_transport(envelope_key_access_plan.execution.key_transport.as_ref());
203
204 let managed_key_source = LocalSymmetricKeySource::managed_reference_with_provider(
205 manifest.managed_key_provider_name.clone(),
206 manifest.managed_key_reference.clone(),
207 );
208 let managed_rewrap_key_source = LocalSymmetricKeySource::managed_reference_with_provider(
209 manifest.managed_key_provider_name.clone(),
210 manifest.managed_rewrap_key_reference.clone(),
211 );
212
213 let signed_detached = client.sign_bytes_with_detached_signature(
214 &signing_key,
215 plaintext,
216 detached_signature_request,
217 )?;
218 let verified_detached = client.verify_bytes_with_detached_signature(
219 &verifying_key,
220 plaintext,
221 &signed_detached.artifact.artifact_bytes,
222 )?;
223
224 let protected_envelope = if use_managed_envelope_keys {
225 client.protect_bytes_with_envelope_using_key_source(
226 &managed_key_source,
227 plaintext,
228 envelope_request,
229 )?
230 } else {
231 match client.protect_bytes_with_envelope(&inline_key, plaintext, envelope_request.clone()) {
232 Ok(result) => result,
233 Err(error) if requires_managed_envelope_key(&error) => {
234 use_managed_envelope_keys = true;
235 client.protect_bytes_with_envelope_using_key_source(
236 &managed_key_source,
237 plaintext,
238 envelope_request,
239 )?
240 }
241 Err(error) => return Err(error),
242 }
243 };
244 let accessed_envelope = if use_managed_envelope_keys {
245 client.access_bytes_with_envelope_using_key_source(
246 &managed_key_source,
247 &protected_envelope.artifact.artifact_bytes,
248 )?
249 } else {
250 client
251 .access_bytes_with_envelope(&inline_key, &protected_envelope.artifact.artifact_bytes)?
252 };
253 let rewrapped_envelope = if use_managed_envelope_keys {
254 client.rewrap_bytes_with_envelope_using_key_sources(
255 &managed_key_source,
256 &managed_rewrap_key_source,
257 &protected_envelope.artifact.artifact_bytes,
258 )?
259 } else {
260 client.rewrap_bytes_with_envelope(
261 &inline_key,
262 &rewrap_key,
263 &protected_envelope.artifact.artifact_bytes,
264 )?
265 };
266
267 let protected_tdf = client.protect_bytes_with_tdf_using_key_source(
268 &managed_key_source,
269 plaintext,
270 tdf_request,
271 )?;
272 let accessed_tdf = client.access_bytes_with_tdf_using_key_source(
273 &managed_key_source,
274 &protected_tdf.artifact.artifact_bytes,
275 )?;
276 let rewrapped_tdf = client.rewrap_bytes_with_tdf_using_key_sources(
277 &managed_key_source,
278 &managed_rewrap_key_source,
279 &protected_tdf.artifact.artifact_bytes,
280 )?;
281 let direct_artifact_registration = client.artifact_register(&SdkArtifactRegisterRequest {
282 operation: ProtectionOperation::Rewrap,
283 workload: workload.clone(),
284 resource: resource.clone(),
285 artifact_profile: ArtifactProfile::Tdf,
286 artifact_digest: rewrapped_tdf.artifact.artifact_digest.clone(),
287 artifact_locator: None,
288 decision_id: None,
289 key_reference: Some(manifest.managed_rewrap_key_reference.clone()),
290 purpose: Some(manifest.purpose.clone()),
291 labels: labels.clone(),
292 attributes: attributes.clone(),
293 })?;
294 let direct_evidence = client.evidence(&SdkEvidenceIngestRequest {
295 event_type: EvidenceEventType::Rewrap,
296 workload,
297 resource,
298 artifact_profile: Some(ArtifactProfile::Tdf),
299 artifact_digest: Some(rewrapped_tdf.artifact.artifact_digest.clone()),
300 decision_id: None,
301 outcome: Some("success".to_string()),
302 occurred_at: None,
303 purpose: Some(manifest.purpose.clone()),
304 labels,
305 attributes,
306 })?;
307
308 Ok(IntegrationFullRunSummary {
309 runner: "sdk-rust".to_string(),
310 scenario: manifest.name.clone(),
311 version: manifest.version,
312 tenant_id: manifest.tenant_id.clone(),
313 steps: vec![
314 integration_full_step(json!({
315 "name": "capabilities",
316 "tenant_id": capabilities.caller.tenant_id,
317 "auth_mode": capabilities.auth_configuration.mode,
318 })),
319 integration_full_step(json!({
320 "name": "bootstrap",
321 "supported_operations": bootstrap.supported_operations,
322 })),
323 integration_full_step(json!({
324 "name": "whoami",
325 "principal_id": whoami.caller.principal_id,
326 })),
327 integration_full_step(json!({
328 "name": "prepare_local_protection",
329 "content_digest": prepared.content_binding.content_digest,
330 })),
331 integration_full_step(json!({
332 "name": "generate_cid_binding",
333 "binding_hash": cid_binding.binding_hash,
334 })),
335 integration_full_step(json!({
336 "name": "protection_plan",
337 "protect_locally": protection_plan.execution.protect_locally,
338 })),
339 integration_full_step(json!({
340 "name": "policy_resolve",
341 "allow": policy_resolution.decision.allow,
342 })),
343 integration_full_step(json!({
344 "name": "key_access_plan",
345 "local_cryptographic_operation": key_access_plan.execution.local_cryptographic_operation,
346 })),
347 integration_full_step(json!({
348 "name": "detached_signature_round_trip",
349 "artifact_digest": signed_detached.artifact.artifact_digest,
350 "signer_key_id": signed_detached.artifact.detached_signature.signer_key_id,
351 "verified_content_digest": verified_detached.content_binding.content_digest,
352 })),
353 integration_full_step(json!({
354 "name": "envelope_round_trip",
355 "artifact_digest": protected_envelope.artifact.artifact_digest,
356 "plaintext_utf8": String::from_utf8_lossy(&accessed_envelope.plaintext).to_string(),
357 })),
358 integration_full_step(json!({
359 "name": "envelope_rewrap",
360 "original_artifact_digest": rewrapped_envelope.original_artifact_digest,
361 "artifact_digest": rewrapped_envelope.artifact.artifact_digest,
362 })),
363 integration_full_step(json!({
364 "name": "tdf_round_trip",
365 "artifact_digest": protected_tdf.artifact.artifact_digest,
366 "plaintext_utf8": String::from_utf8_lossy(&accessed_tdf.plaintext).to_string(),
367 })),
368 integration_full_step(json!({
369 "name": "tdf_rewrap",
370 "original_artifact_digest": rewrapped_tdf.original_artifact_digest,
371 "artifact_digest": rewrapped_tdf.artifact.artifact_digest,
372 })),
373 integration_full_step(json!({
374 "name": "artifact_register_direct",
375 "artifact_digest": direct_artifact_registration.request_summary.artifact_digest,
376 "accepted": direct_artifact_registration.registration.accepted,
377 })),
378 integration_full_step(json!({
379 "name": "evidence_direct",
380 "event_type": direct_evidence.request_summary.event_type,
381 "accepted": direct_evidence.ingestion.accepted,
382 })),
383 ],
384 })
385}
386
387fn integration_full_step(mut step: Value) -> Value {
388 if let Value::Object(ref mut map) = step {
389 map.insert("status".to_string(), Value::String("ok".to_string()));
390 }
391 step
392}
393
394fn decode_fixed_32(label: &str, value: &str) -> Result<[u8; 32], SdkError> {
395 let bytes = BASE64_STANDARD.decode(value).map_err(|error| {
396 SdkError::InvalidInput(format!("failed to decode {label} from base64: {error}"))
397 })?;
398 if bytes.len() != 32 {
399 return Err(SdkError::InvalidInput(format!(
400 "{label} must decode to 32 bytes, got {}",
401 bytes.len()
402 )));
403 }
404 let mut out = [0u8; 32];
405 out.copy_from_slice(&bytes);
406 Ok(out)
407}
408
409#[cfg(test)]
410mod tests {
411 use std::{
412 collections::BTreeMap,
413 env,
414 io::{Read, Write},
415 net::{TcpListener, TcpStream},
416 path::PathBuf,
417 sync::{
418 Arc,
419 atomic::{AtomicBool, Ordering},
420 },
421 thread,
422 time::Duration,
423 };
424
425 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
426 use serde_json::Value;
427 use serde_json::json;
428
429 use crate::{Client, local::LocalSymmetricKey, providers::InMemoryManagedSymmetricKeyProvider};
430
431 use super::{
432 IntegrationFullManifest, load_integration_full_manifest, run_integration_full_scenario,
433 };
434
435 struct ContractServer {
436 base_url: String,
437 address: String,
438 running: Arc<AtomicBool>,
439 handle: Option<thread::JoinHandle<()>>,
440 }
441
442 impl Drop for ContractServer {
443 fn drop(&mut self) {
444 self.running.store(false, Ordering::SeqCst);
445 let _ = TcpStream::connect(&self.address);
446 if let Some(handle) = self.handle.take() {
447 let _ = handle.join();
448 }
449 }
450 }
451
452 #[test]
453 fn loads_integration_full_manifest() {
454 let manifest =
455 load_integration_full_manifest(fixture_manifest_path()).expect("manifest should load");
456 assert_eq!(manifest.name, "integration-full");
457 assert_eq!(manifest.steps.len(), 15);
458 }
459
460 #[test]
461 fn runs_integration_full_scenario_against_contract_server() {
462 let manifest =
463 load_integration_full_manifest(fixture_manifest_path()).expect("manifest should load");
464 let server = spawn_contract_server(manifest.clone());
465
466 let managed_key = decode_test_key(&manifest.managed_key_b64);
467 let managed_rewrap_key = decode_test_key(&manifest.managed_rewrap_key_b64);
468 let mut keys = BTreeMap::new();
469 keys.insert(
470 manifest.managed_key_reference.clone(),
471 LocalSymmetricKey::new(managed_key),
472 );
473 keys.insert(
474 manifest.managed_rewrap_key_reference.clone(),
475 LocalSymmetricKey::new(managed_rewrap_key),
476 );
477
478 let client = Client::builder(server.base_url.clone())
479 .with_managed_symmetric_key_provider(InMemoryManagedSymmetricKeyProvider::new(
480 manifest.managed_key_provider_name.clone(),
481 keys,
482 ))
483 .build()
484 .expect("client should build");
485
486 let summary =
487 run_integration_full_scenario(&client, &manifest).expect("runner should succeed");
488
489 assert_eq!(summary.runner, "sdk-rust");
490 assert_eq!(summary.steps.len(), 15);
491 assert_eq!(summary.steps[8]["name"], "detached_signature_round_trip");
492 assert_eq!(summary.steps[12]["name"], "tdf_rewrap");
493 assert_eq!(summary.steps[13]["accepted"], true);
494 assert_eq!(summary.steps[14]["accepted"], true);
495 }
496
497 fn fixture_manifest_path() -> PathBuf {
498 if let Ok(configured) = env::var("SDK_API_E2E_INTEGRATION_FULL_MANIFEST_PATH") {
499 let configured = configured.trim();
500 if !configured.is_empty() {
501 return PathBuf::from(configured);
502 }
503 }
504
505 let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
506 let candidates = [
507 base.join("..")
508 .join("prop-system-tests")
509 .join("fixtures")
510 .join("sdk_api_e2e")
511 .join("integration_full_manifest.json"),
512 base.join("prop-system-tests")
513 .join("fixtures")
514 .join("sdk_api_e2e")
515 .join("integration_full_manifest.json"),
516 ];
517
518 for candidate in candidates {
519 if candidate.exists() {
520 return candidate;
521 }
522 }
523
524 panic!(
525 "integration-full manifest not found; set SDK_API_E2E_INTEGRATION_FULL_MANIFEST_PATH"
526 )
527 }
528
529 fn decode_test_key(value: &str) -> [u8; 32] {
530 let bytes = BASE64_STANDARD.decode(value).expect("valid test key");
531 let mut out = [0u8; 32];
532 out.copy_from_slice(&bytes);
533 out
534 }
535
536 fn spawn_contract_server(manifest: IntegrationFullManifest) -> ContractServer {
537 let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
538 listener
539 .set_nonblocking(true)
540 .expect("listener should be nonblocking");
541 let address = listener.local_addr().expect("listener addr").to_string();
542 let base_url = format!("http://{address}");
543 let running = Arc::new(AtomicBool::new(true));
544 let running_clone = Arc::clone(&running);
545
546 let handle = thread::spawn(move || {
547 while running_clone.load(Ordering::SeqCst) {
548 match listener.accept() {
549 Ok((mut stream, _)) => {
550 let _ = stream.set_nonblocking(false);
551 let request = match read_http_request(&mut stream) {
552 Ok(request) => request,
553 Err(_) => continue,
554 };
555 let response_body = response_json(&manifest, &request.path, &request.body);
556 let response = format!(
557 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
558 response_body.len(),
559 response_body
560 );
561 let _ = stream.write_all(response.as_bytes());
562 let _ = stream.flush();
563 }
564 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
565 thread::sleep(Duration::from_millis(10));
566 }
567 Err(_) => break,
568 }
569 }
570 });
571
572 ContractServer {
573 base_url,
574 address,
575 running,
576 handle: Some(handle),
577 }
578 }
579
580 struct HttpRequest {
581 path: String,
582 body: String,
583 }
584
585 fn read_http_request(stream: &mut TcpStream) -> std::io::Result<HttpRequest> {
586 stream.set_read_timeout(Some(Duration::from_secs(1)))?;
587
588 let mut buffer = Vec::new();
589 let mut chunk = [0u8; 4096];
590
591 loop {
592 let read = stream.read(&mut chunk)?;
593 if read == 0 {
594 break;
595 }
596 buffer.extend_from_slice(&chunk[..read]);
597 if let Some(header_end) = find_header_end(&buffer) {
598 let content_length = parse_content_length(&buffer[..header_end]);
599 let body_len = buffer.len().saturating_sub(header_end + 4);
600 if body_len >= content_length {
601 break;
602 }
603 }
604 }
605
606 let request = String::from_utf8_lossy(&buffer).to_string();
607 let (head, body) = request.split_once("\r\n\r\n").unwrap_or((&request, ""));
608 let request_line = head.lines().next().unwrap_or("");
609 let path = request_line
610 .split_whitespace()
611 .nth(1)
612 .unwrap_or("/")
613 .to_string();
614 Ok(HttpRequest {
615 path,
616 body: body.to_string(),
617 })
618 }
619
620 fn find_header_end(buffer: &[u8]) -> Option<usize> {
621 buffer.windows(4).position(|window| window == b"\r\n\r\n")
622 }
623
624 fn parse_content_length(headers: &[u8]) -> usize {
625 let text = String::from_utf8_lossy(headers);
626 text.lines()
627 .find_map(|line| {
628 let (name, value) = line.split_once(':')?;
629 if name.eq_ignore_ascii_case("content-length") {
630 value.trim().parse::<usize>().ok()
631 } else {
632 None
633 }
634 })
635 .unwrap_or(0)
636 }
637
638 fn response_json(manifest: &IntegrationFullManifest, path: &str, body: &str) -> String {
639 match path {
640 "/v1/sdk/capabilities" => json!({
641 "service": "lattix-platform-api",
642 "status": "ready",
643 "auth_mode": "bearer_token",
644 "auth_configuration": auth_configuration(),
645 "caller": caller(manifest),
646 "default_required_scopes": manifest.default_required_scopes,
647 "routes": [],
648 })
649 .to_string(),
650 "/v1/sdk/bootstrap" => json!({
651 "service": "lattix-platform-api",
652 "status": "ready",
653 "auth_mode": "bearer_token",
654 "auth_configuration": auth_configuration(),
655 "caller": caller(manifest),
656 "enforcement_model": "embedded_local_library",
657 "plaintext_to_platform": false,
658 "policy_resolution_mode": "metadata_only_control_plane",
659 "supported_operations": ["protect", "access", "rewrap"],
660 "supported_artifact_profiles": ["tdf", "envelope", "detached_signature"],
661 "platform_domains": [{"domain": "policy", "configured": true, "reason": "metadata-only"}],
662 })
663 .to_string(),
664 "/v1/sdk/whoami" => json!({
665 "service": "lattix-platform-api",
666 "status": "ok",
667 "caller": caller(manifest),
668 })
669 .to_string(),
670 "/v1/sdk/policy-resolve" => json!(policy_response(manifest, request_operation(body)))
671 .to_string(),
672 "/v1/sdk/protection-plan" => {
673 json!(protection_plan_response(manifest, request_operation(body), request_profile(body)))
674 .to_string()
675 }
676 "/v1/sdk/key-access-plan" => {
677 json!(key_access_plan_response(manifest, request_operation(body), request_profile(body)))
678 .to_string()
679 }
680 "/v1/sdk/artifact-register" => {
681 json!(artifact_register_response(manifest, request_operation(body), request_profile(body), request_artifact_digest(body)))
682 .to_string()
683 }
684 "/v1/sdk/evidence" => {
685 json!(evidence_response(manifest, request_event_type(body), request_profile(body)))
686 .to_string()
687 }
688 _ => json!({"status": "unexpected", "path": path}).to_string(),
689 }
690 }
691
692 fn auth_configuration() -> Value {
693 json!({
694 "mode": "oauth_client_credentials",
695 "proof_of_possession": "mtls",
696 "oidc_issuer": "https://issuer.example",
697 "oidc_audience": "lattix-platform-api",
698 "oidc_issuer_ready": true,
699 "mtls_ready": true,
700 })
701 }
702
703 fn caller(manifest: &IntegrationFullManifest) -> Value {
704 json!({
705 "tenant_id": manifest.tenant_id,
706 "principal_id": manifest.principal_id,
707 "subject": manifest.subject,
708 "auth_source": "bearer_token",
709 "scopes": manifest.default_required_scopes,
710 })
711 }
712
713 fn policy_response(manifest: &IntegrationFullManifest, operation: &str) -> Value {
714 json!({
715 "service": "lattix-platform-api",
716 "status": "ready",
717 "caller": caller(manifest),
718 "request_summary": {
719 "operation": operation,
720 "workload_application": manifest.workload.application,
721 "workload_environment": manifest.workload.environment,
722 "workload_component": manifest.workload.component,
723 "resource_kind": manifest.resource.kind,
724 "resource_id": manifest.resource.id,
725 "mime_type": manifest.resource.mime_type,
726 "content_digest_present": true,
727 "content_size_bytes": manifest.plaintext_utf8.len(),
728 "purpose": manifest.purpose,
729 "label_count": manifest.labels.len(),
730 "attribute_count": manifest.attributes.len(),
731 },
732 "decision": {
733 "allow": true,
734 "enforcement_mode": "local_embedded_enforcement",
735 "required_scopes": [],
736 "policy_inputs": [],
737 "required_actions": [],
738 },
739 "handling": {
740 "protect_locally": true,
741 "plaintext_transport": "forbidden_by_default",
742 "bind_policy_to": ["artifact_digest", "content_digest"],
743 "evidence_expected": [],
744 },
745 "platform_domains": [],
746 "warnings": [],
747 })
748 }
749
750 fn protection_plan_response(
751 manifest: &IntegrationFullManifest,
752 operation: &str,
753 profile: &str,
754 ) -> Value {
755 let key_transport = if profile == "tdf" {
756 json!({
757 "mode": "wrapped_key_reference",
758 "key_material_origin": "kms",
759 "stable_key_reference_preferred": true,
760 "raw_key_delivery_forbidden": true,
761 })
762 } else {
763 Value::Null
764 };
765 json!({
766 "service": "lattix-platform-api",
767 "status": "ready",
768 "caller": caller(manifest),
769 "request_summary": {
770 "operation": operation,
771 "workload_application": manifest.workload.application,
772 "workload_environment": manifest.workload.environment,
773 "workload_component": manifest.workload.component,
774 "resource_kind": manifest.resource.kind,
775 "resource_id": manifest.resource.id,
776 "mime_type": manifest.resource.mime_type,
777 "preferred_artifact_profile": profile,
778 "content_digest_present": true,
779 "content_size_bytes": manifest.plaintext_utf8.len(),
780 "label_count": manifest.labels.len(),
781 "attribute_count": manifest.attributes.len(),
782 "purpose": manifest.purpose,
783 },
784 "decision": {
785 "allow": true,
786 "required_scopes": [],
787 "handling_mode": "local_embedded_enforcement",
788 "plaintext_transport": "forbidden_by_default",
789 },
790 "execution": {
791 "protect_locally": true,
792 "local_enforcement_library": "sdk_embedded_library",
793 "send_plaintext_to_platform": false,
794 "send_only": ["content digest"],
795 "artifact_profile": profile,
796 "key_strategy": "local",
797 "policy_resolution": "metadata_only",
798 "key_transport": key_transport,
799 },
800 "platform_domains": [],
801 "warnings": [],
802 })
803 }
804
805 fn key_access_plan_response(
806 manifest: &IntegrationFullManifest,
807 operation: &str,
808 profile: &str,
809 ) -> Value {
810 let key_reference_present = profile == "tdf" || profile == "detached_signature";
811 let key_transport = if profile == "tdf" {
812 json!({
813 "mode": "wrapped_key_reference",
814 "key_material_origin": "kms",
815 "stable_key_reference_preferred": true,
816 "raw_key_delivery_forbidden": true,
817 })
818 } else {
819 Value::Null
820 };
821 json!({
822 "service": "lattix-platform-api",
823 "status": "ready",
824 "caller": caller(manifest),
825 "request_summary": {
826 "operation": operation,
827 "workload_application": manifest.workload.application,
828 "workload_environment": manifest.workload.environment,
829 "workload_component": manifest.workload.component,
830 "resource_kind": manifest.resource.kind,
831 "resource_id": manifest.resource.id,
832 "mime_type": manifest.resource.mime_type,
833 "artifact_profile": profile,
834 "key_reference_present": key_reference_present,
835 "content_digest_present": true,
836 "purpose": manifest.purpose,
837 "label_count": manifest.labels.len(),
838 "attribute_count": manifest.attributes.len(),
839 },
840 "decision": {
841 "allow": true,
842 "required_scopes": [],
843 "operation": operation,
844 "key_reference_present": key_reference_present,
845 },
846 "execution": {
847 "local_cryptographic_operation": true,
848 "platform_role": "authorize only",
849 "send_plaintext_to_platform": false,
850 "send_only": ["content digest"],
851 "artifact_profile": profile,
852 "authorization_strategy": "metadata_only",
853 "key_transport": key_transport,
854 },
855 "platform_domains": [],
856 "warnings": [],
857 })
858 }
859
860 fn artifact_register_response(
861 manifest: &IntegrationFullManifest,
862 operation: &str,
863 profile: &str,
864 artifact_digest: &str,
865 ) -> Value {
866 json!({
867 "service": "lattix-platform-api",
868 "status": "ready",
869 "caller": caller(manifest),
870 "request_summary": {
871 "operation": operation,
872 "workload_application": manifest.workload.application,
873 "workload_environment": manifest.workload.environment,
874 "workload_component": manifest.workload.component,
875 "resource_kind": manifest.resource.kind,
876 "resource_id": manifest.resource.id,
877 "mime_type": manifest.resource.mime_type,
878 "artifact_profile": profile,
879 "artifact_digest": artifact_digest,
880 "artifact_locator_present": false,
881 "decision_id_present": false,
882 "key_reference_present": profile == "tdf",
883 "purpose": manifest.purpose,
884 "label_count": manifest.labels.len(),
885 "attribute_count": manifest.attributes.len(),
886 },
887 "registration": {
888 "accepted": true,
889 "required_scopes": [],
890 "artifact_transport": "metadata_only",
891 "send_plaintext_to_platform": false,
892 "catalog_actions": [],
893 "evidence_expected": [],
894 },
895 "platform_domains": [],
896 "warnings": [],
897 })
898 }
899
900 fn evidence_response(
901 manifest: &IntegrationFullManifest,
902 event_type: &str,
903 profile: &str,
904 ) -> Value {
905 json!({
906 "service": "lattix-platform-api",
907 "status": "ready",
908 "caller": caller(manifest),
909 "request_summary": {
910 "event_type": event_type,
911 "workload_application": manifest.workload.application,
912 "workload_environment": manifest.workload.environment,
913 "workload_component": manifest.workload.component,
914 "resource_kind": manifest.resource.kind,
915 "resource_id": manifest.resource.id,
916 "mime_type": manifest.resource.mime_type,
917 "artifact_profile": profile,
918 "artifact_digest_present": true,
919 "decision_id_present": false,
920 "outcome": "success",
921 "occurred_at": null,
922 "purpose": manifest.purpose,
923 "label_count": manifest.labels.len(),
924 "attribute_count": manifest.attributes.len(),
925 },
926 "ingestion": {
927 "accepted": true,
928 "required_scopes": [],
929 "plaintext_transport": "forbidden_by_default",
930 "send_only": [],
931 "correlate_by": [],
932 },
933 "platform_domains": [],
934 "warnings": [],
935 })
936 }
937
938 fn request_operation(body: &str) -> &str {
939 if body.contains("\"operation\":\"unwrap\"") {
940 "unwrap"
941 } else if body.contains("\"operation\":\"wrap\"") {
942 "wrap"
943 } else if body.contains("\"operation\":\"access\"") {
944 "access"
945 } else if body.contains("\"operation\":\"rewrap\"") {
946 "rewrap"
947 } else {
948 "protect"
949 }
950 }
951
952 fn request_profile(body: &str) -> &str {
953 if body.contains("\"detached_signature\"") {
954 "detached_signature"
955 } else if body.contains("\"envelope\"") {
956 "envelope"
957 } else {
958 "tdf"
959 }
960 }
961
962 fn request_artifact_digest(body: &str) -> &str {
963 const PREFIX: &str = "\"artifact_digest\":\"";
964 extract_json_string(body, PREFIX).unwrap_or("sha256:artifact-tdf-rewrapped")
965 }
966
967 fn request_event_type(body: &str) -> &str {
968 const PREFIX: &str = "\"event_type\":\"";
969 extract_json_string(body, PREFIX).unwrap_or("rewrap")
970 }
971
972 fn extract_json_string<'a>(body: &'a str, prefix: &str) -> Option<&'a str> {
973 let start = body.find(prefix)? + prefix.len();
974 let end = body[start..].find('"')? + start;
975 Some(&body[start..end])
976 }
977}