im-core 0.1.0

Rust IM SDK for Awiki clients built on Agent Network Protocol (ANP)
Documentation
use serde_json::Value;

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ReplaceDidBackupResult {
    pub(crate) backup_path: String,
    pub(crate) manifest: crate::identity::ReplaceDidBackupManifestPreview,
}

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ReplaceDidLocalUpdate {
    pub(crate) identity: crate::identity::IdentitySummary,
    pub(crate) new_did: crate::ids::Did,
}

pub(crate) trait ReplaceDidExecutionBridge {
    fn create_backup(
        &mut self,
        plan: &crate::identity::ReplaceDidPlan,
    ) -> crate::ImResult<ReplaceDidBackupResult>;

    fn remote_replace_did(
        &mut self,
        endpoint: &str,
        method: &str,
        params: Value,
    ) -> crate::ImResult<Value>;

    fn replace_local_identity(
        &mut self,
        request: &crate::identity::ReplaceDidExecutionRequest,
        remote_result: &Value,
    ) -> crate::ImResult<ReplaceDidLocalUpdate>;

    fn rebind_local_state(
        &mut self,
        owner_identity_id: &crate::ids::IdentityId,
        old_did: &crate::ids::Did,
        new_did: &crate::ids::Did,
    ) -> crate::ImResult<crate::identity::ReplaceDidAffectedLocalState>;
}

pub(crate) struct ReplaceDidExecutionRuntime<'a, B> {
    _client: &'a crate::core::ImClient,
    bridge: B,
}

impl<'a, B> ReplaceDidExecutionRuntime<'a, B>
where
    B: ReplaceDidExecutionBridge,
{
    pub(crate) fn new(client: &'a crate::core::ImClient, bridge: B) -> Self {
        Self {
            _client: client,
            bridge,
        }
    }

    pub(crate) fn execute(
        mut self,
        request: crate::identity::ReplaceDidExecutionRequest,
    ) -> crate::ImResult<crate::identity::ReplaceDidExecutionResult> {
        validate_request(&request)?;
        let backup = self.bridge.create_backup(&request.plan)?;
        let call = crate::internal::identity_wire::replace_did::build_replace_did_rpc_call(
            crate::internal::identity_wire::ReplaceDidRpcParams {
                new_did_document: request.generated_identity.did_document.clone(),
                is_public: request.is_public,
                is_agent: request.is_agent,
                role: request.role.clone(),
                endpoint_url: request.endpoint_url.clone(),
            },
        );
        let remote_result =
            self.bridge
                .remote_replace_did(call.endpoint, call.method, call.params.clone())?;
        let local_update = self
            .bridge
            .replace_local_identity(&request, &remote_result)
            .map_err(|err| local_failure_after_remote("local identity update", err, &backup))?;
        let affected_local_state = self
            .bridge
            .rebind_local_state(
                &request.plan.identity.id,
                &request.plan.identity.did,
                &local_update.new_did,
            )
            .map_err(|err| local_failure_after_remote("local DID history update", err, &backup))?;

        Ok(crate::identity::ReplaceDidExecutionResult {
            identity: local_update.identity,
            old_did: request.plan.identity.did,
            new_did: local_update.new_did,
            backup_path: backup.backup_path,
            backup_manifest: backup.manifest,
            affected_local_state,
            remote_result,
            warnings: Vec::new(),
            recovery_notes: Vec::new(),
        })
    }
}

fn validate_request(request: &crate::identity::ReplaceDidExecutionRequest) -> crate::ImResult<()> {
    crate::internal::identity_replace_did_plan::validate_request_shape(&request.plan)?;
    if !request.plan.backup_plan.required {
        return Err(crate::ImError::invalid_input(
            Some("plan.backup_plan.required".to_string()),
            "replace DID execution requires a backup plan",
        ));
    }
    if request
        .plan
        .backup_plan
        .backup_path_preview
        .trim()
        .is_empty()
    {
        return Err(crate::ImError::invalid_input(
            Some("plan.backup_plan.backup_path_preview".to_string()),
            "replace DID execution requires a backup path preview",
        ));
    }
    if !request.generated_identity.did_document.is_object() {
        return Err(crate::ImError::invalid_input(
            Some("generated_identity.did_document".to_string()),
            "replacement DID document must be an object",
        ));
    }
    if request.generated_identity.unique_id.trim().is_empty() {
        return Err(crate::ImError::invalid_input(
            Some("generated_identity.unique_id".to_string()),
            "replacement identity unique_id must not be empty",
        ));
    }
    Ok(())
}

