use super::*;
use bamboo_domain::{ActorDirectoryEntry, ActorSession, SessionAuthorityConflict};
fn conflict(message: &str) -> io::Error {
io::Error::new(
io::ErrorKind::WouldBlock,
SessionAuthorityConflict(message.into()),
)
}
fn normalized(session: &Session) -> Session {
let mut session = session.clone();
if session.kind == SessionKind::Root && session.root_session_id.is_empty() {
session.root_session_id = session.id.clone();
}
session
}
fn value(session: &Session) -> io::Result<serde_json::Value> {
serde_json::to_value(normalized(session)).map_err(|e| other_io_error(e.to_string()))
}
fn validate_manual_title_change(previous: &Session, incoming: &Session) -> io::Result<()> {
let mut comparison = incoming.clone();
comparison.title = previous.title.clone();
comparison.title_generated = previous.title_generated;
comparison.title_version = previous.title_version;
comparison.metadata_version = previous.metadata_version;
comparison.updated_at = previous.updated_at;
if value(previous)? != value(&comparison)? {
return Err(conflict(
"canonical non-title Session fields changed before title commit",
));
}
if previous.title_version.checked_add(1) != Some(incoming.title_version)
|| previous.metadata_version.checked_add(1) != Some(incoming.metadata_version)
{
return Err(conflict(
"manual title commit requires exactly one metadata revision",
));
}
Ok(())
}
async fn regular_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::symlink_metadata(path).await {
Ok(meta) if meta.file_type().is_file() => fs::read(path).await.map(Some),
Ok(_) => Err(conflict("title observation source is not a regular file")),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
impl SessionStoreV2 {
async fn title_canonical_session(&self, session: &Session) -> io::Result<Session> {
let rel = Self::default_writer_rel_path(session)?;
let root = if session.kind == SessionKind::Root {
&session.id
} else {
&session.root_session_id
};
self.load_session_from_dir_strict(
&self.abs_path_from_rel(&rel),
&session.id,
session.kind,
root,
)
.await?
.ok_or_else(|| conflict("canonical title Session is missing"))
}
async fn ordinary_title_tree(&self, current: &Session) -> io::Result<bool> {
if current.kind == SessionKind::Root {
return Ok(current.authority_identity.is_ordinary());
}
let root = ¤t.root_session_id;
Ok(self
.load_session_from_dir_strict(
&self.sessions_dir.join(root),
root,
SessionKind::Root,
root,
)
.await?
.is_some_and(|root| root.authority_identity.is_ordinary()))
}
async fn title_observation_rows(
&self,
root: &str,
) -> io::Result<Vec<(PathBuf, ActorDirectoryEntry)>> {
validate_session_id(root)?;
let root_dir = self.sessions_dir.join(root);
let mut directories = vec![root_dir.clone()];
let children = root_dir.join("children");
match fs::symlink_metadata(&children).await {
Ok(meta) if meta.file_type().is_dir() => {
let mut entries = fs::read_dir(&children).await?;
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_dir() {
return Err(conflict(
"title observation Child placement is not a directory",
));
}
directories.push(entry.path());
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
Ok(_) => {
return Err(conflict(
"title observation children placement is not a directory",
))
}
}
directories.sort();
let mut rows = Vec::new();
for directory in directories {
let path = directory.join("actor-authority.json");
let record = regular_bytes(&path).await?;
let marker = regular_bytes(&directory.join("actor-authority.initialized.json")).await?;
let (record, marker) = match (record, marker) {
(None, None) => continue,
(Some(record), Some(marker)) => (record, marker),
_ => {
return Err(conflict(
"title observation has an incomplete initialized Actor",
))
}
};
let id = directory
.file_name()
.and_then(|id| id.to_str())
.ok_or_else(|| conflict("invalid title observation Actor path"))?;
let (rel, canonical_path) = self
.actor_authority_location(id)
.await
.map_err(|e| conflict(&e.to_string()))?;
if canonical_path != path {
return Err(conflict(
"title observation is not at its canonical placement",
));
}
let (kind, expected_root) = Self::copy_source_identity_from_rel(id, &rel)?;
if expected_root != root {
return Err(conflict("title observation Root mismatch"));
}
let current = self
.load_session_from_dir_strict(&directory, id, kind, root)
.await?
.ok_or_else(|| conflict("title observation Session is missing"))?;
let mut expected =
ActorSession::from_session(¤t).map_err(|e| conflict(&e.to_string()))?;
let project = expected
.project_id
.as_deref()
.map(str::parse::<ProjectId>)
.transpose()
.map_err(|_| conflict("invalid title observation Project"))?;
actor_directory::validate_census_witnesses(
&record,
&marker,
¤t,
project.as_ref(),
)?;
let row: ActorDirectoryEntry = serde_json::from_slice(&record)
.map_err(|_| conflict("invalid title observation Actor row"))?;
expected.ancestor_observations = self
.validate_actor_lineage_with_record(&expected, Some(&row.actor))
.await
.map_err(|e| conflict(&e.to_string()))?;
if row.actor.project_id != current.project_id_meta()
|| row.actor.observed_metadata_version != current.metadata_version
|| row.actor.ancestor_observations != expected.ancestor_observations
{
return Err(conflict("title observation is stale"));
}
rows.push((path, row));
}
Ok(rows)
}
pub(super) async fn prepare_title_observations(
&self,
incoming: &Session,
) -> io::Result<Vec<(PathBuf, Vec<u8>)>> {
let previous = self.title_canonical_session(incoming).await?;
if !self.ordinary_title_tree(&previous).await? {
return Ok(Vec::new());
}
validate_manual_title_change(&previous, incoming)?;
let mut writes = Vec::new();
for (path, mut row) in self
.title_observation_rows(&previous.root_session_id)
.await?
{
let mut changed = false;
if row.actor.actor_id == previous.id {
row.actor.observed_metadata_version = incoming.metadata_version;
changed = true;
}
for ancestor in &mut row.actor.ancestor_observations {
if ancestor.actor_id == previous.id {
ancestor.metadata_version = incoming.metadata_version;
changed = true;
}
}
if changed {
row.revision = row
.revision
.checked_add(1)
.ok_or_else(|| conflict("title observation row revision overflow"))?;
row.validate().map_err(|e| conflict(&e.to_string()))?;
let bytes =
serde_json::to_vec_pretty(&row).map_err(|e| other_io_error(e.to_string()))?;
writes.push((path, bytes));
}
}
Ok(writes)
}
pub(super) async fn validate_unchanged_title(&self, expected: &Session) -> io::Result<()> {
let lifecycle = self.lock_session_lifecycle_shared().await?;
let task = self.lock_runtime_task_sidecar_shared().await?;
let session = self.acquire_session_maintenance_lock(&expected.id).await?;
let guards = DefaultWriterGuards::shared(lifecycle, task, session);
let expected = normalized(expected);
let tree = self
.acquire_actor_tree_write_guard(&expected.root_session_id)
.await?;
guards.hold_tree(tree);
let current = self.title_canonical_session(&expected).await?;
if value(¤t)? != value(&expected)? {
return Err(conflict(
"canonical Session changed before title no-op validation",
));
}
if self.ordinary_title_tree(¤t).await? {
self.title_observation_rows(¤t.root_session_id)
.await?;
}
Ok(())
}
}