1use super::*;
12
13const MAX_ANNOTATION_BYTES: usize = 4 * 1024;
15
16const MAX_LINEAGE_STEPS: usize = 64;
18
19#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum MergeAssertion {
23 EntityLive { entity: Uuid },
26 EntityLineageReaches { entity: Uuid, canonical: Uuid },
35 NoteEdgeTo {
38 note: Uuid,
39 relation: EdgeRelation,
40 target: Uuid,
41 },
42 NoteEdgeTargetsWithin {
52 note: Uuid,
53 relation: EdgeRelation,
54 target_kind: String,
55 allowed: Uuid,
56 },
57}
58
59impl MergeAssertion {
60 fn name(&self) -> &'static str {
61 match self {
62 Self::EntityLive { .. } => "EntityLive",
63 Self::EntityLineageReaches { .. } => "EntityLineageReaches",
64 Self::NoteEdgeTo { .. } => "NoteEdgeTo",
65 Self::NoteEdgeTargetsWithin { .. } => "NoteEdgeTargetsWithin",
66 }
67 }
68
69 fn holds(
70 &self,
71 conn: &rusqlite::Connection,
72 namespace: &str,
73 budget: &mut MergeTxBudget,
74 ) -> Result<bool, MergeSqlError> {
75 match self {
76 Self::EntityLive { entity } => entity_is_live(conn, namespace, *entity, budget),
77 Self::EntityLineageReaches { entity, canonical } => {
78 lineage_reaches(conn, namespace, *entity, *canonical, budget)
79 }
80 Self::NoteEdgeTo {
81 note,
82 relation,
83 target,
84 } => {
85 budget.charge(1, 128, "checking a merge assertion edge")?;
86 Ok(conn.query_row(
87 crate::sql!("merge_guard_note_edge_select"),
88 rusqlite::params![
89 namespace,
90 relation.as_str(),
91 note.to_string(),
92 target.to_string(),
93 ],
94 |row| row.get(0),
95 )?)
96 }
97 Self::NoteEdgeTargetsWithin {
98 note,
99 relation,
100 target_kind,
101 allowed,
102 } => {
103 budget.charge(1, 128, "checking a merge assertion allowed target")?;
104 let allowed_kind: Option<String> = conn
105 .query_row(
106 crate::sql!("merge_guard_entity_live_kind_select"),
107 rusqlite::params![allowed.to_string(), namespace],
108 |row| row.get(0),
109 )
110 .optional()?;
111 if allowed_kind.as_deref() != Some(target_kind.as_str()) {
112 return Ok(false);
113 }
114 let mut stmt = conn.prepare(crate::sql!("merge_guard_note_edge_targets_select"))?;
115 let mut rows = stmt.query(rusqlite::params![
116 note.to_string(),
117 relation.as_str(),
118 namespace,
119 target_kind,
120 ])?;
121 while let Some(row) = rows.next()? {
122 budget.charge(1, 128, "checking merge assertion edge targets")?;
123 let counted: bool = row.get(1)?;
127 if counted {
128 let target: String = row.get(0)?;
129 match Uuid::parse_str(&target) {
130 Ok(target) if target == *allowed => {}
131 _ => return Ok(false),
132 }
133 }
134 }
135 Ok(true)
136 }
137 }
138 }
139}
140
141fn entity_is_live(
142 conn: &rusqlite::Connection,
143 namespace: &str,
144 entity: Uuid,
145 budget: &mut MergeTxBudget,
146) -> Result<bool, MergeSqlError> {
147 budget.charge(1, 128, "checking a merge assertion entity")?;
148 Ok(conn.query_row(
149 crate::sql!("merge_guard_entity_live_select"),
150 rusqlite::params![entity.to_string(), namespace],
151 |row| row.get(0),
152 )?)
153}
154
155fn lineage_reaches(
156 conn: &rusqlite::Connection,
157 namespace: &str,
158 entity: Uuid,
159 canonical: Uuid,
160 budget: &mut MergeTxBudget,
161) -> Result<bool, MergeSqlError> {
162 if !entity_is_live(conn, namespace, canonical, budget)? {
163 return Ok(false);
164 }
165 let mut seen = HashSet::new();
166 let mut current = entity;
167 for step in 0..=MAX_LINEAGE_STEPS {
168 if current == canonical {
169 return Ok(true);
170 }
171 if step == MAX_LINEAGE_STEPS || !seen.insert(current) {
172 return Ok(false);
173 }
174 budget.charge(1, 128, "following merge assertion lineage")?;
175 let merged_into: Option<Option<String>> = conn
176 .query_row(
177 crate::sql!("merge_guard_entity_merged_into_select"),
178 rusqlite::params![current.to_string(), namespace],
179 |row| row.get(0),
180 )
181 .optional()?;
182 match merged_into
183 .flatten()
184 .and_then(|value| Uuid::parse_str(&value).ok())
185 {
186 Some(next) => current = next,
187 None => return Ok(false),
188 }
189 }
190 Ok(false)
191}
192
193#[derive(Clone, Debug)]
195pub struct NoteMergeGuard {
196 pub into_version: i64,
198 pub from_version: i64,
200 pub assertions: Vec<MergeAssertion>,
202 pub survivor_properties: serde_json::Map<String, Value>,
205 pub annotation: Option<Value>,
208}
209
210#[derive(Clone, Debug)]
215pub struct GuardedNoteMerge {
216 pub summary: MergeSummary,
217 pub kept_version: i64,
218}
219
220fn refusal(message: String) -> MergeSqlError {
221 MergeSqlError::Refusal(RuntimeError::Khive(KhiveError::conflict(message)))
222}
223
224impl NoteMergeGuard {
225 pub(super) fn check_values(&self) -> RuntimeResult<()> {
228 if self.survivor_properties.contains_key("_merge_history") {
229 return Err(RuntimeError::InvalidInput(
230 "a guarded merge cannot override `_merge_history`: the merge owns its provenance"
231 .into(),
232 ));
233 }
234 let overrides = Value::Object(self.survivor_properties.clone());
235 crate::secret_gate::reject_reserved_secret_gate_property(Some(&overrides))?;
236 crate::secret_gate::check_json_at(&overrides, "note", "properties")?;
238 if let Some(annotation) = &self.annotation {
239 let bytes = serde_json::to_vec(annotation)
240 .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?
241 .len();
242 if bytes > MAX_ANNOTATION_BYTES {
243 return Err(RuntimeError::InvalidInput(format!(
244 "merge annotation is {bytes} bytes serialized; the limit is \
245 {MAX_ANNOTATION_BYTES}"
246 )));
247 }
248 crate::secret_gate::check_json_at(annotation, "note", "merge_annotation")?;
249 }
250 Ok(())
251 }
252
253 pub(super) fn enforce(
255 &self,
256 conn: &rusqlite::Connection,
257 namespace: &str,
258 into: &Note,
259 from: &Note,
260 budget: &mut MergeTxBudget,
261 ) -> Result<(), MergeSqlError> {
262 for (note, expected) in [(into, self.into_version), (from, self.from_version)] {
263 if note.version != expected {
264 return Err(MergeSqlError::Refusal(stale_note_snapshot_error(note.id)));
265 }
266 }
267 for (index, assertion) in self.assertions.iter().enumerate() {
268 if !assertion.holds(conn, namespace, budget)? {
269 return Err(refusal(format!(
270 "guarded note merge refused: assertion {index} ({}) does not hold",
271 assertion.name()
272 )));
273 }
274 }
275 Ok(())
276 }
277
278 pub(super) fn apply_to_survivor(
282 &self,
283 kind: &str,
284 preserve_owner_established: bool,
285 properties: &mut Option<Value>,
286 history_entry: &mut Value,
287 ) -> Result<(), MergeSqlError> {
288 if let Some(key) = self.survivor_properties.keys().find(|key| {
289 kind_owned_properties(kind).contains(&key.as_str())
290 || (preserve_owner_established
291 && OWNER_ESTABLISHED_PROPERTIES.contains(&key.as_str()))
292 }) {
293 return Err(MergeSqlError::Refusal(RuntimeError::InvalidInput(format!(
294 "`{key}` is owned by `{kind}` notes and cannot be overridden by a guarded merge"
295 ))));
296 }
297 if !self.survivor_properties.is_empty() {
298 let Value::Object(object) =
301 properties.get_or_insert_with(|| Value::Object(serde_json::Map::new()))
302 else {
303 return Err(refusal(
304 "guarded note merge refused: the merged properties are not an object".into(),
305 ));
306 };
307 object.extend(
308 self.survivor_properties
309 .iter()
310 .map(|(key, value)| (key.clone(), value.clone())),
311 );
312 }
313 if properties
317 .as_ref()
318 .and_then(|props| props.get("_merge_history"))
319 .is_some_and(|history| !history.is_array())
320 {
321 return Err(refusal(
322 "guarded note merge refused: `_merge_history` is not an array".into(),
323 ));
324 }
325 if let (Some(annotation), Some(entry)) = (&self.annotation, history_entry.as_object_mut()) {
326 entry.insert("annotation".into(), annotation.clone());
327 }
328 Ok(())
329 }
330}
331
332#[cfg(test)]
333#[path = "note_merge_guard_tests.rs"]
334mod tests;