Skip to main content

iroh_services/
client.rs

1use std::{
2    collections::BTreeMap,
3    str::FromStr,
4    sync::{Arc, RwLock},
5};
6
7use anyhow::{Result, anyhow, ensure};
8use iroh::{Endpoint, EndpointAddr, EndpointId, endpoint::ConnectError};
9use iroh_metrics::{MetricsGroup, Registry, encoding::Encoder};
10use irpc_iroh::IrohLazyRemoteConnection;
11use n0_error::StackResultExt;
12use n0_future::{task::AbortOnDropHandle, time::Duration};
13use rcan::Rcan;
14use tokio::sync::oneshot;
15use tracing::{debug, trace, warn};
16use uuid::Uuid;
17
18use crate::{
19    api_secret::{API_SECRET_ENV_VAR_NAME, ApiSecret},
20    caps::{Caps, DEFAULT_CAP_EXPIRY},
21    net_diagnostics::{DiagnosticsReport, checks::run_diagnostics},
22    protocol::{
23        ALPN, Auth, IrohServicesClient, NameEndpoint, Ping, Pong, PutMetrics,
24        PutNetworkDiagnostics, RemoteError, SetAttributes, SetGroup,
25    },
26};
27
28/// Client is the main handle for interacting with iroh-services. It communicates with
29/// iroh-services entirely through an iroh endpoint, and is configured through a builder.
30/// Client requires either an Ssh Key or [`ApiSecret`]
31///
32/// ```no_run
33/// use iroh::{Endpoint, endpoint::presets};
34/// use iroh_services::Client;
35///
36/// async fn build_client() -> anyhow::Result<()> {
37///     let endpoint = Endpoint::bind(presets::N0).await?;
38///
39///     // needs IROH_SERVICES_API_SECRET set to an environment variable
40///     // client will now push endpoint metrics to iroh-services.
41///     let client = Client::builder(&endpoint)
42///         .api_secret_from_str("MY_API_SECRET")?
43///         .build()
44///         .await;
45///
46///     Ok(())
47/// }
48/// ```
49///
50/// [`ApiSecret`]: crate::api_secret::ApiSecret
51#[derive(Debug, Clone)]
52pub struct Client {
53    // owned clone of the endpoint for diagnostics, and for connection restarts on actor close
54    #[allow(dead_code)]
55    endpoint: Endpoint,
56    message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
57    _actor_task: Arc<AbortOnDropHandle<()>>,
58}
59
60/// ClientBuilder provides configures and builds a iroh-services client, typically
61/// created with [`Client::builder`]
62pub struct ClientBuilder {
63    #[allow(dead_code)]
64    cap_expiry: Duration,
65    cap: Option<Rcan<Caps>>,
66    endpoint: Endpoint,
67    name: Option<String>,
68    group: Option<String>,
69    attributes: Option<BTreeMap<String, String>>,
70    metrics_interval: Option<Duration>,
71    remote: Option<EndpointAddr>,
72    registry: Registry,
73}
74
75impl ClientBuilder {
76    pub fn new(endpoint: &Endpoint) -> Self {
77        let mut registry = Registry::default();
78        registry.register_all(endpoint.metrics());
79
80        Self {
81            cap: None,
82            cap_expiry: DEFAULT_CAP_EXPIRY,
83            endpoint: endpoint.clone(),
84            name: None,
85            group: None,
86            attributes: None,
87            metrics_interval: Some(Duration::from_secs(60)),
88            remote: None,
89            registry,
90        }
91    }
92
93    /// Register a metrics group to forward to iroh-services
94    ///
95    /// The default registered metrics uses only the endpoint
96    pub fn register_metrics_group(mut self, metrics_group: Arc<dyn MetricsGroup>) -> Self {
97        self.registry.register(metrics_group);
98        self
99    }
100
101    /// Set the metrics collection interval
102    ///
103    /// Defaults to enabled, every 60 seconds.
104    pub fn metrics_interval(mut self, interval: Duration) -> Self {
105        self.metrics_interval = Some(interval);
106        self
107    }
108
109    /// Disable metrics collection.
110    pub fn disable_metrics_interval(mut self) -> Self {
111        self.metrics_interval = None;
112        self
113    }
114
115    /// Set an optional human-readable name for the endpoint, making its metrics
116    /// easier to identify.
117    ///
118    /// Often a database user id, machine name, or other stable identifier from
119    /// your application. A name must be 2 to 128 bytes of UTF-8; uniqueness is not
120    /// enforced, so different endpoints may share a name.
121    ///
122    /// Validation errors are returned here. The name is sent to the server after
123    /// the client authenticates; a failure to send it at that point is logged at
124    /// warn level rather than returned; use [`Client::set_name`] to set it later
125    /// with explicit error handling.
126    pub fn name(mut self, name: impl Into<String>) -> Result<Self> {
127        let name = name.into();
128        validate_name(&name).map_err(BuildError::InvalidName)?;
129        self.name = Some(name);
130        Ok(self)
131    }
132
133    /// Attach the endpoint to a single named group when the client first
134    /// authenticates.
135    ///
136    /// A group name must be 2 to 128 bytes of UTF-8. Validation errors are returned
137    /// here. The group is sent to the server after the client authenticates; a
138    /// failure to send it at that point is logged at warn level rather than
139    /// returned; use [`Client::set_group`] to set it later with explicit error
140    /// handling.
141    pub fn group(mut self, group: impl Into<String>) -> Result<Self> {
142        let group = group.into();
143        validate_name(&group).map_err(BuildError::InvalidGroup)?;
144        self.group = Some(group);
145        Ok(self)
146    }
147
148    /// Attach arbitrary key-value attributes to the endpoint when the client
149    /// first authenticates. Accepts any iterable of `(key, value)` pairs:
150    ///
151    /// ```no_run
152    /// # use iroh::{Endpoint, endpoint::presets};
153    /// # use iroh_services::Client;
154    /// # async fn example(endpoint: &Endpoint) -> anyhow::Result<()> {
155    /// let _ = Client::builder(endpoint).attributes([("env", "prod"), ("region", "us-west")])?;
156    /// # Ok(()) }
157    /// ```
158    ///
159    /// Each key must be 2 to 128 bytes of UTF-8; values may be empty and are capped
160    /// at 128 bytes; at most 128 entries are allowed. Validation errors are
161    /// returned here. The attributes are sent to the server after the client
162    /// authenticates; a failure to send them at that point is logged at warn
163    /// level rather than returned; use [`Client::set_attributes`] to set them
164    /// later with explicit error handling.
165    pub fn attributes<I, K, V>(mut self, attrs: I) -> Result<Self>
166    where
167        I: IntoIterator<Item = (K, V)>,
168        K: Into<String>,
169        V: Into<String>,
170    {
171        let collected: BTreeMap<String, String> = attrs
172            .into_iter()
173            .map(|(k, v)| (k.into(), v.into()))
174            .collect();
175        validate_attributes(&collected).map_err(BuildError::InvalidAttributes)?;
176        self.attributes = Some(collected);
177        Ok(self)
178    }
179
180    /// Check IROH_SERVICES_API_SECRET environment variable for a valid API secret
181    pub fn api_secret_from_env(self) -> Result<Self> {
182        let ticket = ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)?;
183        self.api_secret(ticket)
184    }
185
186    /// set client API secret from an encoded string
187    pub fn api_secret_from_str(self, secret_key: &str) -> Result<Self> {
188        let key = ApiSecret::from_str(secret_key).context("invalid iroh services api secret")?;
189        self.api_secret(key)
190    }
191
192    /// Use a shared secret & remote iroh-services endpoint ID contained within a ticket
193    /// to construct a iroh-services client. The resulting client will have "Client"
194    /// capabilities.
195    ///
196    /// API secrets include remote details within them, and will set both the
197    /// remote and rcan values on the builder
198    pub fn api_secret(mut self, ticket: ApiSecret) -> Result<Self> {
199        let local_id = self.endpoint.id();
200        let rcan = crate::caps::create_api_token_from_secret_key(
201            ticket.secret,
202            local_id,
203            self.cap_expiry,
204            Caps::for_shared_secret(),
205        )?;
206
207        self.remote = Some(ticket.remote);
208        self.rcan(rcan)
209    }
210
211    /// Loads the private ssh key from the given path, and creates the needed capability.
212    ///
213    /// The file must contain an unencrypted PEM-encoded OpenSSH ed25519 private key.
214    #[cfg(not(wasm_browser))]
215    pub async fn ssh_key_from_file<P: AsRef<std::path::Path>>(self, path: P) -> Result<Self> {
216        let file_content = tokio::fs::read_to_string(path).await?;
217        self.ssh_key(&file_content)
218    }
219
220    /// Creates the capability from the provided PEM-encoded OpenSSH ed25519 private key.
221    #[cfg(not(wasm_browser))]
222    pub fn ssh_key(mut self, pem: &str) -> Result<Self> {
223        let local_id = self.endpoint.id();
224        let rcan = crate::caps::create_api_token_from_openssh_pem(
225            pem,
226            local_id,
227            self.cap_expiry,
228            Caps::all(),
229        )?;
230        self.cap.replace(rcan);
231
232        Ok(self)
233    }
234
235    /// Sets the rcan directly.
236    pub fn rcan(mut self, cap: Rcan<Caps>) -> Result<Self> {
237        ensure!(
238            EndpointId::from_verifying_key(*cap.audience()) == self.endpoint.id(),
239            "invalid audience"
240        );
241        self.cap.replace(cap);
242        Ok(self)
243    }
244
245    /// Sets the remote to dial, must be provided either directly by calling
246    /// this method, or through calling the api_secret builder methods.
247    pub fn remote(mut self, remote: impl Into<EndpointAddr>) -> Self {
248        self.remote = Some(remote.into());
249        self
250    }
251
252    /// Create a new client, connected to the provide service node
253    #[must_use = "dropping the client will silently cancel all client tasks"]
254    pub async fn build(self) -> Result<Client, BuildError> {
255        debug!("starting iroh-services client");
256        let remote = self.remote.ok_or(BuildError::MissingRemote)?;
257        let capabilities = self.cap.ok_or(BuildError::MissingCapability)?;
258
259        let conn = IrohLazyRemoteConnection::new(self.endpoint.clone(), remote, ALPN.to_vec());
260        let irpc_client = IrohServicesClient::boxed(conn);
261
262        let (tx, rx) = tokio::sync::mpsc::channel(1);
263        let actor_task = AbortOnDropHandle::new(n0_future::task::spawn(
264            ClientActor {
265                capabilities,
266                client: irpc_client,
267                name: self.name.clone(),
268                group: self.group.clone(),
269                attributes: self.attributes.clone().unwrap_or_default(),
270                session_id: Uuid::new_v4(),
271                authorized: false,
272            }
273            .run(self.registry, self.metrics_interval, rx),
274        ));
275
276        Ok(Client {
277            endpoint: self.endpoint,
278            message_channel: tx,
279            _actor_task: Arc::new(actor_task),
280        })
281    }
282}
283
284#[derive(thiserror::Error, Debug)]
285pub enum BuildError {
286    #[error("Missing remote endpoint to dial")]
287    MissingRemote,
288    #[error("Missing capability")]
289    MissingCapability,
290    #[error("Unauthorized")]
291    Unauthorized,
292    #[error("Remote error: {0}")]
293    Remote(#[from] RemoteError),
294    #[error("Rpc connection error: {0}")]
295    Rpc(irpc::Error),
296    #[error("Connection error: {0}")]
297    Connect(ConnectError),
298    #[error("Invalid endpoint name: {0}")]
299    InvalidName(#[from] ValidateNameError),
300    #[error("Invalid endpoint group: {0}")]
301    InvalidGroup(ValidateNameError),
302    #[error("Invalid endpoint attributes: {0}")]
303    InvalidAttributes(#[from] ValidateAttributesError),
304}
305
306impl From<irpc::Error> for BuildError {
307    fn from(value: irpc::Error) -> Self {
308        match value {
309            irpc::Error::Request {
310                source:
311                    irpc::RequestError::Connection {
312                        source: iroh::endpoint::ConnectionError::ApplicationClosed(frame),
313                        ..
314                    },
315                ..
316            } if frame.error_code == 401u32.into() => Self::Unauthorized,
317            value => Self::Rpc(value),
318        }
319    }
320}
321
322/// Minimum length in bytes for an endpoint name.
323pub const CLIENT_NAME_MIN_LENGTH: usize = 2;
324/// Maximum length in bytes for an endpoint name.
325pub const CLIENT_NAME_MAX_LENGTH: usize = 128;
326
327/// Error returned when an endpoint name fails validation.
328#[derive(Debug, thiserror::Error)]
329pub enum ValidateNameError {
330    #[error("Name is too long (must be no more than {CLIENT_NAME_MAX_LENGTH} bytes).")]
331    TooLong,
332    #[error("Name is too short (must be at least {CLIENT_NAME_MIN_LENGTH} bytes).")]
333    TooShort,
334}
335
336fn validate_name(name: &str) -> Result<(), ValidateNameError> {
337    if name.len() < CLIENT_NAME_MIN_LENGTH {
338        Err(ValidateNameError::TooShort)
339    } else if name.len() > CLIENT_NAME_MAX_LENGTH {
340        Err(ValidateNameError::TooLong)
341    } else {
342        Ok(())
343    }
344}
345
346/// Maximum length in bytes for an attribute value. Values may be empty.
347pub const CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH: usize = 128;
348/// Maximum number of entries allowed in the attributes map.
349pub const CLIENT_ATTRIBUTES_MAX_COUNT: usize = 128;
350
351/// Error returned when an attributes map fails validation.
352#[derive(Debug, thiserror::Error)]
353pub enum ValidateAttributesError {
354    #[error("Too many attributes (must be no more than {CLIENT_ATTRIBUTES_MAX_COUNT}).")]
355    TooManyEntries,
356    #[error("Invalid attribute key: {0}")]
357    InvalidKey(#[from] ValidateNameError),
358    #[error(
359        "Attribute value too long (must be no more than {CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH} bytes)."
360    )]
361    ValueTooLong,
362}
363
364fn validate_attributes(attrs: &BTreeMap<String, String>) -> Result<(), ValidateAttributesError> {
365    if attrs.len() > CLIENT_ATTRIBUTES_MAX_COUNT {
366        return Err(ValidateAttributesError::TooManyEntries);
367    }
368    for (k, v) in attrs {
369        validate_name(k)?;
370        if v.len() > CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH {
371            return Err(ValidateAttributesError::ValueTooLong);
372        }
373    }
374    Ok(())
375}
376
377#[derive(thiserror::Error, Debug)]
378pub enum Error {
379    #[error("Invalid endpoint name: {0}")]
380    InvalidName(#[from] ValidateNameError),
381    #[error("Invalid endpoint group: {0}")]
382    InvalidGroup(ValidateNameError),
383    #[error("Invalid endpoint attributes: {0}")]
384    InvalidAttributes(#[from] ValidateAttributesError),
385    #[error("Remote error: {0}")]
386    Remote(#[from] RemoteError),
387    #[error("Connection error: {0}")]
388    Rpc(#[from] irpc::Error),
389    #[error(transparent)]
390    Other(#[from] anyhow::Error),
391}
392
393impl Client {
394    pub fn builder(endpoint: &Endpoint) -> ClientBuilder {
395        ClientBuilder::new(endpoint)
396    }
397
398    /// Read the current endpoint name from the local client.
399    pub async fn name(&self) -> Result<Option<String>, Error> {
400        let (tx, rx) = oneshot::channel();
401        self.message_channel
402            .send(ClientActorMessage::ReadName { done: tx })
403            .await
404            .map_err(|_| Error::Other(anyhow!("sending name read request")))?;
405
406        rx.await
407            .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))
408    }
409
410    /// Read the current endpoint group from the local client.
411    pub async fn group(&self) -> Result<Option<String>, Error> {
412        let (tx, rx) = oneshot::channel();
413        self.message_channel
414            .send(ClientActorMessage::ReadGroup { done: tx })
415            .await
416            .map_err(|_| Error::Other(anyhow!("sending group read request")))?;
417
418        rx.await
419            .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))
420    }
421
422    /// Name the active endpoint cloud-side.
423    ///
424    /// names can be any UTF-8 string, with a min length of 2 bytes, and
425    /// maximum length of 128 bytes. **name uniqueness is not enforced.**
426    pub async fn set_name(&self, name: impl Into<String>) -> Result<(), Error> {
427        set_name_inner(self.message_channel.clone(), name.into()).await
428    }
429
430    /// Attach the active endpoint to a single named group cloud-side.
431    ///
432    /// A group name must be 2 to 128 bytes of UTF-8.
433    pub async fn set_group(&self, group: impl Into<String>) -> Result<(), Error> {
434        set_group_inner(self.message_channel.clone(), group.into()).await
435    }
436
437    /// Replace the arbitrary key-value attributes on the active endpoint cloud-side.
438    ///
439    /// Accepts any iterable of `(key, value)` pairs (arrays of tuples, `Vec`s,
440    /// `HashMap`s, `BTreeMap`s, etc.), so most calls fit on a single line:
441    ///
442    /// ```no_run
443    /// # use iroh_services::Client;
444    /// # async fn example(client: Client) -> anyhow::Result<()> {
445    /// client
446    ///     .set_attributes([("env", "prod"), ("region", "us-west")])
447    ///     .await?;
448    /// # Ok(()) }
449    /// ```
450    ///
451    /// Each key must be 2 to 128 bytes of UTF-8; values may be empty and are limited
452    /// to 128 bytes; at most 128 entries are allowed. Each call fully replaces
453    /// the prior set; passing an empty iterator clears all attributes.
454    pub async fn set_attributes<I, K, V>(&self, attrs: I) -> Result<(), Error>
455    where
456        I: IntoIterator<Item = (K, V)>,
457        K: Into<String>,
458        V: Into<String>,
459    {
460        let collected: BTreeMap<String, String> = attrs
461            .into_iter()
462            .map(|(k, v)| (k.into(), v.into()))
463            .collect();
464        set_attributes_inner(self.message_channel.clone(), collected).await
465    }
466
467    /// Set or replace a single attribute, merging it into the endpoint's existing
468    /// attributes rather than replacing the whole set.
469    ///
470    /// A convenience over [`set_attributes`](Self::set_attributes) when you only
471    /// need to change one value. The key must be 2 to 128 bytes of UTF-8 and the
472    /// value is limited to 128 bytes; the merged set must stay within 128 entries.
473    pub async fn set_attribute(
474        &self,
475        key: impl Into<String>,
476        value: impl Into<String>,
477    ) -> Result<(), Error> {
478        set_attribute_inner(self.message_channel.clone(), key.into(), value.into()).await
479    }
480
481    /// Pings the remote node.
482    pub async fn ping(&self) -> Result<Pong, Error> {
483        let (tx, rx) = oneshot::channel();
484        self.message_channel
485            .send(ClientActorMessage::Ping { done: tx })
486            .await
487            .map_err(|_| Error::Other(anyhow!("sending ping request")))?;
488
489        rx.await
490            .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
491            .map_err(Error::Remote)
492    }
493
494    /// immediately send a single dump of metrics to iroh-services. It's not necessary
495    /// to call this function if you're using a non-zero metrics interval,
496    /// which will automatically propagate metrics on the set interval for you
497    pub async fn push_metrics(&self) -> Result<(), Error> {
498        let (tx, rx) = oneshot::channel();
499        self.message_channel
500            .send(ClientActorMessage::SendMetrics { done: tx })
501            .await
502            .map_err(|_| Error::Other(anyhow!("sending metrics")))?;
503
504        rx.await
505            .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
506            .map_err(Error::Remote)
507    }
508
509    /// Grant capabilities to a remote endpoint. Creates a signed RCAN token
510    /// and sends it to iroh-services for storage. The remote can then use this token
511    /// when dialing back to authorize its requests.
512    pub async fn grant_capability(
513        &self,
514        remote_id: EndpointId,
515        caps: impl IntoIterator<Item = impl Into<crate::caps::Cap>>,
516    ) -> Result<(), Error> {
517        let cap = crate::caps::create_grant_token(
518            self.endpoint.secret_key().clone(),
519            remote_id,
520            DEFAULT_CAP_EXPIRY,
521            Caps::new(caps),
522        )
523        .map_err(Error::Other)?;
524
525        let (tx, rx) = oneshot::channel();
526        self.message_channel
527            .send(ClientActorMessage::GrantCap {
528                cap: Box::new(cap),
529                done: tx,
530            })
531            .await
532            .map_err(|_| Error::Other(anyhow!("granting capability")))?;
533
534        rx.await
535            .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
536    }
537
538    /// run local network status diagnostics, optionally uploading the results
539    pub async fn net_diagnostics(&self, send: bool) -> Result<DiagnosticsReport, Error> {
540        let report = run_diagnostics(&self.endpoint).await?;
541        if send {
542            let (tx, rx) = oneshot::channel();
543            self.message_channel
544                .send(ClientActorMessage::PutNetworkDiagnostics {
545                    done: tx,
546                    report: Box::new(report.clone()),
547                })
548                .await
549                .map_err(|_| Error::Other(anyhow!("sending network diagnostics report")))?;
550
551            let _ = rx
552                .await
553                .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?;
554        }
555
556        Ok(report)
557    }
558}
559
560enum ClientActorMessage {
561    SendMetrics {
562        done: oneshot::Sender<Result<(), RemoteError>>,
563    },
564    Ping {
565        done: oneshot::Sender<Result<Pong, RemoteError>>,
566    },
567    // GrantCap is used by the `client_host` feature flag
568    #[allow(dead_code)]
569    GrantCap {
570        // boxed to avoid large enum variants
571        cap: Box<Rcan<Caps>>,
572        done: oneshot::Sender<Result<(), Error>>,
573    },
574    PutNetworkDiagnostics {
575        report: Box<DiagnosticsReport>,
576        done: oneshot::Sender<Result<(), Error>>,
577    },
578    ReadName {
579        done: oneshot::Sender<Option<String>>,
580    },
581    ReadGroup {
582        done: oneshot::Sender<Option<String>>,
583    },
584    NameEndpoint {
585        name: String,
586        done: oneshot::Sender<Result<(), RemoteError>>,
587    },
588    SetGroup {
589        group: String,
590        done: oneshot::Sender<Result<(), RemoteError>>,
591    },
592    SetAttributes {
593        attributes: BTreeMap<String, String>,
594        done: oneshot::Sender<Result<(), RemoteError>>,
595    },
596    SetAttribute {
597        key: String,
598        value: String,
599        // Carries the full client `Error` (not just `RemoteError`) because the
600        // merged-set validation happens in the actor, where the current set is
601        // known, and can fail with a local `InvalidAttributes` error.
602        done: oneshot::Sender<Result<(), Error>>,
603    },
604}
605
606struct ClientActor {
607    capabilities: Rcan<Caps>,
608    client: IrohServicesClient,
609    name: Option<String>,
610    group: Option<String>,
611    attributes: BTreeMap<String, String>,
612    session_id: Uuid,
613    authorized: bool,
614}
615
616impl ClientActor {
617    async fn run(
618        mut self,
619        registry: Registry,
620        interval: Option<Duration>,
621        mut inbox: tokio::sync::mpsc::Receiver<ClientActorMessage>,
622    ) {
623        let registry = Arc::new(RwLock::new(registry));
624        let mut encoder = Encoder::new(registry);
625        let mut metrics_timer = interval.map(|interval| n0_future::time::interval(interval));
626        trace!("starting client actor");
627
628        // Send the initial metadata (set via the builder) once the actor starts.
629        // These live on `self`; a send failure here is logged, not fatal.
630        if let Some(name) = self.name.clone()
631            && let Err(err) = self.send_name_endpoint(name).await
632        {
633            warn!(err = %err, "failed setting endpoint name on startup");
634        }
635
636        if let Some(group) = self.group.clone()
637            && let Err(err) = self.send_set_group(group).await
638        {
639            warn!(err = %err, "failed setting endpoint group on startup");
640        }
641
642        if !self.attributes.is_empty()
643            && let Err(err) = self.send_set_attributes(self.attributes.clone()).await
644        {
645            warn!(err = %err, "failed setting endpoint attributes on startup");
646        }
647
648        loop {
649            trace!("client actor tick");
650            tokio::select! {
651                biased;
652                Some(msg) = inbox.recv() => {
653                    match msg {
654                        ClientActorMessage::Ping{ done } => {
655                            let res = self.send_ping().await;
656                            if let Err(err) = done.send(res) {
657                                debug!("failed to send ping: {:#?}", err);
658                                self.authorized = false;
659                            }
660                        },
661                        ClientActorMessage::SendMetrics{ done } => {
662                            trace!("sending metrics manually triggered");
663                            let res = self.send_metrics(&mut encoder).await;
664                            if let Err(err) = done.send(res) {
665                                debug!("failed to push metrics: {:#?}", err);
666                                self.authorized = false;
667                            }
668                        }
669                        ClientActorMessage::GrantCap{ cap, done } => {
670                            let res = self.grant_cap(*cap).await;
671                            if let Err(err) = done.send(res) {
672                                warn!("failed to grant capability: {:#?}", err);
673                            }
674                        }
675                        ClientActorMessage::ReadName{ done } => {
676                            if let Err(err) = done.send(self.name.clone()) {
677                                warn!("sending name value: {:#?}", err);
678                            }
679                        }
680                        ClientActorMessage::ReadGroup{ done } => {
681                            if let Err(err) = done.send(self.group.clone()) {
682                                warn!("sending group value: {:#?}", err);
683                            }
684                        }
685                        ClientActorMessage::NameEndpoint{ name, done } => {
686                            let res = self.send_name_endpoint(name).await;
687                            if let Err(err) = done.send(res) {
688                                warn!("failed to name endpoint: {:#?}", err);
689                            }
690                        }
691                        ClientActorMessage::SetGroup{ group, done } => {
692                            let res = self.send_set_group(group).await;
693                            if let Err(err) = done.send(res) {
694                                warn!("failed to set group: {:#?}", err);
695                            }
696                        }
697                        ClientActorMessage::SetAttributes{ attributes, done } => {
698                            let res = self.send_set_attributes(attributes).await;
699                            if let Err(err) = done.send(res) {
700                                warn!("failed to set attributes: {:#?}", err);
701                            }
702                        }
703                        ClientActorMessage::SetAttribute{ key, value, done } => {
704                            // Merge into the current set and validate the union:
705                            // adding one valid entry to a valid set can still
706                            // exceed the max entry count, so the single entry
707                            // being valid is not enough.
708                            let mut merged = self.attributes.clone();
709                            merged.insert(key, value);
710                            let res = match validate_attributes(&merged) {
711                                Ok(()) => {
712                                    self.send_set_attributes(merged).await.map_err(Error::Remote)
713                                }
714                                Err(err) => Err(Error::from(err)),
715                            };
716                            if let Err(err) = done.send(res) {
717                                warn!("failed to set attribute: {:#?}", err);
718                            }
719                        }
720                        ClientActorMessage::PutNetworkDiagnostics{ report, done } => {
721                            let res = self.put_network_diagnostics(*report).await;
722                            if let Err(err) = done.send(res) {
723                                warn!("failed to publish network diagnostics: {:#?}", err);
724                            }
725                        }
726                    }
727                }
728                _ = async {
729                    if let Some(ref mut timer) = metrics_timer {
730                        timer.tick().await;
731                    } else {
732                        std::future::pending::<()>().await;
733                    }
734                } => {
735                    trace!("metrics send tick");
736                    if let Err(err) = self.send_metrics(&mut encoder).await {
737                        debug!("failed to push metrics: {:#?}", err);
738                        self.authorized = false;
739                    }
740                },
741            }
742        }
743    }
744
745    // sends an authorization request to the server
746    async fn auth(&mut self) -> Result<(), RemoteError> {
747        if self.authorized {
748            return Ok(());
749        }
750        trace!("client authorizing");
751        self.client
752            .rpc(Auth {
753                caps: self.capabilities.clone(),
754            })
755            .await
756            .inspect_err(|e| debug!("authorization failed: {:?}", e))
757            .map_err(|e| RemoteError::AuthError(e.to_string()))?;
758        self.authorized = true;
759        Ok(())
760    }
761
762    async fn send_ping(&mut self) -> Result<Pong, RemoteError> {
763        trace!("client actor send ping");
764        self.auth().await?;
765
766        let req = rand::random();
767        self.client
768            .rpc(Ping { req_id: req })
769            .await
770            .inspect_err(|e| warn!("rpc ping error: {e}"))
771            .map_err(|_| RemoteError::InternalServerError)
772    }
773
774    async fn send_name_endpoint(&mut self, name: String) -> Result<(), RemoteError> {
775        trace!("client sending name endpoint request");
776        self.auth().await?;
777
778        self.client
779            .rpc(NameEndpoint { name: name.clone() })
780            .await
781            .inspect_err(|e| debug!("name endpoint error: {e}"))
782            .map_err(|_| RemoteError::InternalServerError)??;
783        self.name = Some(name);
784        Ok(())
785    }
786
787    async fn send_set_group(&mut self, group: String) -> Result<(), RemoteError> {
788        trace!("client sending set group request");
789        self.auth().await?;
790
791        self.client
792            .rpc(SetGroup {
793                group: group.clone(),
794            })
795            .await
796            .inspect_err(|e| debug!("set group error: {e}"))
797            .map_err(|_| RemoteError::InternalServerError)??;
798        self.group = Some(group);
799        Ok(())
800    }
801
802    async fn send_set_attributes(
803        &mut self,
804        attributes: BTreeMap<String, String>,
805    ) -> Result<(), RemoteError> {
806        trace!("client sending set attributes request");
807        self.auth().await?;
808
809        self.client
810            .rpc(SetAttributes {
811                attributes: attributes.clone(),
812            })
813            .await
814            .inspect_err(|e| debug!("set attributes error: {e}"))
815            .map_err(|_| RemoteError::InternalServerError)??;
816        self.attributes = attributes;
817        Ok(())
818    }
819
820    async fn send_metrics(&mut self, encoder: &mut Encoder) -> Result<(), RemoteError> {
821        trace!("client actor send metrics");
822        self.auth().await?;
823
824        let update = encoder.export();
825        // let delta = update_delta(&self.latest_ackd_update, &update);
826        let req = PutMetrics {
827            session_id: self.session_id,
828            update,
829        };
830
831        self.client
832            .rpc(req)
833            .await
834            .map_err(|_| RemoteError::InternalServerError)??;
835
836        Ok(())
837    }
838
839    async fn grant_cap(&mut self, cap: Rcan<Caps>) -> Result<(), Error> {
840        trace!("client actor grant capability");
841        self.auth().await?;
842
843        self.client
844            .rpc(crate::protocol::GrantCap { cap })
845            .await
846            .map_err(|_| RemoteError::InternalServerError)??;
847
848        Ok(())
849    }
850
851    async fn put_network_diagnostics(
852        &mut self,
853        report: crate::net_diagnostics::DiagnosticsReport,
854    ) -> Result<(), Error> {
855        trace!("client actor publish network diagnostics");
856        self.auth().await?;
857
858        let req = PutNetworkDiagnostics { report };
859
860        self.client
861            .rpc(req)
862            .await
863            .map_err(|_| RemoteError::InternalServerError)??;
864
865        Ok(())
866    }
867}
868
869async fn set_name_inner(
870    message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
871    name: String,
872) -> Result<(), Error> {
873    validate_name(&name)?;
874    debug!(name_len = name.len(), "calling set name");
875    let (tx, rx) = oneshot::channel();
876    message_channel
877        .send(ClientActorMessage::NameEndpoint { name, done: tx })
878        .await
879        .map_err(|_| Error::Other(anyhow!("sending name endpoint request")))?;
880    rx.await
881        .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
882        .map_err(Error::Remote)
883}
884
885async fn set_group_inner(
886    message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
887    group: String,
888) -> Result<(), Error> {
889    validate_name(&group).map_err(Error::InvalidGroup)?;
890    debug!(%group, "calling set group");
891    let (tx, rx) = oneshot::channel();
892    message_channel
893        .send(ClientActorMessage::SetGroup { group, done: tx })
894        .await
895        .map_err(|_| Error::Other(anyhow!("sending set group request")))?;
896    rx.await
897        .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
898        .map_err(Error::Remote)
899}
900
901async fn set_attributes_inner(
902    message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
903    attributes: BTreeMap<String, String>,
904) -> Result<(), Error> {
905    validate_attributes(&attributes)?;
906    debug!(attr_count = attributes.len(), "calling set attributes");
907    let (tx, rx) = oneshot::channel();
908    message_channel
909        .send(ClientActorMessage::SetAttributes {
910            attributes,
911            done: tx,
912        })
913        .await
914        .map_err(|_| Error::Other(anyhow!("sending set attributes request")))?;
915    rx.await
916        .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
917        .map_err(Error::Remote)
918}
919
920async fn set_attribute_inner(
921    message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
922    key: String,
923    value: String,
924) -> Result<(), Error> {
925    // Validation happens in the actor against the merged set (current attributes
926    // plus this entry), since only there is the current set known. Merging can
927    // exceed the entry-count limit even when this single entry is valid.
928    let (tx, rx) = oneshot::channel();
929    message_channel
930        .send(ClientActorMessage::SetAttribute {
931            key,
932            value,
933            done: tx,
934        })
935        .await
936        .map_err(|_| Error::Other(anyhow!("sending set attribute request")))?;
937    rx.await
938        .map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
939}
940
941#[cfg(test)]
942mod tests {
943    use std::collections::HashMap;
944
945    use iroh::{Endpoint, EndpointAddr, SecretKey, endpoint::presets};
946    use rand::{RngExt, SeedableRng};
947    use temp_env_vars::temp_env_vars;
948
949    use crate::{
950        Client,
951        api_secret::ApiSecret,
952        caps::{Cap, Caps},
953        client::{
954            API_SECRET_ENV_VAR_NAME, BuildError, CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH,
955            CLIENT_ATTRIBUTES_MAX_COUNT, CLIENT_NAME_MAX_LENGTH, Error, ValidateAttributesError,
956            ValidateNameError,
957        },
958    };
959
960    #[tokio::test]
961    #[temp_env_vars]
962    async fn test_api_key_from_env() {
963        // construct
964        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
965        let shared_secret = SecretKey::from_bytes(&rng.random());
966        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
967        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
968        unsafe {
969            std::env::set_var(API_SECRET_ENV_VAR_NAME, api_secret.to_string());
970        };
971
972        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
973
974        let builder = Client::builder(&endpoint).api_secret_from_env().unwrap();
975
976        let fake_endpoint_addr: EndpointAddr = fake_endpoint_id.into();
977        assert_eq!(builder.remote, Some(fake_endpoint_addr));
978
979        // Compare capability fields individually to avoid flaky timestamp
980        // mismatches between the builder's rcan and a freshly-created one.
981        let cap = builder.cap.as_ref().expect("expected capability to be set");
982        assert_eq!(cap.capability(), &Caps::new([Cap::Client]));
983        assert_eq!(cap.audience(), &endpoint.id().as_verifying_key());
984        assert_eq!(cap.issuer(), &shared_secret.public().as_verifying_key());
985    }
986
987    /// Assert that disabling metrics interval can manually send metrics without
988    /// panicking. Metrics sending itself is expected to fail.
989    #[tokio::test]
990    async fn test_no_metrics_interval() {
991        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(1);
992        let shared_secret = SecretKey::from_bytes(&rng.random());
993        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
994        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
995
996        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
997
998        let client = Client::builder(&endpoint)
999            .disable_metrics_interval()
1000            .api_secret(api_secret)
1001            .unwrap()
1002            .build()
1003            .await
1004            .unwrap();
1005
1006        let err = client.push_metrics().await;
1007        assert!(err.is_err());
1008    }
1009
1010    #[tokio::test]
1011    async fn test_name() {
1012        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1013        let shared_secret = SecretKey::from_bytes(&rng.random());
1014        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1015        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1016
1017        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1018
1019        let builder = Client::builder(&endpoint)
1020            .name("my-node 👋")
1021            .unwrap()
1022            .api_secret(api_secret)
1023            .unwrap();
1024
1025        assert_eq!(builder.name, Some("my-node 👋".to_string()));
1026
1027        let Err(err) = Client::builder(&endpoint).name("a") else {
1028            panic!("name should fail for strings under 2 bytes");
1029        };
1030        assert!(matches!(
1031            err.downcast_ref::<BuildError>(),
1032            Some(BuildError::InvalidName(ValidateNameError::TooShort))
1033        ));
1034
1035        let too_long_name = "👋".repeat(129);
1036        let Err(err) = Client::builder(&endpoint).name(&too_long_name) else {
1037            panic!("name should fail for strings over 128 bytes");
1038        };
1039        assert!(matches!(
1040            err.downcast_ref::<BuildError>(),
1041            Some(BuildError::InvalidName(ValidateNameError::TooLong))
1042        ));
1043    }
1044
1045    #[tokio::test]
1046    async fn test_group() {
1047        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1048        let shared_secret = SecretKey::from_bytes(&rng.random());
1049        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1050        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1051
1052        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1053
1054        let builder = Client::builder(&endpoint)
1055            .group("staging")
1056            .unwrap()
1057            .api_secret(api_secret)
1058            .unwrap();
1059
1060        assert_eq!(builder.group, Some("staging".to_string()));
1061
1062        let Err(err) = Client::builder(&endpoint).group("a") else {
1063            panic!("group should fail for strings under 2 bytes");
1064        };
1065        assert!(matches!(
1066            err.downcast_ref::<BuildError>(),
1067            Some(BuildError::InvalidGroup(ValidateNameError::TooShort))
1068        ));
1069
1070        let too_long_group = "👋".repeat(129);
1071        let Err(err) = Client::builder(&endpoint).group(&too_long_group) else {
1072            panic!("group should fail for strings over 128 bytes");
1073        };
1074        assert!(matches!(
1075            err.downcast_ref::<BuildError>(),
1076            Some(BuildError::InvalidGroup(ValidateNameError::TooLong))
1077        ));
1078    }
1079
1080    #[tokio::test]
1081    async fn test_attributes() {
1082        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1083
1084        // empty iterator is accepted (clears attributes server-side)
1085        let builder = Client::builder(&endpoint)
1086            .attributes(std::iter::empty::<(String, String)>())
1087            .unwrap();
1088        assert_eq!(builder.attributes.as_ref().map(|m| m.len()), Some(0));
1089
1090        // array literal of `&str` tuples, for the one-liner ergonomics
1091        let builder = Client::builder(&endpoint)
1092            .attributes([("env", "prod"), ("region", "us-west")])
1093            .unwrap();
1094        let attrs = builder.attributes.as_ref().expect("attributes set");
1095        assert_eq!(attrs.get("env").map(String::as_str), Some("prod"));
1096        assert_eq!(attrs.get("region").map(String::as_str), Some("us-west"));
1097
1098        // HashMap<String, String> also works
1099        let mut map: HashMap<String, String> = HashMap::new();
1100        map.insert("k1".into(), "v1".into());
1101        map.insert("k2".into(), "".into()); // empty value is allowed
1102        let builder = Client::builder(&endpoint).attributes(map).unwrap();
1103        let attrs = builder.attributes.as_ref().expect("attributes set");
1104        assert_eq!(attrs.get("k2").map(String::as_str), Some(""));
1105
1106        // value over 128 bytes errors
1107        let too_long_value = "x".repeat(129);
1108        let Err(err) = Client::builder(&endpoint).attributes([("ok", too_long_value.as_str())])
1109        else {
1110            panic!("attributes should fail for value over 128 bytes");
1111        };
1112        assert!(matches!(
1113            err.downcast_ref::<BuildError>(),
1114            Some(BuildError::InvalidAttributes(
1115                ValidateAttributesError::ValueTooLong
1116            ))
1117        ));
1118
1119        // key under 2 bytes errors
1120        let Err(err) = Client::builder(&endpoint).attributes([("a", "v")]) else {
1121            panic!("attributes should fail for key under 2 bytes");
1122        };
1123        assert!(matches!(
1124            err.downcast_ref::<BuildError>(),
1125            Some(BuildError::InvalidAttributes(
1126                ValidateAttributesError::InvalidKey(ValidateNameError::TooShort)
1127            ))
1128        ));
1129
1130        // more than 128 entries errors
1131        let big: Vec<(String, String)> = (0..(CLIENT_ATTRIBUTES_MAX_COUNT + 1))
1132            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1133            .collect();
1134        let Err(err) = Client::builder(&endpoint).attributes(big) else {
1135            panic!("attributes should fail for more than 128 entries");
1136        };
1137        assert!(matches!(
1138            err.downcast_ref::<BuildError>(),
1139            Some(BuildError::InvalidAttributes(
1140                ValidateAttributesError::TooManyEntries
1141            ))
1142        ));
1143    }
1144
1145    /// Build a client with no reachable server, mirroring `test_no_metrics_interval`.
1146    /// The runtime setters validate input locally before any network call, so
1147    /// validation errors surface without a live server.
1148    async fn build_serverless_client(seed: u64) -> Client {
1149        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(seed);
1150        let shared_secret = SecretKey::from_bytes(&rng.random());
1151        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1152        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1153
1154        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1155
1156        Client::builder(&endpoint)
1157            .disable_metrics_interval()
1158            .api_secret(api_secret)
1159            .unwrap()
1160            .build()
1161            .await
1162            .unwrap()
1163    }
1164
1165    /// Covers the runtime `Client::set_group` path the builder tests miss:
1166    /// validation runs locally and returns `Error::InvalidGroup` without a server.
1167    #[tokio::test]
1168    async fn test_set_group_runtime_validation() {
1169        let client = build_serverless_client(2).await;
1170
1171        let err = client
1172            .set_group("a")
1173            .await
1174            .expect_err("too-short group should fail validation");
1175        assert!(matches!(
1176            err,
1177            Error::InvalidGroup(ValidateNameError::TooShort)
1178        ));
1179
1180        let too_long = "x".repeat(CLIENT_NAME_MAX_LENGTH + 1);
1181        let err = client
1182            .set_group(too_long)
1183            .await
1184            .expect_err("too-long group should fail validation");
1185        assert!(matches!(
1186            err,
1187            Error::InvalidGroup(ValidateNameError::TooLong)
1188        ));
1189    }
1190
1191    /// Covers the runtime `Client::set_attributes` path the builder tests miss:
1192    /// validation runs locally and returns `Error::InvalidAttributes` without a server.
1193    #[tokio::test]
1194    async fn test_set_attributes_runtime_validation() {
1195        let client = build_serverless_client(3).await;
1196
1197        // key under 2 bytes
1198        let err = client
1199            .set_attributes([("a", "v")])
1200            .await
1201            .expect_err("too-short attribute key should fail validation");
1202        assert!(matches!(
1203            err,
1204            Error::InvalidAttributes(ValidateAttributesError::InvalidKey(
1205                ValidateNameError::TooShort
1206            ))
1207        ));
1208
1209        // value over the max length
1210        let too_long_value = "x".repeat(CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH + 1);
1211        let err = client
1212            .set_attributes([("ok", too_long_value.as_str())])
1213            .await
1214            .expect_err("too-long attribute value should fail validation");
1215        assert!(matches!(
1216            err,
1217            Error::InvalidAttributes(ValidateAttributesError::ValueTooLong)
1218        ));
1219
1220        // more entries than allowed
1221        let big: Vec<(String, String)> = (0..(CLIENT_ATTRIBUTES_MAX_COUNT + 1))
1222            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1223            .collect();
1224        let err = client
1225            .set_attributes(big)
1226            .await
1227            .expect_err("too many attributes should fail validation");
1228        assert!(matches!(
1229            err,
1230            Error::InvalidAttributes(ValidateAttributesError::TooManyEntries)
1231        ));
1232    }
1233
1234    #[tokio::test]
1235    async fn test_set_attribute_runtime_validation() {
1236        let client = build_serverless_client(7).await;
1237
1238        // A bad single key is rejected before any network call.
1239        let err = client
1240            .set_attribute("a", "v")
1241            .await
1242            .expect_err("too-short attribute key should fail validation");
1243        assert!(matches!(
1244            err,
1245            Error::InvalidAttributes(ValidateAttributesError::InvalidKey(
1246                ValidateNameError::TooShort
1247            ))
1248        ));
1249
1250        // A valid single attribute passes validation, then reaches the remote
1251        // layer (no server) and surfaces a remote error, proving set_attribute
1252        // is wired through the actor/RPC path.
1253        let err = client
1254            .set_attribute("firmware", "2.1.0")
1255            .await
1256            .expect_err("no server: remote call must fail after validation passes");
1257        assert!(matches!(err, Error::Remote(_)), "got {err:?}");
1258    }
1259
1260    #[tokio::test]
1261    async fn test_set_attribute_merge_over_limit_rejected() {
1262        // A client already holding the maximum number of attributes.
1263        let full: Vec<(String, String)> = (0..CLIENT_ATTRIBUTES_MAX_COUNT)
1264            .map(|i| (format!("key_{i:04}"), "v".to_string()))
1265            .collect();
1266
1267        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(9);
1268        let shared_secret = SecretKey::from_bytes(&rng.random());
1269        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1270        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1271        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1272        let client = Client::builder(&endpoint)
1273            .disable_metrics_interval()
1274            .attributes(full)
1275            .unwrap()
1276            .api_secret(api_secret)
1277            .unwrap()
1278            .build()
1279            .await
1280            .unwrap();
1281
1282        // Merging one more (individually valid) entry pushes the set over the
1283        // limit. The single-entry check would miss this; the merged-set check in
1284        // the actor catches it locally, before any network call.
1285        let err = client
1286            .set_attribute("one-too-many", "v")
1287            .await
1288            .expect_err("merging past the attribute limit must fail");
1289        assert!(
1290            matches!(
1291                err,
1292                Error::InvalidAttributes(ValidateAttributesError::TooManyEntries)
1293            ),
1294            "expected TooManyEntries, got {err:?}"
1295        );
1296    }
1297
1298    /// Boundary "accepted" case for the runtime setter. Without a live server we
1299    /// cannot assert success; instead we assert the input passes local validation
1300    /// and the call proceeds to the (failing) remote layer, surfacing
1301    /// `Error::Remote` rather than an `Error::InvalidAttributes` validation error.
1302    #[tokio::test]
1303    async fn test_set_attributes_runtime_boundary_accepted() {
1304        let client = build_serverless_client(4).await;
1305
1306        // value of exactly the max length is accepted by validation
1307        let max_value = "x".repeat(CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH);
1308        let err = client
1309            .set_attributes([("ok".to_string(), max_value)])
1310            .await
1311            .expect_err("no server: remote call must fail after validation passes");
1312        assert!(
1313            matches!(err, Error::Remote(_)),
1314            "expected a remote error (validation accepted), got {err:?}"
1315        );
1316
1317        // exactly CLIENT_ATTRIBUTES_MAX_COUNT entries is accepted by validation
1318        let max_entries: Vec<(String, String)> = (0..CLIENT_ATTRIBUTES_MAX_COUNT)
1319            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1320            .collect();
1321        let err = client
1322            .set_attributes(max_entries)
1323            .await
1324            .expect_err("no server: remote call must fail after validation passes");
1325        assert!(
1326            matches!(err, Error::Remote(_)),
1327            "expected a remote error (validation accepted), got {err:?}"
1328        );
1329    }
1330}