1
// org.freedesktop.Secret.Prompt
2

            
3
use std::{future::Future, pin::Pin, str::FromStr, sync::Arc};
4

            
5
use oo7::{Secret, dbus::ServiceError};
6
use tokio::sync::{Mutex, OnceCell};
7
use zbus::{
8
    interface,
9
    object_server::SignalEmitter,
10
    zvariant::{ObjectPath, Optional, OwnedObjectPath, OwnedValue},
11
};
12

            
13
use crate::{
14
    error::custom_service_error,
15
    gnome::prompter::{PrompterCallback, PrompterProxy},
16
    service::Service,
17
};
18

            
19
#[derive(Debug, Clone, Copy)]
20
pub enum PromptRole {
21
    Unlock,
22
    CreateCollection,
23
}
24

            
25
/// A boxed future that represents the action to be taken when a prompt
26
/// completes
27
pub type PromptActionFuture =
28
    Pin<Box<dyn Future<Output = Result<OwnedValue, ServiceError>> + Send + 'static>>;
29

            
30
/// Represents the action to be taken when a prompt completes
31
pub struct PromptAction {
32
    /// The async function to execute when the prompt is accepted
33
    action: Box<dyn FnOnce(Secret) -> PromptActionFuture + Send>,
34
}
35

            
36
impl PromptAction {
37
    /// Create a new prompt action from a closure that takes an optional secret
38
    /// and returns a future
39
10
    pub fn new<F, Fut>(f: F) -> Self
40
    where
41
        F: FnOnce(Secret) -> Fut + Send + 'static,
42
        Fut: Future<Output = Result<OwnedValue, ServiceError>> + Send + 'static,
43
    {
44
        Self {
45
30
            action: Box::new(move |secret| Box::pin(f(secret))),
46
        }
47
    }
48

            
49
    /// Execute the action with the provided secret
50
8
    pub async fn execute(self, secret: Secret) -> Result<OwnedValue, ServiceError> {
51
6
        (self.action)(secret).await
52
    }
53
}
54

            
55
#[derive(Clone)]
56
pub struct Prompt {
57
    service: Service,
58
    role: PromptRole,
59
    path: OwnedObjectPath,
60
    /// The label of the collection/keyring being prompted for
61
    label: String,
62
    /// The collection for Unlock prompts (needed for secret validation)
63
    collection: Option<crate::collection::Collection>,
64
    /// GNOME Specific
65
    callback: Arc<OnceCell<PrompterCallback>>,
66
    /// The action to execute when the prompt completes
67
    action: Arc<Mutex<Option<PromptAction>>>,
68
}
69

            
70
// Manual impl because OnceCell doesn't impl Debug
71
impl std::fmt::Debug for Prompt {
72
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73
        f.debug_struct("Prompt")
74
            .field("service", &self.service)
75
            .field("role", &self.role)
76
            .field("path", &self.path)
77
            .field("label", &self.label)
78
            .field("collection", &self.collection)
79
            .finish()
80
    }
