ccd-cli 1.0.0-beta.4

Bootstrap and validate Continuous Context Development repositories
use anyhow::{bail, Result};
use serde::Serialize;

use crate::display_safe::display_safe;
use crate::paths::state::StateLayout;

use super::session::{self, SessionLifecycle};

#[derive(Debug, Clone, Default)]
pub(crate) struct ExclusiveWriteOptions {
    pub(crate) actor_id: Option<String>,
    pub(crate) session_id: Option<String>,
    pub(crate) expected_revision: Option<u64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ExclusiveWriteGuard {
    pub(crate) expected_revision: Option<u64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AppendWriteOutcome {
    Applied,
    IdempotentNoop,
    OwnershipConflict,
    StaleSession,
    UnsupportedMultiwriter,
    DuplicateIdConflict,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AppendWriteAuthority {
    OwnerOnly,
    OwnerOrSupervisor,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AppendWriteConflict {
    pub(crate) outcome: AppendWriteOutcome,
    pub(crate) message: String,
}

impl ExclusiveWriteOptions {
    fn has_identity_fields(&self) -> bool {
        self.actor_id.is_some() || self.session_id.is_some()
    }
}

pub(crate) fn authorize_owner_surface_write(
    layout: &StateLayout,
    surface: &str,
    options: &ExclusiveWriteOptions,
) -> Result<ExclusiveWriteGuard> {
    if options.has_identity_fields() && options.expected_revision.is_none() {
        bail!(
            "`--expected-revision` is required when protected-write identity flags are provided for `{surface}`"
        );
    }

    let Some(state) = session::load_for_layout(layout)? else {
        return Ok(ExclusiveWriteGuard {
            expected_revision: options.expected_revision,
        });
    };

    if state.lifecycle() != SessionLifecycle::Autonomous {
        return Ok(ExclusiveWriteGuard {
            expected_revision: options.expected_revision,
        });
    }

    let now = session::now_epoch_s()?;
    if session::is_stale(&state, now) {
        bail!(
            "`{surface}` is protected by stale autonomous session `{}` owned by `{}`; use `ccd session-state takeover` or `ccd session-state clear` before mutating it",
            display_safe(state.session_id.as_deref().unwrap_or("unknown")),
            display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
        );
    }

    let Some(actor_id) = options.actor_id.as_deref() else {
        bail!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
            display_safe(state.session_id.as_deref().unwrap_or("unknown")),
            display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
        );
    };
    let Some(session_id) = options.session_id.as_deref() else {
        bail!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
            display_safe(state.session_id.as_deref().unwrap_or("unknown")),
            display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
        );
    };
    let Some(expected_revision) = options.expected_revision else {
        bail!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
            display_safe(state.session_id.as_deref().unwrap_or("unknown")),
            display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
        );
    };

    if state.owner_id.as_deref() != Some(actor_id) {
        bail!(
            "`{surface}` requires owner actor `{}`; got `{}`",
            display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
            display_safe(actor_id),
        );
    }

    if state.session_id.as_deref() != Some(session_id) {
        bail!(
            "`{surface}` requires active session id `{}`; got `{}`",
            display_safe(state.session_id.as_deref().unwrap_or("unknown")),
            display_safe(session_id),
        );
    }

    Ok(ExclusiveWriteGuard {
        expected_revision: Some(expected_revision),
    })
}

pub(crate) fn authorize_append_surface_write(
    layout: &StateLayout,
    surface: &str,
    options: &ExclusiveWriteOptions,
    authority: AppendWriteAuthority,
) -> Result<Option<AppendWriteConflict>> {
    let Some(state) = session::load_for_layout(layout)? else {
        return Ok(None);
    };

    if state.lifecycle() != SessionLifecycle::Autonomous {
        return Ok(None);
    }

    let now = session::now_epoch_s()?;
    if session::is_stale(&state, now) {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::StaleSession,
            message: format!(
                "`{surface}` is protected by stale autonomous session `{}` owned by `{}`; use `ccd session-state takeover` or `ccd session-state clear` before mutating it",
                display_safe(state.session_id.as_deref().unwrap_or("unknown")),
                display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
            ),
        }));
    }

    let Some(actor_id) = options.actor_id.as_deref() else {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::OwnershipConflict,
            message: missing_append_identity_message(&state, surface, authority),
        }));
    };

    let Some(session_id) = options.session_id.as_deref() else {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::OwnershipConflict,
            message: missing_append_identity_message(&state, surface, authority),
        }));
    };

    if state.session_id.as_deref() != Some(session_id) {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::OwnershipConflict,
            message: format!(
                "`{surface}` requires active session id `{}`; got `{}`",
                display_safe(state.session_id.as_deref().unwrap_or("unknown")),
                display_safe(session_id),
            ),
        }));
    }

    let actor_allowed = match authority {
        AppendWriteAuthority::OwnerOnly => state.owner_id.as_deref() == Some(actor_id),
        AppendWriteAuthority::OwnerOrSupervisor => {
            state.owner_id.as_deref() == Some(actor_id)
                || state.supervisor_id.as_deref() == Some(actor_id)
        }
    };

    if actor_allowed {
        return Ok(None);
    }

    let expected_actor = match authority {
        AppendWriteAuthority::OwnerOnly => state.owner_id.as_deref().unwrap_or("unknown"),
        AppendWriteAuthority::OwnerOrSupervisor => state
            .owner_id
            .as_deref()
            .or(state.supervisor_id.as_deref())
            .unwrap_or("unknown"),
    };

    Ok(Some(AppendWriteConflict {
        outcome: AppendWriteOutcome::OwnershipConflict,
        message: match authority {
            AppendWriteAuthority::OwnerOnly => format!(
                "`{surface}` requires owner actor `{}`; got `{}`",
                display_safe(expected_actor),
                display_safe(actor_id),
            ),
            AppendWriteAuthority::OwnerOrSupervisor => format!(
                "`{surface}` requires the active owner `{}` or supervisor `{}`; got `{}`",
                display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
                display_safe(state.supervisor_id.as_deref().unwrap_or("unknown")),
                display_safe(actor_id),
            ),
        },
    }))
}