fn local_failure_after_remote(
    phase: &str,
    err: crate::ImError,
    backup: &ReplaceDidBackupResult,
) -> crate::ImError {
    crate::ImError::LocalStateUnavailable {
        detail: format!(
            "{phase} failed after remote replace_did succeeded: {err}. Restore from backup {} or inspect backup manifest for manual recovery.",
            backup.backup_path
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn replace_did_execution_orders_backup_remote_local_history_update() {
        let client = fixture_client();
        let result = ReplaceDidExecutionRuntime::new(
            &client,
            TestBridge {
                events: Vec::new(),
                fail_backup: false,
                fail_local: false,
                fail_rebind: false,
            },
        )
        .execute(execution_request())
        .unwrap();

        assert_eq!(result.backup_path, "/tmp/backup");
        assert_eq!(result.new_did.as_str(), "did:wba:awiki.test:alice:e1_new");
        assert_eq!(result.remote_result["user_id"], "user-alice");
        assert_eq!(
            result.affected_local_state.store_rebind_counts["messages"],
            1
        );
    }

    #[test]
    fn replace_did_execution_stops_before_remote_when_backup_fails() {
        let client = fixture_client();
        let err = ReplaceDidExecutionRuntime::new(
            &client,
            TestBridge {
                events: Vec::new(),
                fail_backup: true,
                fail_local: false,
                fail_rebind: false,
            },
        )
        .execute(execution_request())
        .unwrap_err();

        assert!(err.to_string().contains("backup failed"));
    }

    #[test]
    fn replace_did_execution_reports_manual_recovery_when_local_update_fails_after_remote() {
        let client = fixture_client();
        let err = ReplaceDidExecutionRuntime::new(
            &client,
            TestBridge {
                events: Vec::new(),
                fail_backup: false,
                fail_local: true,
                fail_rebind: false,
            },
        )
        .execute(execution_request())
        .unwrap_err();

        assert!(err
            .to_string()
            .contains("after remote replace_did succeeded"));
        assert!(err.to_string().contains("/tmp/backup"));
    }

    #[derive(Debug)]
    struct TestBridge {
        events: Vec<&'static str>,
        fail_backup: bool,
        fail_local: bool,
        fail_rebind: bool,
    }

    impl ReplaceDidExecutionBridge for TestBridge {
        fn create_backup(
            &mut self,
            plan: &crate::identity::ReplaceDidPlan,
        ) -> crate::ImResult<ReplaceDidBackupResult> {
            self.events.push("backup");
            if self.fail_backup {
                return Err(crate::ImError::Internal {
                    message: "backup failed".to_string(),
                });
            }
            Ok(ReplaceDidBackupResult {
                backup_path: "/tmp/backup".to_string(),
                manifest: plan.backup_plan.manifest_preview.clone(),
            })
        }

        fn remote_replace_did(
            &mut self,
            endpoint: &str,
            method: &str,
            params: Value,
        ) -> crate::ImResult<Value> {
            self.events.push("remote");
            assert_eq!(
                endpoint,
                crate::internal::identity_wire::DID_AUTH_RPC_ENDPOINT
            );
            assert_eq!(method, "replace_did");
            assert!(params["new_did_document"].is_object());
            Ok(serde_json::json!({
                "did": "did:wba:awiki.test:alice:e1_new",
                "user_id": "user-alice",
                "access_token": "jwt-new"
            }))
        }

        fn replace_local_identity(
            &mut self,
            _request: &crate::identity::ReplaceDidExecutionRequest,
            _remote_result: &Value,
        ) -> crate::ImResult<ReplaceDidLocalUpdate> {
            self.events.push("local");
            if self.fail_local {
                return Err(crate::ImError::LocalStateUnavailable {
                    detail: "write identity failed".to_string(),
                });
            }
            Ok(ReplaceDidLocalUpdate {
                identity: identity_summary("did:wba:awiki.test:alice:e1_new"),
                new_did: crate::ids::Did::parse("did:wba:awiki.test:alice:e1_new").unwrap(),
            })
        }

        fn rebind_local_state(
            &mut self,
            _owner_identity_id: &crate::ids::IdentityId,
            _old_did: &crate::ids::Did,
            _new_did: &crate::ids::Did,
        ) -> crate::ImResult<crate::identity::ReplaceDidAffectedLocalState> {
            self.events.push("history");
            if self.fail_rebind {
                return Err(crate::ImError::LocalStateUnavailable {
                    detail: "history update failed".to_string(),
                });
            }
            Ok(crate::identity::ReplaceDidAffectedLocalState {
                store_rebind_counts: [("messages".to_string(), 1)].into_iter().collect(),
                e2ee_cleanup_counts: [("e2ee_sessions".to_string(), 1)].into_iter().collect(),
            })
        }
    }

    fn execution_request() -> crate::identity::ReplaceDidExecutionRequest {
        crate::identity::ReplaceDidExecutionRequest {
            plan: crate::internal::identity_replace_did_plan::plan_replace_did(
                crate::identity::ReplaceDidPlanRequest {
                    identity: identity_summary("did:wba:awiki.test:alice:e1_old"),
                    linked_identity_names: vec!["alice".to_string()],
                    planned_new_did: crate::ids::Did::parse("did:wba:awiki.test:alice:e1_new")
                        .unwrap(),
                    backup_path_preview: "/tmp/backup-preview".to_string(),
                    old_dir_name: "e1_old".to_string(),
                    is_public: Some(false),
                    is_agent: Some(true),
                    role: Some(String::new()),
                    endpoint_url: Some("https://example.test/agent".to_string()),
                    affected_local_state: crate::identity::ReplaceDidAffectedLocalState::default(),
                },
            )
            .unwrap(),
            generated_identity: crate::identity::ReplaceDidGeneratedIdentity {
                did: crate::ids::Did::parse("did:wba:awiki.test:alice:e1_new").unwrap(),
                unique_id: "e1_new".to_string(),
                did_document: serde_json::json!({
                    "id": "did:wba:awiki.test:alice:e1_new"
                }),
            },
            is_public: Some(false),
            is_agent: Some(true),
            role: Some(String::new()),
            endpoint_url: Some("https://example.test/agent".to_string()),
        }
    }

    fn identity_summary(did: &str) -> crate::identity::IdentitySummary {
        crate::identity::IdentitySummary {
            id: crate::ids::IdentityId::parse("alice-id").unwrap(),
            did: crate::ids::Did::parse(did).unwrap(),
            handle: Some(crate::ids::Handle::parse("alice.awiki.test", "").unwrap()),
            display_name: Some("Alice".to_string()),
            local_alias: Some("alice".to_string()),
            device_id: None,
            is_default: true,
            readiness: crate::identity::IdentityReadiness {
                ready_for_auth: true,
                ready_for_messaging: true,
                missing: Vec::new(),
            },
        }
    }

    fn fixture_client() -> crate::core::ImClient {
        let core = crate::core::ImCore::new(
            crate::config::ImCoreConfig {
                service_base_url: crate::config::ServiceEndpoint::parse("https://example.test")
                    .unwrap(),
                did_domain: "awiki.test".to_string(),
                user_service_endpoint: None,
                message_service_endpoint: None,
                mail_service_endpoint: None,
                anp_service_endpoint: None,
                anp_service_did: None,
                ca_bundle: None,
                transport_policy: crate::config::MessageTransportPolicy::HttpOnly,
            },
            crate::paths::ImCorePaths {
                identities: crate::paths::IdentityRegistryPaths {
                    identity_root_dir: std::path::PathBuf::from("/tmp/im-core-identities"),
                    registry_path: std::path::PathBuf::from(
                        "/tmp/im-core-identities/registry.json",
                    ),
                    default_identity_path: Some(std::path::PathBuf::from(
                        "/tmp/im-core-identities/default",
                    )),
                },
                local_state: crate::paths::LocalStatePaths {
                    sqlite_path: std::path::PathBuf::from("/tmp/im-core-local/im.sqlite"),
                },
                runtime: crate::paths::RuntimePaths {
                    cache_dir: std::path::PathBuf::from("/tmp/im-core-cache"),
                    temp_dir: std::path::PathBuf::from("/tmp/im-core-tmp"),
                },
            },
        )
        .unwrap();
        core.client(crate::identity::IdentitySelector::Did(
            crate::ids::Did::parse("did:wba:awiki.test:alice:e1_old").unwrap(),
        ))
        .unwrap()
    }
}