| 1 | //! Durable, append-only receipts for the DSH integration. |
| 2 | //! |
| 3 | //! One JSON document at `$CODEWHALE_HOME/integrations/dsh/receipt.json` |
| 4 | //! holds the current connection record (or `null`) and an append-only history |
| 5 | //! of every install/update/disable/enable/remove event. Receipts follow |
| 6 | //! `docs/RECEIPTS.md`: read-only summaries that never carry credentials. |
| 7 | |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | |
| 10 | use anyhow::{Context, Result}; |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | |
| 13 | use super::identity::MappedIdentity; |
| 14 | |
| 15 | pub(crate) const RECEIPT_SCHEMA_VERSION: u32 = 1; |
| 16 | pub(crate) const MAX_HISTORY: usize = 256; |
| 17 | |
| 18 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 19 | #[serde(rename_all = "snake_case")] |
| 20 | pub(crate) enum DshReceiptEvent { |
| 21 | Connect, |
| 22 | Update, |
| 23 | Disable, |
| 24 | Enable, |
| 25 | Remove, |
| 26 | InstallBundle, |
| 27 | RemoveBundle, |
| 28 | } |
| 29 | |
| 30 | impl DshReceiptEvent { |
| 31 | pub(crate) fn as_str(&self) -> &'static str { |
| 32 | match self { |
| 33 | Self::Connect => "connect", |
| 34 | Self::Update => "update", |
| 35 | Self::Disable => "disable", |
| 36 | Self::Enable => "enable", |
| 37 | Self::Remove => "remove", |
| 38 | Self::InstallBundle => "install_bundle", |
| 39 | Self::RemoveBundle => "remove_bundle", |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | fn default_true() -> bool { |
| 45 | true |
| 46 | } |
| 47 | |
| 48 | /// The live connection record. |
| 49 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 50 | pub(crate) struct DshConnectionRecord { |
| 51 | pub(crate) connected_at: String, |
| 52 | pub(crate) updated_at: String, |
| 53 | pub(crate) dsh_version: Option<String>, |
| 54 | pub(crate) dsh_binary: Option<PathBuf>, |
| 55 | pub(crate) dsh_home: PathBuf, |
| 56 | /// `web` or `headless`. |
| 57 | pub(crate) profile: String, |
| 58 | pub(crate) overlay_path: PathBuf, |
| 59 | pub(crate) overlay_sha256: String, |
| 60 | /// Palette applied through the bundle profile via `overrideTokens`. |
| 61 | /// Serialized as `skin` (the receipt field the spec names); older |
| 62 | /// documents that wrote `skin_enabled` still load. |
| 63 | #[serde(default, rename = "skin", alias = "skin_enabled")] |
| 64 | pub(crate) skin_enabled: bool, |
| 65 | pub(crate) skin_path: Option<PathBuf>, |
| 66 | /// SHA-256 of the rendered TOKENS JSON (not of a stylesheet). |
| 67 | pub(crate) skin_sha256: Option<String>, |
| 68 | /// Ambient ocean scene spliced into the bundle's client half (only |
| 69 | /// meaningful with `skin`). Serialized as `ocean`; receipts written before |
| 70 | /// the scene existed load as `true` (the default) and are reported stale |
| 71 | /// by the client-half byte check until `update` rewrites them. |
| 72 | #[serde(default = "default_true", rename = "ocean")] |
| 73 | pub(crate) ocean_enabled: bool, |
| 74 | pub(crate) disabled: bool, |
| 75 | /// Documented plugin path, when installed into the dedicated profile. |
| 76 | #[serde(default)] |
| 77 | pub(crate) bundle: Option<super::bundle::DshBundleRecord>, |
| 78 | pub(crate) identity: MappedIdentity, |
| 79 | } |
| 80 | |
| 81 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 82 | pub(crate) struct DshReceiptEntry { |
| 83 | pub(crate) event: DshReceiptEvent, |
| 84 | pub(crate) at: String, |
| 85 | pub(crate) codewhale_version: String, |
| 86 | pub(crate) dsh_version: Option<String>, |
| 87 | pub(crate) dsh_home: PathBuf, |
| 88 | pub(crate) overlay_sha256: Option<String>, |
| 89 | pub(crate) skin_sha256: Option<String>, |
| 90 | /// `provider/model` the overlay pinned, when applicable. |
| 91 | pub(crate) identity_summary: Option<String>, |
| 92 | pub(crate) permission_mode: Option<String>, |
| 93 | pub(crate) note: Option<String>, |
| 94 | } |
| 95 | |
| 96 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 97 | pub(crate) struct DshReceiptDocument { |
| 98 | pub(crate) schema_version: u32, |
| 99 | pub(crate) current: Option<DshConnectionRecord>, |
| 100 | #[serde(default)] |
| 101 | pub(crate) history: Vec<DshReceiptEntry>, |
| 102 | } |
| 103 | |
| 104 | impl Default for DshReceiptDocument { |
| 105 | fn default() -> Self { |
| 106 | Self { |
| 107 | schema_version: RECEIPT_SCHEMA_VERSION, |
| 108 | current: None, |
| 109 | history: Vec::new(), |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | impl DshReceiptDocument { |
| 115 | pub(crate) fn load(path: &Path) -> Result<Self> { |
| 116 | match std::fs::read(path) { |
| 117 | Ok(bytes) => { |
| 118 | let doc: Self = serde_json::from_slice(&bytes) |
| 119 | .with_context(|| format!("parse DSH receipt {}", path.display()))?; |
| 120 | if doc.schema_version > RECEIPT_SCHEMA_VERSION { |
| 121 | anyhow::bail!( |
| 122 | "DSH receipt {} has schema_version {} (this build understands {})", |
| 123 | path.display(), |
| 124 | doc.schema_version, |
| 125 | RECEIPT_SCHEMA_VERSION |
| 126 | ); |
| 127 | } |
| 128 | Ok(doc) |
| 129 | } |
| 130 | Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), |
| 131 | Err(error) => { |
| 132 | Err(error).with_context(|| format!("read DSH receipt {}", path.display())) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | pub(crate) fn push(&mut self, entry: DshReceiptEntry) { |
| 138 | self.history.push(entry); |
| 139 | if self.history.len() > MAX_HISTORY { |
| 140 | let excess = self.history.len() - MAX_HISTORY; |
| 141 | self.history.drain(0..excess); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | pub(crate) fn save(&self, path: &Path) -> Result<()> { |
| 146 | let parent = path |
| 147 | .parent() |
| 148 | .ok_or_else(|| anyhow::anyhow!("receipt path has no parent"))?; |
| 149 | std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; |
| 150 | let json = serde_json::to_vec_pretty(self)?; |
| 151 | write_atomic(path, &json) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /// Write via a sibling temp file + rename so a crash never leaves a torn |
| 156 | /// receipt or overlay behind. |
| 157 | pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { |
| 158 | let parent = path |
| 159 | .parent() |
| 160 | .ok_or_else(|| anyhow::anyhow!("path has no parent"))?; |
| 161 | let tmp = parent.join(format!( |
| 162 | ".{}.tmp-{}", |
| 163 | path.file_name() |
| 164 | .map(|n| n.to_string_lossy().into_owned()) |
| 165 | .unwrap_or_else(|| "file".to_string()), |
| 166 | std::process::id() |
| 167 | )); |
| 168 | std::fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?; |
| 169 | #[cfg(unix)] |
| 170 | { |
| 171 | use std::os::unix::fs::PermissionsExt; |
| 172 | let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)); |
| 173 | } |
| 174 | std::fs::rename(&tmp, path).with_context(|| format!("rename into {}", path.display()))?; |
| 175 | Ok(()) |
| 176 | } |
| 177 | |
| 178 | pub(crate) fn now_rfc3339() -> String { |
| 179 | chrono::Utc::now().to_rfc3339() |
| 180 | } |
| 181 |