81
}
82

            
83
#[interface(name = "org.freedesktop.Secret.Prompt")]
84
impl Prompt {
85
8
    pub async fn prompt(&self, window_id: Optional<&str>) -> Result<(), ServiceError> {
86
4
        if self.callback.get().is_some() {
87
4
            return Err(custom_service_error(
88
                "A prompt callback is ongoing already.",
89
            ));
90
        };
91

            
92
        let callback = PrompterCallback::new(
93
4
            (*window_id).and_then(|w| ashpd::WindowIdentifierType::from_str(w).ok()),
94
4
            self.service.clone(),
95
2
            self.path.clone(),
96
        )
97
6
        .await
98
2
        .map_err(|err| {
99
            custom_service_error(&format!("Failed to create PrompterCallback {err}."))
100
        })?;
101

            
102
4
        let path = OwnedObjectPath::from(callback.path().clone());
103

            
104
2
        self.callback
105
2
            .set(callback.clone())
106
            .expect("A prompt callback is only set once");
107

            
108
2
        self.service.object_server().at(&path, callback).await?;
109
4
        tracing::debug!("Prompt `{}` created.", self.path);
110

            
111
        // Starts GNOME System Prompting.
112
        // Spawned separately to avoid blocking the early return of the current
113
        // execution.
114
4
        let prompter = PrompterProxy::new(self.service.connection()).await?;
115
8
        tokio::spawn(async move { prompter.begin_prompting(&path).await });
116

            
117
2
        Ok(())
118
    }
119

            
120
8
    pub async fn dismiss(&self) -> Result<(), ServiceError> {
121
4
        if let Some(_callback) = self.callback.get() {
122
            // TODO: figure out if we should destroy the un-export the callback
123
            // here?
124
        }
125

            
126
8
        self.service
127
            .object_server()
128
2
            .remove::<Self, _>(&self.path)
129
6
            .await?;
130
2
        self.service.remove_prompt(&self.path).await;
131

            
132
2
        Ok(())
133
    }
134

            
135
    #[zbus(signal, name = "Completed")]
136
    pub async fn completed(
137
2
        signal_emitter: &SignalEmitter<'_>,
138
2
        dismissed: bool,
139
4
        result: OwnedValue,
140
    ) -> zbus::Result<()>;
141
}
142

            
143
impl Prompt {
144
2
    pub async fn new(
145
        service: Service,
146
        role: PromptRole,
147
        label: String,
148
        collection: Option<crate::collection::Collection>,
149
    ) -> Self {
150
4
        let index = service.prompt_index().await;
151
        Self {
152
2
            path: OwnedObjectPath::try_from(format!("/org/freedesktop/secrets/prompt/p{index}"))
153
                .unwrap(),
154
            service,
155
            role,
156
            label,
157
            collection,
158
4
            callback: Default::default(),
159
4
            action: Arc::new(Mutex::new(None)),
160
        }
161
    }
162

            
163
2
    pub fn path(&self) -> &ObjectPath<'_> {
164
2
        &self.path
165
    }
166

            
167
2
    pub fn role(&self) -> PromptRole {
168
2
        self.role
169
    }
170

            
171
2
    pub fn label(&self) -> &str {
172
2
        &self.label
173
    }
174

            
175
2
    pub fn collection(&self) -> Option<&crate::collection::Collection> {
176
2
        self.collection.as_ref()
177
    }
178

            
179
    /// Set the action to execute when the prompt completes
180
8
    pub async fn set_action(&self, action: PromptAction) {
181
2
        *self.action.lock().await = Some(action);
182
    }
183

            
184
    /// Take the action, consuming it so it can only be executed once
185
8
    pub async fn take_action(&self) -> Option<PromptAction> {
186
4
        self.action.lock().await.take()
187
    }
