use super::*;
const MAX_ANNOTATION_BYTES: usize = 4 * 1024;
const MAX_LINEAGE_STEPS: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MergeAssertion {
EntityLive { entity: Uuid },
EntityLineageReaches { entity: Uuid, canonical: Uuid },
NoteEdgeTo {
note: Uuid,
relation: EdgeRelation,
target: Uuid,
},
NoteEdgeTargetsWithin {
note: Uuid,
relation: EdgeRelation,
target_kind: String,
allowed: Uuid,
},
}
impl MergeAssertion {
fn name(&self) -> &'static str {
match self {
Self::EntityLive { .. } => "EntityLive",
Self::EntityLineageReaches { .. } => "EntityLineageReaches",
Self::NoteEdgeTo { .. } => "NoteEdgeTo",
Self::NoteEdgeTargetsWithin { .. } => "NoteEdgeTargetsWithin",
}
}
fn holds(
&self,
conn: &rusqlite::Connection,
namespace: &str,
budget: &mut MergeTxBudget,
) -> Result<bool, MergeSqlError> {
match self {
Self::EntityLive { entity } => entity_is_live(conn, namespace, *entity, budget),
Self::EntityLineageReaches { entity, canonical } => {
lineage_reaches(conn, namespace, *entity, *canonical, budget)
}
Self::NoteEdgeTo {
note,
relation,
target,
} => {
budget.charge(1, 128, "checking a merge assertion edge")?;
Ok(conn.query_row(
crate::sql!("merge_guard_note_edge_select"),
rusqlite::params![
namespace,
relation.as_str(),
note.to_string(),
target.to_string(),
],
|row| row.get(0),
)?)
}
Self::NoteEdgeTargetsWithin {
note,
relation,
target_kind,
allowed,
} => {
budget.charge(1, 128, "checking a merge assertion allowed target")?;
let allowed_kind: Option<String> = conn
.query_row(
crate::sql!("merge_guard_entity_live_kind_select"),
rusqlite::params![allowed.to_string(), namespace],
|row| row.get(0),
)
.optional()?;
if allowed_kind.as_deref() != Some(target_kind.as_str()) {
return Ok(false);
}
let mut stmt = conn.prepare(crate::sql!("merge_guard_note_edge_targets_select"))?;
let mut rows = stmt.query(rusqlite::params![
note.to_string(),
relation.as_str(),
namespace,
target_kind,
])?;
while let Some(row) = rows.next()? {
budget.charge(1, 128, "checking merge assertion edge targets")?;
let counted: bool = row.get(1)?;
if counted {
let target: String = row.get(0)?;
match Uuid::parse_str(&target) {
Ok(target) if target == *allowed => {}
_ => return Ok(false),
}
}
}
Ok(true)
}
}
}
}
fn entity_is_live(
conn: &rusqlite::Connection,
namespace: &str,
entity: Uuid,
budget: &mut MergeTxBudget,
) -> Result<bool, MergeSqlError> {
budget.charge(1, 128, "checking a merge assertion entity")?;
Ok(conn.query_row(
crate::sql!("merge_guard_entity_live_select"),
rusqlite::params![entity.to_string(), namespace],
|row| row.get(0),
)?)
}
fn lineage_reaches(
conn: &rusqlite::Connection,
namespace: &str,
entity: Uuid,
canonical: Uuid,
budget: &mut MergeTxBudget,
) -> Result<bool, MergeSqlError> {
if !entity_is_live(conn, namespace, canonical, budget)? {
return Ok(false);
}
let mut seen = HashSet::new();
let mut current = entity;
for step in 0..=MAX_LINEAGE_STEPS {
if current == canonical {
return Ok(true);
}
if step == MAX_LINEAGE_STEPS || !seen.insert(current) {
return Ok(false);
}
budget.charge(1, 128, "following merge assertion lineage")?;
let merged_into: Option<Option<String>> = conn
.query_row(
crate::sql!("merge_guard_entity_merged_into_select"),
rusqlite::params![current.to_string(), namespace],
|row| row.get(0),
)
.optional()?;
match merged_into
.flatten()
.and_then(|value| Uuid::parse_str(&value).ok())
{
Some(next) => current = next,
None => return Ok(false),
}
}
Ok(false)
}
#[derive(Clone, Debug)]
pub struct NoteMergeGuard {
pub into_version: i64,
pub from_version: i64,
pub assertions: Vec<MergeAssertion>,
pub survivor_properties: serde_json::Map<String, Value>,
pub annotation: Option<Value>,
}
#[derive(Clone, Debug)]
pub struct GuardedNoteMerge {
pub summary: MergeSummary,
pub kept_version: i64,
}
fn refusal(message: String) -> MergeSqlError {
MergeSqlError::Refusal(RuntimeError::Khive(KhiveError::conflict(message)))
}
impl NoteMergeGuard {
pub(super) fn check_values(&self) -> RuntimeResult<()> {
if self.survivor_properties.contains_key("_merge_history") {
return Err(RuntimeError::InvalidInput(
"a guarded merge cannot override `_merge_history`: the merge owns its provenance"
.into(),
));
}
let overrides = Value::Object(self.survivor_properties.clone());
crate::secret_gate::reject_reserved_secret_gate_property(Some(&overrides))?;
crate::secret_gate::check_json_at(&overrides, "note", "properties")?;
if let Some(annotation) = &self.annotation {
let bytes = serde_json::to_vec(annotation)
.map_err(|error| RuntimeError::InvalidInput(error.to_string()))?
.len();
if bytes > MAX_ANNOTATION_BYTES {
return Err(RuntimeError::InvalidInput(format!(
"merge annotation is {bytes} bytes serialized; the limit is \
{MAX_ANNOTATION_BYTES}"
)));
}
crate::secret_gate::check_json_at(annotation, "note", "merge_annotation")?;
}
Ok(())
}
pub(super) fn enforce(
&self,
conn: &rusqlite::Connection,
namespace: &str,
into: &Note,
from: &Note,
budget: &mut MergeTxBudget,
) -> Result<(), MergeSqlError> {
for (note, expected) in [(into, self.into_version), (from, self.from_version)] {
if note.version != expected {
return Err(MergeSqlError::Refusal(stale_note_snapshot_error(note.id)));
}
}
for (index, assertion) in self.assertions.iter().enumerate() {
if !assertion.holds(conn, namespace, budget)? {
return Err(refusal(format!(
"guarded note merge refused: assertion {index} ({}) does not hold",
assertion.name()
)));
}
}
Ok(())
}
pub(super) fn apply_to_survivor(
&self,
kind: &str,
preserve_owner_established: bool,
properties: &mut Option<Value>,
history_entry: &mut Value,
) -> Result<(), MergeSqlError> {
if let Some(key) = self.survivor_properties.keys().find(|key| {
kind_owned_properties(kind).contains(&key.as_str())
|| (preserve_owner_established
&& OWNER_ESTABLISHED_PROPERTIES.contains(&key.as_str()))
}) {
return Err(MergeSqlError::Refusal(RuntimeError::InvalidInput(format!(
"`{key}` is owned by `{kind}` notes and cannot be overridden by a guarded merge"
))));
}
if !self.survivor_properties.is_empty() {
let Value::Object(object) =
properties.get_or_insert_with(|| Value::Object(serde_json::Map::new()))
else {
return Err(refusal(
"guarded note merge refused: the merged properties are not an object".into(),
));
};
object.extend(
self.survivor_properties
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
}
if properties
.as_ref()
.and_then(|props| props.get("_merge_history"))
.is_some_and(|history| !history.is_array())
{
return Err(refusal(
"guarded note merge refused: `_merge_history` is not an array".into(),
));
}
if let (Some(annotation), Some(entry)) = (&self.annotation, history_entry.as_object_mut()) {
entry.insert("annotation".into(), annotation.clone());
}
Ok(())
}
}
#[cfg(test)]
#[path = "note_merge_guard_tests.rs"]
mod tests;