Skip to main content

canic_host/icp/
identity.rs

1//! Module: icp::identity
2//!
3//! Responsibility: bind an operation's selected ICP identity and resolve its Principal.
4//! Does not own: global identity selection, authentication policy, or controller authorization.
5//! Boundary: callers compare the returned text with their own typed authority.
6
7use super::{error::IcpCommandError, model::IcpCli, run::run_output};
8
9impl IcpCli {
10    /// Freeze the selected identity for this operation and every clone of its transport.
11    /// Callers must still compare its Principal with the reviewed operator before effects.
12    pub(crate) fn bind_selected_identity(&self) -> Result<(), IcpCommandError> {
13        if self.selected_identity.get().is_none() {
14            let identity = self.selected_identity_name()?;
15            let _ = self.selected_identity.set(identity);
16        }
17        Ok(())
18    }
19
20    pub(super) fn selected_identity_name(&self) -> Result<String, IcpCommandError> {
21        if let Some(identity) = self.selected_identity.get() {
22            return Ok(identity.clone());
23        }
24        let mut command = self.request_command();
25        command.args(["identity", "default"]);
26        let identity = run_output(&mut command, self)?;
27        if identity.is_empty() {
28            return Err(std::io::Error::new(
29                std::io::ErrorKind::InvalidData,
30                "ICP returned an empty selected identity",
31            )
32            .into());
33        }
34        Ok(identity)
35    }
36
37    /// Return the Principal text for the identity that will execute ICP commands.
38    pub fn identity_principal_text(&self) -> Result<String, IcpCommandError> {
39        self.identity_lookups
40            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
41        let started = std::time::Instant::now();
42        let mut command = self.identity_principal_command();
43        let result = self.measure_request(super::IcpRequestKind::Identity, None, None, || {
44            run_output(&mut command, self)
45        });
46        self.identity_lookup_millis.fetch_add(
47            u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
48            std::sync::atomic::Ordering::Relaxed,
49        );
50        result
51    }
52
53    /// Return the selected identity's exact default account in the requested ledger format.
54    pub fn identity_account_id_text(
55        &self,
56        format: IcpIdentityAccountFormat,
57    ) -> Result<String, IcpCommandError> {
58        let mut command = self.identity_account_id_command(format);
59        run_output(&mut command, self)
60    }
61
62    fn identity_principal_command(&self) -> std::process::Command {
63        let mut command = self.request_command();
64        command.args(["identity", "principal"]);
65        self.add_selected_identity_arg(&mut command);
66        command
67    }
68
69    fn identity_account_id_command(
70        &self,
71        format: IcpIdentityAccountFormat,
72    ) -> std::process::Command {
73        let mut command = self.request_command();
74        command.args(["identity", "account-id", "--format", format.label()]);
75        self.add_selected_identity_arg(&mut command);
76        command
77    }
78}
79
80///
81/// IcpIdentityAccountFormat
82///
83/// Ledger account representation selected for one ICP CLI identity observation.
84///
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum IcpIdentityAccountFormat {
87    IcpLedger,
88    Icrc1,
89}
90
91impl IcpIdentityAccountFormat {
92    const fn label(self) -> &'static str {
93        match self {
94            Self::IcpLedger => "ledger",
95            Self::Icrc1 => "icrc1",
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::icp::command::command_display;
104
105    #[cfg(unix)]
106    #[test]
107    fn explicit_identities_keep_concurrent_contexts_independent_without_default_access() {
108        use std::{fs, os::unix::fs::PermissionsExt};
109
110        let root = crate::test_support::temp_dir("explicit-icp-identities");
111        fs::create_dir_all(&root).unwrap();
112        let executable = root.join("icp");
113        fs::write(
114            &executable,
115            crate::test_support::tool_script(
116                r#"#!/bin/sh
117if [ "$1" = --version ]; then echo 'icp @ICP_VERSION@'; exit 0; fi
118while [ "$1" = --project-root-override ] || [ "$1" = --identity-password-file ]; do shift 2; done
119if [ "$1 $2" = 'identity default' ]; then touch default-accessed; exit 91; fi
120for arg do
121  if [ "$previous" = --identity ]; then selected=$arg; fi
122  previous=$arg
123done
124test -n "$selected" || exit 92
125printf '%s\n' "$selected"
126"#,
127            ),
128        )
129        .unwrap();
130        fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
131        fs::write(root.join("selected"), b"unrelated-default\n").unwrap();
132        let base = IcpCli::new(executable.to_str().unwrap(), None).with_cwd(&root);
133        std::thread::scope(|scope| {
134            for name in ["operator-a", "operator-b"] {
135                let context = base.clone().with_identity(Some(name));
136                scope.spawn(move || {
137                    context.bind_selected_identity().unwrap();
138                    let clone = context.clone();
139                    assert_eq!(clone.selected_identity_name().unwrap(), name);
140                    assert_eq!(clone.identity_principal_text().unwrap(), name);
141                    assert_eq!(
142                        clone
143                            .identity_account_id_text(IcpIdentityAccountFormat::Icrc1)
144                            .unwrap(),
145                        name
146                    );
147                    assert_eq!(
148                        clone
149                            .canister_metadata_output("canister", "candid:service")
150                            .unwrap(),
151                        name
152                    );
153                });
154            }
155        });
156        assert!(base.selected_identity.get().is_none());
157        assert!(!root.join("default-accessed").exists());
158        assert_eq!(
159            fs::read(root.join("selected")).unwrap(),
160            b"unrelated-default\n"
161        );
162        fs::remove_dir_all(root).unwrap();
163    }
164
165    #[cfg(unix)]
166    #[test]
167    fn bound_identity_survives_default_change_for_clones_and_later_calls() {
168        use std::{fs, os::unix::fs::PermissionsExt};
169
170        let root = crate::test_support::temp_dir("bound-icp-identity");
171        fs::create_dir_all(&root).unwrap();
172        let executable = root.join("icp");
173        fs::write(
174            &executable,
175            crate::test_support::tool_script(
176                r#"#!/bin/sh
177if [ "$1" = --version ]; then echo 'icp @ICP_VERSION@'; exit 0; fi
178while [ "$1" = --project-root-override ] || [ "$1" = --identity-password-file ]; do shift 2; done
179if [ "$1 $2" = 'identity default' ]; then cat selected; exit 0; fi
180selected=$(cat selected)
181for arg do
182  if [ "$previous" = --identity ]; then selected=$arg; fi
183  previous=$arg
184done
185printf '%s\n' "$selected"
186"#,
187            ),
188        )
189        .unwrap();
190        fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
191        fs::write(root.join("selected"), "reviewed").unwrap();
192        let icp = IcpCli::new(executable.to_str().unwrap(), None).with_cwd(&root);
193        let clone = icp.clone();
194        icp.bind_selected_identity().unwrap();
195        assert_eq!(icp.identity_principal_text().unwrap(), "reviewed");
196        fs::write(root.join("selected"), "unrelated-encrypted").unwrap();
197        clone.bind_selected_identity().unwrap();
198        assert_eq!(clone.identity_principal_text().unwrap(), "reviewed");
199        assert_eq!(
200            clone
201                .identity_account_id_text(IcpIdentityAccountFormat::Icrc1)
202                .unwrap(),
203            "reviewed"
204        );
205        assert_eq!(clone.selected_identity_name().unwrap(), "reviewed");
206        assert_eq!(
207            clone
208                .canister_metadata_output("canister", "candid:service")
209                .unwrap(),
210            "reviewed"
211        );
212        let unbound = IcpCli::new(executable.to_str().unwrap(), None).with_cwd(&root);
213        assert_eq!(
214            unbound.identity_principal_text().unwrap(),
215            "unrelated-encrypted"
216        );
217        assert_ne!(clone, unbound);
218        fs::remove_dir_all(root).unwrap();
219    }
220
221    #[test]
222    fn identity_resolution_uses_the_active_identity_without_network_selection() {
223        let icp = IcpCli::new("icp", Some("ic".to_string())).with_cwd("/workspace/app");
224
225        assert_eq!(
226            command_display(&icp.identity_principal_command()),
227            "icp --project-root-override /workspace/app identity principal"
228        );
229    }
230
231    #[test]
232    fn account_resolution_selects_the_exact_ledger_representation() {
233        let icp = IcpCli::new("icp", Some("ic".to_string())).with_cwd("/workspace/app");
234
235        assert_eq!(
236            command_display(&icp.identity_account_id_command(IcpIdentityAccountFormat::Icrc1)),
237            "icp --project-root-override /workspace/app identity account-id --format icrc1"
238        );
239        assert_eq!(
240            command_display(&icp.identity_account_id_command(IcpIdentityAccountFormat::IcpLedger)),
241            "icp --project-root-override /workspace/app identity account-id --format ledger"
242        );
243    }
244}