fn missing_append_identity_message(
    state: &session::SessionStateFile,
    surface: &str,
    authority: AppendWriteAuthority,
) -> String {
    match authority {
        AppendWriteAuthority::OwnerOnly => format!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id` and `--session-id` matching the active owner",
            display_safe(state.session_id.as_deref().unwrap_or("unknown")),
            display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
        ),
        AppendWriteAuthority::OwnerOrSupervisor => format!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id` and `--session-id` matching the active owner or supervisor",
            display_safe(state.session_id.as_deref().unwrap_or("unknown")),
            display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::super::session::{SessionMode, SessionOwnerKind, SessionStateFile};
    use super::*;

    /// ccd#576: identifiers rendered into protected-write conflict
    /// messages must not carry literal newlines or ANSI escapes
    /// through to stderr, even when the surrounding session state
    /// holds attacker-controlled `owner_id` / `session_id`.
    #[test]
    fn missing_append_identity_message_escapes_newlines_in_session_fields() {
        let state = SessionStateFile {
            // The tested function only reads `session_id` / `owner_id`;
            // schema_version is a dummy here, not load-bearing.
            schema_version: 0,
            started_at_epoch_s: 0,
            last_started_at_epoch_s: 0,
            start_count: 1,
            session_id: Some("ses\nFAKE".to_owned()),
            mode: SessionMode::General,
            owner_kind: SessionOwnerKind::RuntimeWorker,
            owner_id: Some("owner\x1b[2Jroot".to_owned()),
            supervisor_id: None,
            lease_ttl_secs: Some(3600),
            last_heartbeat_at_epoch_s: Some(0),
            revision: 1,
        };

        let rendered = missing_append_identity_message(
            &state,
            "handoff write",
            AppendWriteAuthority::OwnerOnly,
        );
        assert!(
            !rendered.contains('\n'),
            "message must not contain literal newlines; got: {rendered:?}"
        );
        assert!(
            !rendered.contains('\x1b'),
            "message must not contain literal ESC bytes; got: {rendered:?}"
        );
        assert!(
            rendered.contains(r"ses\nFAKE"),
            "session_id must render in escaped form; got: {rendered:?}"
        );
        assert!(
            rendered.contains(r"owner\x1b[2Jroot"),
            "owner_id must render in escaped form; got: {rendered:?}"
        );
    }
}