返回 CodeWhale
receipt.rs
根目录 / crates / tui / src / integrations / dsh / receipt.rs
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
181 lines RUST