1use std::path::{Path, PathBuf};
64
65use chrono::{DateTime, Utc};
66use greentic_deploy_spec::{BundleDeployment, RevenueShareEntry};
67use greentic_distributor_client::signing::{SigningError, verify_artifact_dsse};
68use greentic_operator_trust::revenue_policy::{self, RevenuePolicyError};
69use thiserror::Error;
70
71use super::atomic_write::AtomicWriteError;
72use super::trust_root::{self, TrustRootError};
73use crate::operator_key::{OperatorKey, OperatorKeyError};
74
75pub use greentic_operator_trust::revenue_policy::{
78 REVENUE_POLICY_PREDICATE_TYPE_V1, RevenuePolicyPredicate,
79};
80
81#[derive(Debug, Error)]
82pub enum BundleDeploymentError {
83 #[error("revenue-policy spec invalid: {0}")]
84 Spec(#[from] greentic_deploy_spec::SpecError),
85 #[error("revenue-policy write {path}: {source}")]
86 Write {
87 path: PathBuf,
88 #[source]
89 source: AtomicWriteError,
90 },
91 #[error(
92 "unsafe path segment `{0}`: must be a single component, not `.`/`..`, and contain no path separators or NUL"
93 )]
94 UnsafeSegment(String),
95 #[error("revenue-policy version counter exhausted (committed ref already at the maximum)")]
96 VersionOverflow,
97 #[error("revenue-policy io on {path}: {source}")]
98 Io {
99 path: PathBuf,
100 #[source]
101 source: std::io::Error,
102 },
103 #[error("revenue-policy signing: {0}")]
104 Sign(#[from] SigningError),
105 #[error("revenue-policy operator key: {0}")]
106 OperatorKey(#[from] OperatorKeyError),
107 #[error("revenue-policy trust-root: {0}")]
108 TrustRoot(#[from] TrustRootError),
109 #[error("revenue-policy serialize: {0}")]
110 Serialize(serde_json::Error),
111 #[error(
115 "operator key `{key_id}` is not trusted in env `{env_dir}` (not present in `trust-root.json`); run `gtc op trust-root bootstrap <env-id>` first, or restore the key via `gtc op trust-root add`"
116 )]
117 OperatorKeyNotTrusted { key_id: String, env_dir: PathBuf },
118 #[error(
122 "revenue-policy document `{path}` was corrupted after write: expected SHA-256 `{expected}`, on-disk SHA-256 `{actual}`"
123 )]
124 DocCorruptedAfterWrite {
125 path: PathBuf,
126 expected: String,
127 actual: String,
128 },
129}
130
131fn map_policy_error(err: RevenuePolicyError, env_dir: &Path) -> BundleDeploymentError {
136 match err {
137 RevenuePolicyError::Spec(e) => BundleDeploymentError::Spec(e),
138 RevenuePolicyError::UnsafeSegment(s) => BundleDeploymentError::UnsafeSegment(s),
139 RevenuePolicyError::VersionOverflow => BundleDeploymentError::VersionOverflow,
140 RevenuePolicyError::Sign(e) => BundleDeploymentError::Sign(e),
141 RevenuePolicyError::Serialize(e) => BundleDeploymentError::Serialize(e),
142 RevenuePolicyError::OperatorKeyNotTrusted { key_id } => {
143 BundleDeploymentError::OperatorKeyNotTrusted {
144 key_id,
145 env_dir: env_dir.to_path_buf(),
146 }
147 }
148 }
149}
150
151#[derive(Clone, Debug)]
153pub struct RevenuePolicyVersion {
154 pub policy_ref: PathBuf,
156 pub version: u64,
157 pub doc_sha256: String,
161 pub key_id: String,
164}
165
166pub fn write_revenue_policy_version(
192 env_dir: &Path,
193 deployment: &BundleDeployment,
194 revenue_share: &[RevenueShareEntry],
195 created_at: DateTime<Utc>,
196 operator_key: &OperatorKey,
197) -> Result<RevenuePolicyVersion, BundleDeploymentError> {
198 let trust_root = trust_root::load(env_dir)?;
203 let built = revenue_policy::build_revenue_policy_version(
204 deployment,
205 revenue_share,
206 created_at,
207 operator_key,
208 &trust_root,
209 )
210 .map_err(|err| map_policy_error(err, env_dir))?;
211
212 let doc_abs = env_dir.join(&built.doc_ref);
213 let sig_abs = env_dir.join(&built.policy_ref);
214 let abs_dir = doc_abs
215 .parent()
216 .expect("built refs always carry the billing-policies parent dir")
217 .to_path_buf();
218 std::fs::create_dir_all(&abs_dir).map_err(|source| BundleDeploymentError::Io {
219 path: abs_dir.clone(),
220 source,
221 })?;
222
223 super::atomic_write::atomic_write_bytes(&doc_abs, &built.doc_bytes).map_err(|source| {
224 BundleDeploymentError::Write {
225 path: doc_abs.clone(),
226 source,
227 }
228 })?;
229 super::atomic_write::atomic_write_bytes(&sig_abs, &built.envelope_bytes).map_err(|source| {
230 BundleDeploymentError::Write {
231 path: sig_abs.clone(),
232 source,
233 }
234 })?;
235
236 let on_disk_doc = std::fs::read(&doc_abs).map_err(|source| BundleDeploymentError::Io {
242 path: doc_abs.clone(),
243 source,
244 })?;
245 let on_disk_doc_sha256 = revenue_policy::sha256_hex(&on_disk_doc);
246 if on_disk_doc_sha256 != built.doc_sha256 {
247 return Err(BundleDeploymentError::DocCorruptedAfterWrite {
248 path: doc_abs.clone(),
249 expected: built.doc_sha256.clone(),
250 actual: on_disk_doc_sha256,
251 });
252 }
253 let written = std::fs::read(&sig_abs).map_err(|source| BundleDeploymentError::Io {
254 path: sig_abs.clone(),
255 source,
256 })?;
257 verify_artifact_dsse(&written, &on_disk_doc_sha256, &trust_root)?;
258
259 Ok(RevenuePolicyVersion {
260 policy_ref: built.policy_ref,
261 version: built.version,
262 doc_sha256: built.doc_sha256,
263 key_id: built.key_id,
264 })
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::operator_key::{OperatorKey, load_or_generate_at};
271 use greentic_deploy_spec::{
272 BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId, PartyId,
273 RevenuePolicyDocument, RouteBinding, SchemaVersion, TenantSelector,
274 };
275 use greentic_distributor_client::signing::{DsseEnvelope, TrustedKey};
276 use greentic_operator_trust::revenue_policy::sha256_hex;
277 use std::collections::BTreeMap;
278 use tempfile::{TempDir, tempdir};
279
280 fn test_operator_key(workdir: &TempDir) -> OperatorKey {
286 let key = test_operator_key_without_bootstrap(workdir);
287 trust_root::add_trusted_key(
288 workdir.path(),
289 TrustedKey {
290 key_id: key.key_id.clone(),
291 public_key_pem: key.public_pem.clone(),
292 },
293 )
294 .expect("seed operator key into env trust root");
295 key
296 }
297
298 fn test_operator_key_without_bootstrap(workdir: &TempDir) -> OperatorKey {
299 load_or_generate_at(&workdir.path().join("operator-key.pem")).expect("generate key")
302 }
303
304 fn deployment(bundle: &str, customer: &str) -> BundleDeployment {
305 BundleDeployment {
306 schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
307 deployment_id: DeploymentId::new(),
308 env_id: EnvId::try_from("local").unwrap(),
309 bundle_id: BundleId::new(bundle),
310 customer_id: CustomerId::new(customer),
311 status: BundleDeploymentStatus::Active,
312 current_revisions: Vec::new(),
313 route_binding: RouteBinding {
314 hosts: Vec::new(),
315 path_prefixes: Vec::new(),
316 tenant_selector: TenantSelector {
317 tenant: "default".to_string(),
318 team: "default".to_string(),
319 },
320 },
321 revenue_share: shares(&[("greentic", 10_000)]),
322 revenue_policy_ref: PathBuf::from("placeholder"),
323 usage: None,
324 created_at: Utc::now(),
325 authorization_ref: PathBuf::from("auth.json"),
326 config_overrides: BTreeMap::new(),
327 }
328 }
329
330 fn shares(parts: &[(&str, u32)]) -> Vec<RevenueShareEntry> {
331 parts
332 .iter()
333 .map(|(p, bps)| RevenueShareEntry {
334 party_id: PartyId::new(*p),
335 basis_points: *bps,
336 })
337 .collect()
338 }
339
340 #[test]
341 fn first_write_is_v1_with_files_and_no_previous() {
342 let dir = tempdir().unwrap();
343 let op = test_operator_key(&dir);
344 let dep = deployment("fast2flow", "local-dev");
345 let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
346 .unwrap();
347 assert_eq!(v.version, 1);
348 assert_eq!(
349 v.policy_ref,
350 PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig")
351 );
352 assert!(dir.path().join(&v.policy_ref).is_file());
353 assert!(
354 dir.path()
355 .join("billing-policies/fast2flow/local-dev/v1.json")
356 .is_file()
357 );
358 let doc: RevenuePolicyDocument = serde_json::from_slice(
359 &std::fs::read(
360 dir.path()
361 .join("billing-policies/fast2flow/local-dev/v1.json"),
362 )
363 .unwrap(),
364 )
365 .unwrap();
366 assert!(doc.previous_version_ref.is_none());
367 assert!(doc.validate().is_ok());
368 }
369
370 #[test]
371 fn second_write_increments_and_chains() {
372 let dir = tempdir().unwrap();
373 let op = test_operator_key(&dir);
374 let mut dep = deployment("fast2flow", "cust-acme");
378 let v1 =
379 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
380 .unwrap();
381 dep.revenue_policy_ref = v1.policy_ref;
382 let v2 = write_revenue_policy_version(
383 dir.path(),
384 &dep,
385 &shares(&[("agency-a", 3_000), ("greentic", 7_000)]),
386 Utc::now(),
387 &op,
388 )
389 .unwrap();
390 assert_eq!(v2.version, 2);
391 let doc: RevenuePolicyDocument = serde_json::from_slice(
392 &std::fs::read(
393 dir.path()
394 .join("billing-policies/fast2flow/cust-acme/v2.json"),
395 )
396 .unwrap(),
397 )
398 .unwrap();
399 assert_eq!(
400 doc.previous_version_ref,
401 Some(PathBuf::from(
402 "billing-policies/fast2flow/cust-acme/v1.json.sig"
403 ))
404 );
405 }
406
407 #[test]
408 fn retry_after_uncommitted_write_reuses_same_version() {
409 let dir = tempdir().unwrap();
413 let op = test_operator_key(&dir);
414 let dep = deployment("fast2flow", "local-dev"); let a = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
418 .unwrap();
419 assert_eq!(a.version, 1);
420 let b = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
422 .unwrap();
423 assert_eq!(b.version, 1, "retry must not advance past the orphan");
424 assert!(
426 !dir.path()
427 .join("billing-policies/fast2flow/local-dev/v2.json")
428 .exists()
429 );
430 }
431
432 #[test]
433 fn retry_on_update_path_does_not_dangle_chain() {
434 let dir = tempdir().unwrap();
438 let op = test_operator_key(&dir);
439 let mut dep = deployment("fast2flow", "cust-acme");
440 let v1 =
441 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
442 .unwrap();
443 dep.revenue_policy_ref = v1.policy_ref; write_revenue_policy_version(
446 dir.path(),
447 &dep,
448 &shares(&[("greentic", 10_000)]),
449 Utc::now(),
450 &op,
451 )
452 .unwrap();
453 let v2 = write_revenue_policy_version(
455 dir.path(),
456 &dep,
457 &shares(&[("greentic", 10_000)]),
458 Utc::now(),
459 &op,
460 )
461 .unwrap();
462 assert_eq!(v2.version, 2);
463 let doc: RevenuePolicyDocument = serde_json::from_slice(
464 &std::fs::read(
465 dir.path()
466 .join("billing-policies/fast2flow/cust-acme/v2.json"),
467 )
468 .unwrap(),
469 )
470 .unwrap();
471 let prev = doc.previous_version_ref.expect("v2 chains to v1");
472 assert!(
473 dir.path().join(&prev).is_file(),
474 "previous_version_ref must point at a real (committed) sidecar"
475 );
476 assert!(
477 !dir.path()
478 .join("billing-policies/fast2flow/cust-acme/v3.json")
479 .exists()
480 );
481 }
482
483 #[test]
484 fn sidecar_is_dsse_envelope_that_verifies_against_doc_sha256() {
485 let dir = tempdir().unwrap();
486 let op = test_operator_key(&dir);
487 let dep = deployment("fast2flow", "local-dev");
488 let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
489 .unwrap();
490 assert_eq!(v.key_id, op.key_id);
491
492 let doc_path = dir
493 .path()
494 .join("billing-policies/fast2flow/local-dev/v1.json");
495 let doc_bytes = std::fs::read(&doc_path).unwrap();
496 let doc_sha256 = sha256_hex(&doc_bytes);
497 let _doc: RevenuePolicyDocument = serde_json::from_slice(&doc_bytes).unwrap();
500
501 let envelope_bytes = std::fs::read(dir.path().join(&v.policy_ref)).unwrap();
502 let parsed: DsseEnvelope = serde_json::from_slice(&envelope_bytes).unwrap();
503 assert_eq!(parsed.payload_type, "application/vnd.in-toto+json");
504 assert_eq!(parsed.signatures.len(), 1);
505 assert_eq!(parsed.signatures[0].keyid, op.key_id);
506
507 let trust = super::super::trust_root::load(dir.path()).unwrap();
509 assert!(!trust.is_empty(), "fixture must bootstrap trust-root.json");
510 verify_artifact_dsse(&envelope_bytes, &doc_sha256, &trust)
511 .expect("envelope must verify against the env trust root");
512 }
513
514 #[test]
515 fn previous_version_ref_in_predicate_uses_forward_slashes() {
516 let dir = tempdir().unwrap();
520 let op = test_operator_key(&dir);
521 let mut dep = deployment("fast2flow", "cust-acme");
522 let v1 =
523 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
524 .unwrap();
525 dep.revenue_policy_ref = v1.policy_ref;
526 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
527 .unwrap();
528
529 let envelope_bytes = std::fs::read(
530 dir.path()
531 .join("billing-policies/fast2flow/cust-acme/v2.json.sig"),
532 )
533 .unwrap();
534 let env: DsseEnvelope = serde_json::from_slice(&envelope_bytes).unwrap();
535 use base64::Engine;
536 let payload = base64::engine::general_purpose::STANDARD
537 .decode(&env.payload)
538 .unwrap();
539 let stmt: serde_json::Value = serde_json::from_slice(&payload).unwrap();
540 let prev = stmt["predicate"]["previous_version_ref"].as_str().unwrap();
541 assert!(
542 !prev.contains('\\'),
543 "predicate previous_version_ref must not contain backslashes; got {prev:?}"
544 );
545 assert!(
546 prev.starts_with("billing-policies/fast2flow/cust-acme/"),
547 "expected forward-slash-normalized path; got {prev:?}"
548 );
549 }
550
551 #[test]
552 fn writer_does_not_mutate_trust_root() {
553 let dir = tempdir().unwrap();
556 let op = test_operator_key(&dir);
557 let pre = super::super::trust_root::load(dir.path()).unwrap();
558 let mut dep = deployment("fast2flow", "local-dev");
559 let v1 =
560 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
561 .unwrap();
562 dep.revenue_policy_ref = v1.policy_ref;
563 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
564 .unwrap();
565 let post = super::super::trust_root::load(dir.path()).unwrap();
566 assert_eq!(pre.keys, post.keys, "writer must not mutate trust root");
567 }
568
569 #[test]
570 fn writer_refuses_when_operator_key_not_in_trust_root() {
571 let dir = tempdir().unwrap();
574 let op = test_operator_key_without_bootstrap(&dir);
575 let dep = deployment("fast2flow", "local-dev");
576 let err =
577 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
578 .expect_err("unbootstrapped op must be rejected");
579 match err {
580 BundleDeploymentError::OperatorKeyNotTrusted { key_id, .. } => {
581 assert_eq!(key_id, op.key_id);
582 }
583 other => panic!("expected OperatorKeyNotTrusted, got {other:?}"),
584 }
585 assert!(
586 !dir.path().join("billing-policies").exists(),
587 "no policy artifact must land on a failed precondition"
588 );
589 }
590
591 #[test]
592 fn writer_refuses_after_explicit_trust_root_remove() {
593 let dir = tempdir().unwrap();
597 let op = test_operator_key(&dir);
598 let dep = deployment("fast2flow", "local-dev");
599 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
600 .unwrap();
601 super::super::trust_root::remove_trusted_key(dir.path(), &op.key_id).unwrap();
602 let mut dep2 = dep.clone();
603 dep2.bundle_id = BundleId::new("post-revocation");
604 let err =
605 write_revenue_policy_version(dir.path(), &dep2, &dep2.revenue_share, Utc::now(), &op)
606 .expect_err("revoked op must stay revoked");
607 assert!(matches!(
608 err,
609 BundleDeploymentError::OperatorKeyNotTrusted { .. }
610 ));
611 let trust = super::super::trust_root::load(dir.path()).unwrap();
612 assert!(
613 trust.keys.is_empty(),
614 "revocation must be durable; got {:?}",
615 trust.keys
616 );
617 }
618
619 #[test]
620 fn unsafe_bundle_segment_rejected() {
621 let dir = tempdir().unwrap();
622 let op = test_operator_key(&dir);
623 let dep = deployment("../escape", "local-dev");
624 let err =
625 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
626 .unwrap_err();
627 assert!(matches!(err, BundleDeploymentError::UnsafeSegment(_)));
628 }
629
630 #[test]
631 fn unsafe_customer_segment_rejected() {
632 let dir = tempdir().unwrap();
633 let op = test_operator_key(&dir);
634 let dep = deployment("fast2flow", "a/b");
635 let err =
636 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
637 .unwrap_err();
638 assert!(matches!(err, BundleDeploymentError::UnsafeSegment(_)));
639 }
640
641 #[test]
642 fn invalid_revenue_share_rejected_before_write() {
643 let dir = tempdir().unwrap();
644 let op = test_operator_key(&dir);
645 let dep = deployment("fast2flow", "local-dev");
646 let err = write_revenue_policy_version(
647 dir.path(),
648 &dep,
649 &shares(&[("greentic", 5_000)]),
650 Utc::now(),
651 &op,
652 )
653 .unwrap_err();
654 assert!(matches!(err, BundleDeploymentError::Spec(_)));
655 assert!(!dir.path().join("billing-policies").exists());
657 }
658
659 #[test]
660 fn corrupted_v0_ref_starts_fresh_at_v1_without_chain() {
661 let dir = tempdir().unwrap();
662 let op = test_operator_key(&dir);
663 let mut dep = deployment("fast2flow", "local-dev");
664 dep.revenue_policy_ref = PathBuf::from("billing-policies/fast2flow/local-dev/v0.json.sig");
665 let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
666 .unwrap();
667 assert_eq!(v.version, 1, "v0 ref must not chain; restart at v1");
668 let doc: RevenuePolicyDocument = serde_json::from_slice(
669 &std::fs::read(
670 dir.path()
671 .join("billing-policies/fast2flow/local-dev/v1.json"),
672 )
673 .unwrap(),
674 )
675 .unwrap();
676 assert!(doc.previous_version_ref.is_none());
677 }
678
679 #[test]
680 fn version_counter_overflow_is_an_error_not_a_panic() {
681 let dir = tempdir().unwrap();
682 let op = test_operator_key(&dir);
683 let mut dep = deployment("fast2flow", "local-dev");
684 dep.revenue_policy_ref = PathBuf::from(format!(
685 "billing-policies/fast2flow/local-dev/v{}.json.sig",
686 u64::MAX
687 ));
688 let err =
689 write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
690 .unwrap_err();
691 assert!(matches!(err, BundleDeploymentError::VersionOverflow));
692 }
693
694 #[test]
695 fn previous_version_ref_is_reconstructed_not_copied_verbatim() {
696 let dir = tempdir().unwrap();
700 let op = test_operator_key(&dir);
701 let mut dep = deployment("fast2flow", "cust-acme");
702 dep.revenue_policy_ref =
703 PathBuf::from("../../other-env/billing-policies/victim/cust/v3.json.sig");
704 let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
705 .unwrap();
706 assert_eq!(v.version, 4); let doc: RevenuePolicyDocument = serde_json::from_slice(
708 &std::fs::read(
709 dir.path()
710 .join("billing-policies/fast2flow/cust-acme/v4.json"),
711 )
712 .unwrap(),
713 )
714 .unwrap();
715 assert_eq!(
716 doc.previous_version_ref,
717 Some(PathBuf::from(
718 "billing-policies/fast2flow/cust-acme/v3.json.sig"
719 )),
720 "previous_version_ref must be rebuilt under this deployment's dir, not the crafted ref"
721 );
722 }
723}