188
}
189

            
190
#[cfg(test)]
191
mod tests {
192
    use crate::tests::TestServiceSetup;
193

            
194
    #[tokio::test]
195
    async fn prompt_called_twice_error() -> Result<(), Box<dyn std::error::Error>> {
196
        let setup = TestServiceSetup::plain_session(true).await?;
197

            
198
        // Lock the collection to create a prompt scenario
199
        let collection = setup
200
            .server
201
            .collection_from_path(setup.collections[0].inner().path())
202
            .await
203
            .expect("Collection should exist");
204
        collection
205
            .set_locked(true, setup.keyring_secret.clone())
206
            .await?;
207

            
208
        // Get a prompt path by calling unlock (which creates a prompt but doesn't
209
        // auto-trigger it)
210
        let (_unlocked, prompt_path) = setup
211
            .server
212
            .unlock(vec![setup.collections[0].inner().path().to_owned().into()])
213
            .await?;
214

            
215
        // Verify we got a prompt path
216
        assert!(!prompt_path.is_empty(), "Should have a prompt path");
217

            
218
        // Create a Prompt proxy manually
219
        let prompt = oo7::dbus::api::Prompt::new(&setup.client_conn, prompt_path.as_ref())
220
            .await?
221
            .unwrap();
222

            
223
        // First call to prompt() should succeed
224
        prompt.prompt(None).await?;
225

            
226
        // Give the prompt a moment to register the callback
227
        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
228

            
229
        // Second call to prompt() should fail with "callback is ongoing already" error
230
        assert!(
231
            prompt.prompt(None).await.is_err(),
232
            "Second call to prompt() should fail"
233
        );
234
        Ok(())
235
    }
236

            
237
    #[tokio::test]
238
    async fn prompt_not_found_error() -> Result<(), Box<dyn std::error::Error>> {
239
        let setup = TestServiceSetup::plain_session(true).await?;
240

            
241
        // Lock the collection to create a prompt scenario
242
        let collection = setup
243
            .server
244
            .collection_from_path(setup.collections[0].inner().path())
245
            .await
246
            .expect("Collection should exist");
247
        collection
248
            .set_locked(true, setup.keyring_secret.clone())
249
            .await?;
250

            
251
        // Create a prompt using server API
252
        let (_unlocked, prompt_path) = setup
253
            .server
254
            .unlock(vec![setup.collections[0].inner().path().to_owned().into()])
255
            .await?;
256

            
257
        assert!(!prompt_path.is_empty(), "Should have a prompt path");
258

            
259
        // Remove the prompt from the service before MockPrompter tries to process it
260
        setup.server.remove_prompt(&prompt_path).await;
261

            
262
        // Manually serve a callback to trigger the error path
263
        let callback = crate::gnome::prompter::PrompterCallback::new(
264
            None,
265
            setup.server.clone(),
266
            prompt_path.clone(),
267
        )
268
        .await?;
269

            
270
        let callback_path = super::OwnedObjectPath::from(callback.path().clone());
271
        setup
272
            .server
273
            .object_server()
274
            .at(&callback_path, callback.clone())
275
            .await?;
276

            
277
        // Now call prompt_ready which should fail because the prompt doesn't exist
278
        let result = callback
279
            .prompt_ready(
280
                zbus::zvariant::Optional::from(None),
281
                crate::gnome::prompter::Properties::default(),
282
                "",
283
                setup.server.connection(),
284
            )
285
            .await;
286

            
287
        assert!(result.is_err(), "Should fail when prompt doesn't exist");
288

            
289
        // Verify it's the specific error we expect
290
        assert!(
291
            matches!(result, Err(oo7::dbus::ServiceError::NoSuchObject(_))),
292
            "Should be NoSuchObject error"
293
        );
294

            
295
        Ok(())
296
    }
297

            
298
    #[tokio::test]
299
    async fn dismiss_prompt_cleanup() -> Result<(), Box<dyn std::error::Error>> {
300
        let setup = TestServiceSetup::plain_session(true).await?;
301

            
302
        // Lock the collection to create a prompt scenario
303
        let collection = setup
304
            .server
305
            .collection_from_path(setup.collections[0].inner().path())
306
            .await
307
            .expect("Collection should exist");
308
        collection
309
            .set_locked(true, setup.keyring_secret.clone())
310
            .await?;
311

            
312
        // Get a prompt path by calling unlock
313
        let (_unlocked, prompt_path) = setup
314
            .server
315
            .unlock(vec![setup.collections[0].inner().path().to_owned().into()])
316
            .await?;
317

            
318
        assert!(!prompt_path.is_empty(), "Should have a prompt path");
319

            
320
        // Verify prompt exists in service before dismissal
321
        let prompt_exists_before = setup.server.prompt(&prompt_path).await;
322
        assert!(
323
            prompt_exists_before.is_some(),
324
            "Prompt should exist in service before dismissal"
325
        );
326

            
327
        // Verify prompt is accessible via D-Bus
328
        let prompt = oo7::dbus::api::Prompt::new(&setup.client_conn, prompt_path.as_ref()).await?;
329
        assert!(
330
            prompt.is_some(),
331
            "Prompt should be accessible via D-Bus before dismissal"
332
        );
333

            
334
        // Dismiss the prompt
335
        prompt.unwrap().dismiss().await?;
336

            
337
        // Give it a moment to process the dismissal
338
        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
339

            
340
        // Verify prompt is removed from service
341
        let prompt_exists_after = setup.server.prompt(&prompt_path).await;
342
        assert!(
343
            prompt_exists_after.is_none(),
344
            "Prompt should be removed from service after dismissal"
345
        );
346

            
347
        Ok(())
348
    }
349
}