1
// org.freedesktop.Secret.Service
2

            
3
use std::{
4
    collections::HashMap,
5
    sync::{Arc, OnceLock},
6
};
7

            
8
use oo7::{
9
    Key, Secret,
10
    dbus::{
11
        Algorithm, ServiceError,
12
        api::{DBusSecretInner, Properties},
13
    },
14
    file::{Keyring, LockedKeyring, UnlockedKeyring},
15
};
16
use tokio::sync::{Mutex, RwLock};
17
use tokio_stream::StreamExt;
18
use zbus::{
19
    names::UniqueName,
20
    object_server::SignalEmitter,
21
    proxy::Defaults,
22
    zvariant::{ObjectPath, Optional, OwnedObjectPath, OwnedValue, Value},
23
};
24

            
25
use crate::{
26
    collection::Collection,
27
    error::{Error, custom_service_error},
28
    prompt::{Prompt, PromptAction, PromptRole},
29
    session::Session,
30
};
31

            
32
const DEFAULT_COLLECTION_ALIAS_PATH: ObjectPath<'static> =
33
    ObjectPath::from_static_str_unchecked("/org/freedesktop/secrets/aliases/default");
34

            
35
#[derive(Debug, Default, Clone)]
36
pub struct Service {
37
    // Properties
38
    pub(crate) collections: Arc<Mutex<HashMap<OwnedObjectPath, Collection>>>,
39
    // Other attributes
40
    connection: Arc<OnceLock<zbus::Connection>>,
41
    // sessions mapped to their corresponding object path on the bus
42
    sessions: Arc<Mutex<HashMap<OwnedObjectPath, Session>>>,
43
    session_index: Arc<RwLock<u32>>,
44
    // prompts mapped to their corresponding object path on the bus
45
    prompts: Arc<Mutex<HashMap<OwnedObjectPath, Prompt>>>,
46
    prompt_index: Arc<RwLock<u32>>,
47
    // pending collection creations: prompt_path -> (label, alias)
48
    pending_collections: Arc<Mutex<HashMap<OwnedObjectPath, (String, String)>>>,
49
    // pending v0 keyring migrations: name -> (path, label, alias)
50
    #[allow(clippy::type_complexity)]
51
    pub(crate) pending_migrations:
52
        Arc<Mutex<HashMap<String, (std::path::PathBuf, String, String)>>>,
53
}
54

            
55
#[zbus::interface(name = "org.freedesktop.Secret.Service")]
56
impl Service {
57
    #[zbus(out_args("output", "result"))]
58
6
    pub async fn open_session(
59
        &self,
60
        algorithm: Algorithm,
61
        input: Value<'_>,
62
        #[zbus(header)] header: zbus::message::Header<'_>,
63
        #[zbus(object_server)] object_server: &zbus::ObjectServer,
64
    ) -> Result<(OwnedValue, OwnedObjectPath), ServiceError> {
65
12
        let (public_key, aes_key) = match algorithm {
66
6
            Algorithm::Plain => (None, None),
67
            Algorithm::Encrypted => {
68
4
                let client_public_key = Key::try_from(input).map_err(|err| {
69
                    custom_service_error(&format!(
70
                        "Input Value could not be converted into a Key {err}."
71
                    ))
72
                })?;
73
4
                let private_key = Key::generate_private_key().map_err(|err| {
74
                    custom_service_error(&format!("Failed to generate private key {err}."))
75
                })?;
76
                (
77
4
                    Some(Key::generate_public_key(&private_key).map_err(|err| {
78
                        custom_service_error(&format!("Failed to generate public key {err}."))
79
                    })?),
80
2
                    Some(
81
4
                        Key::generate_aes_key(&private_key, &client_public_key).map_err(|err| {
82
                            custom_service_error(&format!("Failed to generate aes key {err}."))
83
                        })?,
84
                    ),
85
                )
86
            }
87
        };
88

            
89
18
        let sender = if let Some(s) = header.sender() {
90
            s.to_owned()
91
        } else {
92
            #[cfg(test)]
93
            {
94
                // For p2p test connections, use a dummy sender since p2p connections
95
                // don't have a bus to assign unique names
96
                UniqueName::try_from(":p2p.test").unwrap().into()
97
            }
98
            #[cfg(not(test))]
99
            {
100
                return Err(custom_service_error("Failed to get sender from header."));
101
            }
102
        };
103

            
104
12
        tracing::info!("Client {} connected", sender);
105

            
106
10
        let session = Session::new(aes_key.map(Arc::new), self.clone(), sender).await;
107
7
        let path = OwnedObjectPath::from(session.path().clone());
108

            
109
12
        self.sessions
110
            .lock()
111
10
            .await
112
6
            .insert(path.clone(), session.clone());
113

            
114
2
        object_server.at(&path, session).await?;
115

            
116
4
        let service_key = public_key
117
2
            .map(OwnedValue::from)
118
10
            .unwrap_or_else(|| Value::new::<Vec<u8>>(vec![]).try_into_owned().unwrap());
119

            
120
2
        Ok((service_key, path))
121
    }
122

            
123
    #[zbus(out_args("collection", "prompt"))]
124
2
    pub async fn create_collection(
125
        &self,
126
        properties: Properties,
127
        alias: &str,
128
    ) -> Result<(OwnedObjectPath, ObjectPath<'_>), ServiceError> {
129
4
        let label = properties.label().to_owned();
130
4
        let alias = alias.to_owned();
131

            
132
        // Create a prompt to get the password for the new collection
133
        let prompt = Prompt::new(
134
4
            self.clone(),
135
            PromptRole::CreateCollection,
136
2
            label.clone(),
137
2
            None,
138
        )
139
6
        .await;
140
4
        let prompt_path = OwnedObjectPath::from(prompt.path().clone());
141

            
142
        // Store the collection metadata for later creation
143
8
        self.pending_collections
144
            .lock()
145
6
            .await
146
2
            .insert(prompt_path.clone(), (label, alias));
147

            
148
        // Create the collection creation action
149
4
        let service = self.clone();
150
2
        let creation_prompt_path = prompt_path.clone();
151
10
        let action = PromptAction::new(move |secret: Secret| async move {
152
8
            let collection_path = service
153
4
                .complete_collection_creation(&creation_prompt_path, secret)
154
8
                .await?;
155

            
156
4
            Ok(Value::new(collection_path).try_into_owned().unwrap())
157
        });
158

            
159
2
        prompt.set_action(action).await;
160

            
161
        // Register the prompt
162
10
        self.prompts
163
            .lock()
164
6
            .await
165
4
            .insert(prompt_path.clone(), prompt.clone());
166

            
167
2
        self.object_server().at(&prompt_path, prompt).await?;
168

            
169
2
        tracing::debug!("CreateCollection prompt created at `{}`", prompt_path);
170

            
171
        // Return empty collection path and the prompt path
172
4
        Ok((OwnedObjectPath::default(), prompt_path.into()))
173
    }
174

            
175
    #[zbus(out_args("unlocked", "locked"))]
176
2
    pub async fn search_items(
177
        &self,
178
        attributes: HashMap<String, String>,
179
    ) -> Result<(Vec<OwnedObjectPath>, Vec<OwnedObjectPath>), ServiceError> {
180
2
        let mut unlocked = Vec::new();
181
2
        let mut locked = Vec::new();
182
4
        let collections = self.collections.lock().await;
183

            
184
6
        for (_path, collection) in collections.iter() {
185
8
            let items = collection.search_inner_items(&attributes).await?;
186
4
            for item in items {
187
8
                if item.is_locked().await {
188
4
                    locked.push(item.path().clone().into());
189
                } else {
190
4
                    unlocked.push(item.path().clone().into());
191
                }
192
            }
193
        }
194

            
195
4
        if unlocked.is_empty() && locked.is_empty() {
196
4
            tracing::debug!(
197
                "Items with attributes {:?} does not exist in any collection.",
198
                attributes
199
            );
200
        } else {
201
6
            tracing::debug!("Items with attributes {:?} found.", attributes);
202
        }
203

            
204
2
        Ok((unlocked, locked))
205
    }
206

            
207
    #[zbus(out_args("unlocked", "prompt"))]
208
2
    pub async fn unlock(
209
        &self,
210
        objects: Vec<OwnedObjectPath>,
211
    ) -> Result<(Vec<OwnedObjectPath>, OwnedObjectPath), ServiceError> {
212
4
        let (unlocked, not_unlocked) = self.set_locked(false, &objects).await?;
213
4
        if !not_unlocked.is_empty() {
214
            // Extract the label and collection before creating the prompt
215
4
            let label = self.extract_label_from_objects(&not_unlocked).await;
216
4
            let collection = self.extract_collection_from_objects(&not_unlocked).await;
217

            
218
4
            let prompt = Prompt::new(self.clone(), PromptRole::Unlock, label, collection).await;
219
4
            let path = OwnedObjectPath::from(prompt.path().clone());
220

            
221
            // Create the unlock action
222
2
            let service = self.clone();
223
12
            let action = PromptAction::new(move |secret: Secret| async move {
224
                // The prompter will handle secret validation
225
                // Here we just perform the unlock operation
226
4
                let collections = service.collections.lock().await;
227
8
                for object in &not_unlocked {
228
                    // Try to find as collection first
229
4
                    if let Some(collection) = collections.get(object) {
230
6
                        let _ = collection.set_locked(false, Some(secret.clone())).await;
231
                    } else {
232
                        // Try to find as item within collections
233
6
                        for (_path, collection) in collections.iter() {
234
4
                            if let Some(item) = collection.item_from_path(object).await {
235
                                // If the collection is locked, unlock it
236
4
                                if collection.is_locked().await {
237
                                    let _ =
238
6
                                        collection.set_locked(false, Some(secret.clone())).await;
239
                                } else {
240
                                    // Collection is already unlocked, just unlock the item
241
                                    let keyring = collection.keyring.read().await;
242
                                    let _ = item
243
                                        .set_locked(false, keyring.as_ref().unwrap().as_unlocked())
244
                                        .await;
245
                                }
246
                                break;
247
                            }
248
                        }
249
                    }
250
                }
251
2
                Ok(Value::new(not_unlocked).try_into_owned().unwrap())
252
            });
253

            
254
2
            prompt.set_action(action).await;
255

            
256
10
            self.prompts
257
                .lock()
258
6
                .await
259
4
                .insert(path.clone(), prompt.clone());
260

            
261
2
            self.object_server().at(&path, prompt).await?;
262
2
            return Ok((unlocked, path));
263
        }
264

            
265
2
        Ok((unlocked, OwnedObjectPath::default()))
266
    }
267

            
268
    #[zbus(out_args("locked", "Prompt"))]
269
2
    pub async fn lock(
270
        &self,
271
        objects: Vec<OwnedObjectPath>,
272
    ) -> Result<(Vec<OwnedObjectPath>, OwnedObjectPath), ServiceError> {
273
        // set_locked now handles locking directly (without prompts)
274
4
        let (locked, not_locked) = self.set_locked(true, &objects).await?;
275
        // Locking never requires prompts, so not_locked should always be empty
276
        debug_assert!(
277
            not_locked.is_empty(),
278
            "Lock operation should never require prompts"
279
        );
280
2
        Ok((locked, OwnedObjectPath::default()))
281
    }
282

            
283
    #[zbus(out_args("secrets"))]
284
2
    pub async fn get_secrets(
285
        &self,
286
        items: Vec<OwnedObjectPath>,
287
        session: OwnedObjectPath,
288
    ) -> Result<HashMap<OwnedObjectPath, DBusSecretInner>, ServiceError> {
289
2
        let mut secrets = HashMap::new();
290
4
        let collections = self.collections.lock().await;
291

            
292
8
        'outer: for (_path, collection) in collections.iter() {
293
8
            for item in &items {
294
6
                if let Some(item) = collection.item_from_path(item).await {
295
6
                    match item.get_secret(session.clone()).await {
296
2
                        Ok((secret,)) => {
297
4
                            secrets.insert(item.path().clone().into(), secret);
298
                            // To avoid iterating through all the remaining collections, if the
299
                            // items secrets are already retrieved.
300
2
                            if secrets.len() == items.len() {
301
                                break 'outer;
302
                            }
303
                        }
304
                        // Avoid erroring out if an item is locked.
305
                        Err(ServiceError::IsLocked(_)) => {
306
                            continue;
307
                        }
308
2
                        Err(err) => {
309
2
                            return Err(err);
310
                        }
311
                    };
312
                }
313
            }
314
        }
315

            
316
2
        Ok(secrets)
317
    }
318

            
319
    #[zbus(out_args("collection"))]
320
8
    pub async fn read_alias(&self, name: &str) -> Result<OwnedObjectPath, ServiceError> {
321
        // Map "login" alias to "default" for compatibility with gnome-keyring
322
6
        let alias_to_find = if name == Self::LOGIN_ALIAS {
323
            oo7::dbus::Service::DEFAULT_COLLECTION
324
        } else {
325
2
            name
326
        };
327

            
328
2
        let collections = self.collections.lock().await;
329

            
330
8
        for (path, collection) in collections.iter() {
331
6
            if collection.alias().await == alias_to_find {
332
2
                tracing::debug!("Collection: {} found for alias: {}.", path, name);
333
4
                return Ok(path.to_owned());
334
            }
335
        }
336

            
337
2
        tracing::info!("Collection with alias {} does not exist.", name);
338

            
339
4
        Ok(OwnedObjectPath::default())
340
    }
341

            
342
2
    pub async fn set_alias(
343
        &self,
344
        name: &str,
345
        collection: OwnedObjectPath,
346
    ) -> Result<(), ServiceError> {
347
4
        let collections = self.collections.lock().await;
348

            
349
6
        for (path, other_collection) in collections.iter() {
350
4
            if *path == collection {
351
2
                other_collection.set_alias(name).await;
352

            
353
2
                tracing::info!("Collection: {} alias updated to {}.", collection, name);
354
2
                return Ok(());
355
            }
356
        }
357

            
358
2
        tracing::info!("Collection: {} does not exist.", collection);
359

            
360
4
        Err(ServiceError::NoSuchObject(format!(
361
            "The collection: {collection} does not exist.",
362
        )))
363
    }
364

            
365
    #[zbus(property, name = "Collections")]
366
15
    pub async fn collections(&self) -> Vec<OwnedObjectPath> {
367
9
        self.collections.lock().await.keys().cloned().collect()
368
    }
369

            
370
    #[zbus(signal, name = "CollectionCreated")]
371
    pub async fn collection_created(
372
2
        signal_emitter: &SignalEmitter<'_>,
373
2
        collection: &ObjectPath<'_>,
374
    ) -> zbus::Result<()>;
375

            
376
    #[zbus(signal, name = "CollectionDeleted")]
377
    pub async fn collection_deleted(
378
2
        signal_emitter: &SignalEmitter<'_>,
379
2
        collection: &ObjectPath<'_>,
380
    ) -> zbus::Result<()>;
381

            
382
    #[zbus(signal, name = "CollectionChanged")]
383
    pub async fn collection_changed(
384
2
        signal_emitter: &SignalEmitter<'_>,
385
2
        collection: &ObjectPath<'_>,
386
    ) -> zbus::Result<()>;
387
}
388

            
389
impl Service {
390
    const LOGIN_ALIAS: &str = "login";
391

            
392
    pub async fn run(secret: Option<Secret>, request_replacement: bool) -> Result<(), Error> {
393
        let service = Self::default();
394

            
395
        let connection = zbus::connection::Builder::session()?
396
            .allow_name_replacements(true)
397
            .replace_existing_names(request_replacement)
398
            .name(oo7::dbus::api::Service::DESTINATION.as_deref().unwrap())?
399
            .serve_at(
400
                oo7::dbus::api::Service::PATH.as_deref().unwrap(),
401
                service.clone(),
402
            )?
403
            .build()
404
            .await?;
405

            
406
        // Discover existing keyrings
407
        let discovered_keyrings = service.discover_keyrings(secret).await?;
408

            
409
        service
410
            .initialize(connection, discovered_keyrings, true)
411
            .await?;
412

            
413
        // Start PAM listener
414
        tracing::info!("Starting PAM listener");
415
        let pam_listener = crate::pam_listener::PamListener::new(service.clone());
416
        tokio::spawn(async move {
417
            if let Err(e) = pam_listener.start().await {
418
                tracing::error!("PAM listener error: {}", e);
419
            }
420
        });
421

            
422
        Ok(())
423
    }
424

            
425
    #[cfg(test)]
426
7
    pub async fn run_with_connection(
427
        connection: zbus::Connection,
428
        secret: Option<Secret>,
429
    ) -> Result<Self, Error> {
430
5
        let service = Self::default();
431

            
432
        // Serve the service at the standard path
433
23
        connection
434
            .object_server()
435
            .at(
436
5
                oo7::dbus::api::Service::PATH.as_deref().unwrap(),
437
6
                service.clone(),
438
            )
439
7
            .await?;
440

            
441
7
        let default_keyring = if let Some(secret) = secret {
442
13
            vec![(
443
4
                "Login".to_owned(),
444
4
                oo7::dbus::Service::DEFAULT_COLLECTION.to_owned(),
445
11
                Keyring::Unlocked(UnlockedKeyring::temporary(secret).await?),
446
            )]
447
        } else {
448
4
            vec![]
449
        };
450

            
451
16
        service
452
3
            .initialize(connection, default_keyring, false)
453
15
            .await?;
454
5
        Ok(service)
455
    }
456

            
457
    /// Discover existing keyrings in the data directory
458
    /// Returns a vector of (label, alias, keyring) tuples
459
2
    pub(crate) async fn discover_keyrings(
460
        &self,
461
        secret: Option<Secret>,
462
    ) -> Result<Vec<(String, String, Keyring)>, Error> {
463
2
        let mut discovered = Vec::new();
464

            
465
        // Get data directory using the same logic as oo7::file::api::data_dir()
466
4
        let data_dir = std::env::var_os("XDG_DATA_HOME")
467
6
            .and_then(|h| if h.is_empty() { None } else { Some(h) })
468
2
            .map(std::path::PathBuf::from)
469
6
            .and_then(|p| if p.is_absolute() { Some(p) } else { None })
470
2
            .or_else(|| {
471
                std::env::var_os("HOME")
472
                    .and_then(|h| if h.is_empty() { None } else { Some(h) })
473
                    .map(std::path::PathBuf::from)
474
                    .map(|p| p.join(".local/share"))
475
            });
476

            
477
2
        let Some(data_dir) = data_dir else {
478
            tracing::warn!("No data directory found, skipping keyring discovery");
479
            return Ok(discovered);
480
        };
481

            
482
4
        let keyrings_dir = data_dir.join("keyrings");
483

            
484
        // Scan for v1 keyrings first
485
4
        let v1_dir = keyrings_dir.join("v1");
486
4
        if v1_dir.exists() {
487
2
            tracing::debug!("Scanning for v1 keyrings in {}", v1_dir.display());
488
8
            if let Ok(mut entries) = tokio::fs::read_dir(&v1_dir).await {
489
10
                while let Ok(Some(entry)) = entries.next_entry().await {
490
2
                    let path = entry.path();
491

            
492
                    // Skip directories and non-.keyring files
493
4
                    if path.is_dir() || path.extension() != Some(std::ffi::OsStr::new("keyring")) {
494
                        continue;
495
                    }
496

            
497
6
                    if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
498
4
                        tracing::debug!("Found v1 keyring: {name}");
499

            
500
                        // Try to load the keyring
501
10
                        match self.load_keyring(&path, name, secret.as_ref()).await {
502
2
                            Ok((label, alias, keyring)) => discovered.push((label, alias, keyring)),
503
                            Err(e) => tracing::warn!("Failed to load keyring {:?}: {}", path, e),
504
                        }
505
                    }
506
                }
507
            }
508
        }
509

            
510
        // Scan for v0 keyrings
511
4
        if keyrings_dir.exists() {
512
2
            tracing::debug!("Scanning for v0 keyrings in {}", keyrings_dir.display());
513
8
            if let Ok(mut entries) = tokio::fs::read_dir(&keyrings_dir).await {
514
10
                while let Ok(Some(entry)) = entries.next_entry().await {
515
2
                    let path = entry.path();
516

            
517
                    // Skip directories and non-.keyring files
518
4
                    if path.is_dir() || path.extension() != Some(std::ffi::OsStr::new("keyring")) {
519
                        continue;
520
                    }
521

            
522
6
                    if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
523
4
                        tracing::debug!("Found v0 keyring: {name}");
524

            
525
                        // Try to load the keyring
526
10
                        match self.load_keyring(&path, name, secret.as_ref()).await {
527
2
                            Ok((label, alias, keyring)) => discovered.push((label, alias, keyring)),
528
2
                            Err(e) => tracing::warn!("Failed to load keyring {:?}: {}", path, e),
529
                        }
530
                    }
531
                }
532
            }
533
        }
534

            
535
4
        let pending_count = self.pending_migrations.lock().await.len();
536

            
537
4
        if discovered.is_empty() && pending_count == 0 {
538
2
            tracing::info!("No keyrings discovered in data directory");
539
        } else {
540
2
            tracing::info!(
541
                "Discovered {} keyring(s), {} pending v0 migration(s)",
542
                discovered.len(),
543
                pending_count
544
            );
545
        }
546

            
547
2
        Ok(discovered)
548
    }
549

            
550
    /// Load a single keyring from a file path
551
    /// Returns (label, alias, keyring)
552
2
    async fn load_keyring(
553
        &self,
554
        path: &std::path::Path,
555
        name: &str,
556
        secret: Option<&Secret>,
557
    ) -> Result<(String, String, Keyring), Error> {
558
4
        let alias = if name.eq_ignore_ascii_case(Self::LOGIN_ALIAS) {
559
4
            oo7::dbus::Service::DEFAULT_COLLECTION.to_owned()
560
        } else {
561
4
            name.to_owned().to_lowercase()
562
        };
563

            
564
        // Use name as label (capitalized for consistency with Login)
565
        let label = {
566
4
            let mut chars = name.chars();
567
2
            match chars.next() {
568
                None => String::new(),
569
2
                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
570
            }
571
        };
572

            
573
        // Try to load the keyring
574
8
        let keyring = match LockedKeyring::load(path).await {
575
2
            Ok(locked_keyring) => {
576
                // Successfully loaded as v1 keyring
577
4
                if let Some(secret) = secret {
578
4
                    match locked_keyring.unlock(secret.clone()).await {
579
2
                        Ok(unlocked) => {
580
4
                            tracing::info!("Unlocked keyring '{}' from {:?}", name, path);
581
2
                            Keyring::Unlocked(unlocked)
582
                        }
583
2
                        Err(e) => {
584
4
                            tracing::warn!(
585
                                "Failed to unlock keyring '{}' with provided secret: {}. Keeping it locked.",
586
                                name,
587
                                e
588
                            );
589
                            // Reload as locked since unlock consumed it
590
6
                            Keyring::Locked(LockedKeyring::load(path).await?)
591
                        }
592
                    }
593
                } else {
594
4
                    tracing::debug!("No secret provided, keeping keyring '{}' locked", name);
595
2
                    Keyring::Locked(locked_keyring)
596
                }
597
            }
598
4
            Err(oo7::file::Error::VersionMismatch(Some(version)))
599
4
                if version.first() == Some(&0) =>
600
            // v0 is the legacy version
601
            {
602
                // This is a v0 keyring that needs migration
603
2
                tracing::info!(
604
                    "Found legacy v0 keyring '{name}' at {}, registering for migration",
605
                    path.display()
606
                );
607

            
608
4
                if let Some(secret) = secret {
609
4
                    tracing::debug!("Attempting immediate migration of v0 keyring '{name}'",);
610
6
                    match UnlockedKeyring::open(name, secret.clone()).await {
611
2
                        Ok(unlocked) => {
612
4
                            tracing::info!("Successfully migrated v0 keyring '{name}' to v1",);
613

            
614
                            // Write the migrated keyring to disk
615
6
                            unlocked.write().await?;
616
2
                            tracing::info!("Wrote migrated keyring '{name}' to disk");
617

            
618
                            // Remove the v0 keyring file after successful migration
619
6
                            if let Err(e) = tokio::fs::remove_file(path).await {
620
                                tracing::warn!(
621
                                    "Failed to remove v0 keyring at {}: {e}",
622
                                    path.display()
623
                                );
624
                            } else {
625
4
                                tracing::info!("Removed v0 keyring file at {}", path.display());
626
                            }
627

            
628
2
                            Keyring::Unlocked(unlocked)
629
                        }
630
2
                        Err(e) => {
631
4
                            tracing::warn!(
632
                                "Failed to migrate v0 keyring '{name}': {e}. Will retry when secret is available.",
633
                            );
634
6
                            self.pending_migrations.lock().await.insert(
635
4
                                name.to_owned(),
636
4
                                (path.to_path_buf(), label.clone(), alias.clone()),
637
                            );
638
2
                            return Err(e.into());
639
                        }
640
                    }
641
                } else {
642
4
                    tracing::debug!(
643
                        "No secret available for v0 keyring '{}', registering for pending migration",
644
                        name
645
                    );
646
6
                    self.pending_migrations.lock().await.insert(
647
4
                        name.to_owned(),
648
4
                        (path.to_path_buf(), label.clone(), alias.clone()),
649
                    );
650
2
                    return Err(Error::IO(std::io::Error::other(
651
                        "v0 keyring requires migration, no secret available",
652
                    )));
653
                }
654
            }
655
            Err(e) => {
656
                return Err(e.into());
657
            }
658
        };
659

            
660
2
        Ok((label, alias, keyring))
661
    }
662

            
663
    /// Initialize the service with collections and start client disconnect
664
    /// handler
665
5
    pub(crate) async fn initialize(
666
        &self,
667
        connection: zbus::Connection,
668
        mut discovered_keyrings: Vec<(String, String, Keyring)>, // (name, alias, keyring)
669
        auto_create_default: bool,
670
    ) -> Result<(), Error> {
671
8
        self.connection.set(connection.clone()).unwrap();
672

            
673
3
        let object_server = connection.object_server();
674
8
        let mut collections = self.collections.lock().await;
675

            
676
        // Check if we have a default collection
677
15
        let has_default = discovered_keyrings.iter().any(|(_, alias, _)| {
678
4
            alias == oo7::dbus::Service::DEFAULT_COLLECTION || alias == Self::LOGIN_ALIAS
679
        });
680

            
681
4
        if !has_default && auto_create_default {
682
            tracing::info!("No default collection found, creating 'Login' keyring");
683

            
684
            let locked_keyring = LockedKeyring::open(Self::LOGIN_ALIAS)
685
                .await
686
                .inspect_err(|e| {
687
                    tracing::error!("Failed to create default Login keyring: {}", e);
688
                })?;
689

            
690
            discovered_keyrings.push((
691
                "Login".to_owned(),
692
                oo7::dbus::Service::DEFAULT_COLLECTION.to_owned(),
693
                Keyring::Locked(locked_keyring),
694
            ));
695

            
696
            tracing::info!("Created default 'Login' collection (locked)");
697
        }
698

            
699
        // Set up discovered collections
700
16
        for (label, alias, keyring) in discovered_keyrings {
701
13
            let collection = Collection::new(&label, &alias, self.clone(), keyring).await;
702
3
            collections.insert(collection.path().to_owned().into(), collection.clone());
703
10
            collection.dispatch_items().await?;
704
18
            object_server
705
3
                .at(collection.path(), collection.clone())
706
16
                .await?;
707

            
708
            // If this is the default collection, also register it at the alias path
709
3
            if alias == oo7::dbus::Service::DEFAULT_COLLECTION {
710
21
                object_server
711
7
                    .at(DEFAULT_COLLECTION_ALIAS_PATH, collection)
712
16
                    .await?;
713
            }
714
        }
715

            
716
        // Always create session collection (always temporary)
717
        let collection = Collection::new(
718
            "session",
719
4
            oo7::dbus::Service::SESSION_COLLECTION,
720
8
            self.clone(),
721
12
            Keyring::Unlocked(UnlockedKeyring::temporary(Secret::random().unwrap()).await?),
722
        )
723
12
        .await;
724
16
        object_server
725
8
            .at(collection.path(), collection.clone())
726
12
            .await?;
727
4
        collections.insert(collection.path().to_owned().into(), collection);
728

            
729
4
        drop(collections); // Release the lock
730

            
731
        // Spawn client disconnect handler
732
4
        let service = self.clone();
733
11
        tokio::spawn(async move { service.on_client_disconnect().await });
734

            
735
5
        Ok(())
736
    }
737

            
738
16
    async fn on_client_disconnect(&self) -> zbus::Result<()> {
739
19
        let rule = zbus::MatchRule::builder()
740
5
            .msg_type(zbus::message::Type::Signal)
741
            .sender("org.freedesktop.DBus")?
742
            .interface("org.freedesktop.DBus")?
743
            .member("NameOwnerChanged")?
744
            .arg(2, "")?
745
            .build();
746
8
        let mut stream = zbus::MessageStream::for_match_rule(rule, self.connection(), None).await?;
747
10
        while let Some(message) = stream.try_next().await? {
748
            let body = message.body();
749
            let Ok((_name, old_owner, new_owner)) =
750
                body.deserialize::<(String, Optional<UniqueName<'_>>, Optional<UniqueName<'_>>)>()
751
            else {
752
                continue;
753
            };
754
            debug_assert!(new_owner.is_none()); // We enforce that in the matching rule
755
            let old_owner = old_owner
756
                .as_ref()
757
                .expect("A disconnected client requires an old_owner");
758
            if let Some(session) = self.session_from_sender(old_owner).await {
759
                match session.close().await {
760
                    Ok(_) => tracing::info!(
761
                        "Client {} disconnected. Session: {} closed.",
762
                        old_owner,
763
                        session.path()
764
                    ),
765
                    Err(err) => tracing::error!("Failed to close session: {}", err),
766
                }
767
            }
768
        }
769
        Ok(())
770
    }
771

            
772
2
    pub async fn set_locked(
773
        &self,
774
        locked: bool,
775
        objects: &[OwnedObjectPath],
776
    ) -> Result<(Vec<OwnedObjectPath>, Vec<OwnedObjectPath>), ServiceError> {
777
2
        let mut without_prompt = Vec::new();
778
2
        let mut with_prompt = Vec::new();
779
4
        let collections = self.collections.lock().await;
780

            
781
8
        for object in objects {
782
6
            for (path, collection) in collections.iter() {
783
4
                let collection_locked = collection.is_locked().await;
784
2
                if *object == *path {
785
2
                    if collection_locked == locked {
786
2
                        tracing::debug!(
787
                            "Collection: {} is already {}.",
788
                            object,
789
                            if locked { "locked" } else { "unlocked" }
790
                        );
791
4
                        without_prompt.push(object.clone());
792
2
                    } else if locked {
793
                        // Locking never requires a prompt
794
6
                        collection.set_locked(true, None).await?;
795
2
                        without_prompt.push(object.clone());
796
                    } else {
797
                        // Unlocking may require a prompt
798
4
                        with_prompt.push(object.clone());
799
                    }
800
                    break;
801
6
                } else if let Some(item) = collection.item_from_path(object).await {
802
6
                    if locked == item.is_locked().await {
803
4
                        tracing::debug!(
804
                            "Item: {} is already {}.",
805
                            object,
806
                            if locked { "locked" } else { "unlocked" }
807
                        );
808
4
                        without_prompt.push(object.clone());
809
                    // If the collection is unlocked, we can lock/unlock the
810
                    // item directly
811
2
                    } else if !collection_locked {
812
6
                        let keyring = collection.keyring.read().await;
813
8
                        item.set_locked(locked, keyring.as_ref().unwrap().as_unlocked())
814
8
                            .await?;
815
2
                        without_prompt.push(object.clone());
816
                    } else {
817
                        // Collection is locked, unlocking the item requires unlocking the
818
                        // collection
819
4
                        with_prompt.push(object.clone());
820
                    }
821
                    break;
822
                }
823
4
                tracing::warn!("Object: {} does not exist.", object);
824
            }
825
        }
826

            
827
2
        Ok((without_prompt, with_prompt))
828
    }
829

            
830
4
    pub fn connection(&self) -> &zbus::Connection {
831
4
        self.connection.get().unwrap()
832
    }
833

            
834
4
    pub fn object_server(&self) -> &zbus::ObjectServer {
835
4
        self.connection().object_server()
836
    }
837

            
838
8
    pub async fn collection_from_path(&self, path: &ObjectPath<'_>) -> Option<Collection> {
839
4
        let collections = self.collections.lock().await;
840
4
        collections.get(path).cloned()
841
    }
842

            
843
12
    pub async fn session_index(&self) -> u32 {
844
6
        let n_sessions = *self.session_index.read().await + 1;
845
5
        *self.session_index.write().await = n_sessions;
846

            
847
4
        n_sessions
848
    }
849

            
850
    async fn session_from_sender(&self, sender: &UniqueName<'_>) -> Option<Session> {
851
        let sessions = self.sessions.lock().await;
852

            
853
        sessions.values().find(|s| s.sender() == sender).cloned()
854
    }
855

            
856
8
    pub async fn session(&self, path: &ObjectPath<'_>) -> Option<Session> {
857
4
        self.sessions.lock().await.get(path).cloned()
858
    }
859

            
860
8
    pub async fn remove_session(&self, path: &ObjectPath<'_>) {
861
4
        self.sessions.lock().await.remove(path);
862
    }
863

            
864
8
    pub async fn remove_collection(&self, path: &ObjectPath<'_>) {
865
4
        self.collections.lock().await.remove(path);
866

            
867
4
        if let Ok(signal_emitter) =
868
            self.signal_emitter(oo7::dbus::api::Service::PATH.as_deref().unwrap())
869
        {
870
4
            let _ = self.collections_changed(&signal_emitter).await;
871
        }
872
    }
873

            
874
8
    pub async fn prompt_index(&self) -> u32 {
875
4
        let n_prompts = *self.prompt_index.read().await + 1;
876
2
        *self.prompt_index.write().await = n_prompts;
877

            
878
2
        n_prompts
879
    }
880

            
881
8
    pub async fn prompt(&self, path: &ObjectPath<'_>) -> Option<Prompt> {
882
4
        self.prompts.lock().await.get(path).cloned()
883
    }
884

            
885
8
    pub async fn remove_prompt(&self, path: &ObjectPath<'_>) {
886
4
        self.prompts.lock().await.remove(path);
887
        // Also clean up pending collection if it exists
888
2
        self.pending_collections.lock().await.remove(path);
889
    }
890

            
891
8
    pub async fn register_prompt(&self, path: OwnedObjectPath, prompt: Prompt) {
892
4
        self.prompts.lock().await.insert(path, prompt);
893
    }
894

            
895
2
    pub async fn pending_collection(
896
        &self,
897
        prompt_path: &ObjectPath<'_>,
898
    ) -> Option<(String, String)> {
899
8
        self.pending_collections
900
            .lock()
901
6
            .await
902
2
            .get(prompt_path)
903
            .cloned()
904
    }
905

            
906
2
    pub async fn complete_collection_creation(
907
        &self,
908
        prompt_path: &ObjectPath<'_>,
909
        secret: Secret,
910
    ) -> Result<OwnedObjectPath, ServiceError> {
911
        // Retrieve the pending collection metadata
912
4
        let Some((label, alias)) = self.pending_collection(prompt_path).await else {
913
4
            return Err(ServiceError::NoSuchObject(format!(
914
                "No pending collection for prompt `{prompt_path}`"
915
            )));
916
        };
917

            
918
        // Create a persistent keyring with the provided secret
919
12
        let keyring = UnlockedKeyring::open(&label.to_lowercase(), secret)
920
6
            .await
921
4
            .map_err(|err| custom_service_error(&format!("Failed to create keyring: {err}")))?;
922

            
923
        // Write the keyring file to disk immediately
924
8
        keyring
925
            .write()
926
8
            .await
927
2
            .map_err(|err| custom_service_error(&format!("Failed to write keyring file: {err}")))?;
928

            
929
2
        let keyring = Keyring::Unlocked(keyring);
930

            
931
        // Create the collection
932
6
        let collection = Collection::new(&label, &alias, self.clone(), keyring).await;
933
4
        let collection_path: OwnedObjectPath = collection.path().to_owned().into();
934

            
935
        // Register with object server
936
8
        self.object_server()
937
2
            .at(collection.path(), collection.clone())
938
6
            .await?;
939

            
940
        // Add to collections
941
8
        self.collections
942
            .lock()
943
6
            .await
944
2
            .insert(collection_path.clone(), collection);
945

            
946
        // Clean up pending collection
947
2
        self.pending_collections.lock().await.remove(prompt_path);
948

            
949
        // Emit CollectionCreated signal
950
2
        let service_path = oo7::dbus::api::Service::PATH.as_ref().unwrap();
951
2
        let signal_emitter = self.signal_emitter(service_path)?;
952
4
        Service::collection_created(&signal_emitter, &collection_path).await?;
953

            
954
        // Emit PropertiesChanged for Collections property to invalidate client cache
955
2
        self.collections_changed(&signal_emitter).await?;
956

            
957
2
        tracing::info!(
958
            "Collection `{}` created with label '{}'",
959
            collection_path,
960
            label
961
        );
962

            
963
2
        Ok(collection_path)
964
    }
965

            
966
10
    pub fn signal_emitter<'a, P>(
967
        &self,
968
        path: P,
969
    ) -> Result<zbus::object_server::SignalEmitter<'a>, oo7::dbus::ServiceError>
970
    where
971
        P: TryInto<ObjectPath<'a>>,
972
        P::Error: Into<zbus::Error>,
973
    {
974
20
        let signal_emitter = zbus::object_server::SignalEmitter::new(self.connection(), path)?;
975

            
976
10
        Ok(signal_emitter)
977
    }
978

            
979
    /// Extract the collection label from a list of object paths
980
    /// The objects can be either collections or items
981
8
    async fn extract_label_from_objects(&self, objects: &[OwnedObjectPath]) -> String {
982
4
        if objects.is_empty() {
983
            return String::new();
984
        }
985

            
986
        // Check if at least one of the objects is a Collection
987
8
        for object in objects {
988
6
            if let Some(collection) = self.collection_from_path(object).await {
989
4
                return collection.label().await;
990
            }
991
        }
992

            
993
        // Get the collection path from the first item
994
        // assumes all items are from the same collection
995
6
        if let Some(path_str) = objects.first().and_then(|p| p.as_str().rsplit_once('/')) {
996
2
            let collection_path = path_str.0;
997
6
            if let Ok(obj_path) = ObjectPath::try_from(collection_path) {
998
4
                if let Some(collection) = self.collection_from_path(&obj_path).await {
999
4
                    return collection.label().await;
                }
            }
        }
        String::new()
    }
    /// Extract the collection from a list of object paths
    /// The objects can be either collections or items
2
    async fn extract_collection_from_objects(
        &self,
        objects: &[OwnedObjectPath],
    ) -> Option<Collection> {
4
        if objects.is_empty() {
            return None;
        }
        // Check if at least one of the objects is a Collection
8
        for object in objects {
6
            if let Some(collection) = self.collection_from_path(object).await {
2
                return Some(collection);
            }
        }
        // Get the collection path from the first item
        // (assumes all items are from the same collection)
6
        let path = objects
            .first()
            .unwrap()
            .as_str()
            .rsplit_once('/')
4
            .map(|(parent, _)| parent)?;
6
        self.collection_from_path(&ObjectPath::try_from(path).unwrap())
4
            .await
    }
    /// Attempt to migrate pending v0 keyrings with the provided secret
    /// Returns a list of successfully migrated keyring names
8
    pub async fn migrate_pending_keyrings(&self, secret: &Secret) -> Vec<String> {
2
        let mut migrated = Vec::new();
4
        let mut pending = self.pending_migrations.lock().await;
2
        let mut to_remove = Vec::new();
8
        for (name, (path, label, alias)) in pending.iter() {
4
            tracing::debug!("Attempting to migrate pending v0 keyring: {}", name);
8
            match UnlockedKeyring::open(name, secret.clone()).await {
2
                Ok(unlocked) => {
4
                    tracing::info!("Successfully migrated v0 keyring '{}' to v1", name);
                    // Write the migrated keyring to disk
8
                    match unlocked.write().await {
                        Ok(_) => {
2
                            tracing::info!("Wrote migrated keyring '{}' to disk", name);
                            // Remove the v0 keyring file after successful migration
8
                            if let Err(e) = tokio::fs::remove_file(path).await {
                                tracing::warn!("Failed to remove v0 keyring at {:?}: {}", path, e);
                            } else {
4
                                tracing::info!("Removed v0 keyring file at {:?}", path);
                            }
                        }
                        Err(e) => {
                            tracing::error!(
                                "Failed to write migrated keyring '{}' to disk: {}",
                                name,
                                e
                            );
                            continue;
                        }
                    }
                    // Create a collection for this migrated keyring
2
                    let keyring = Keyring::Unlocked(unlocked);
8
                    let collection = Collection::new(label, alias, self.clone(), keyring).await;
2
                    let collection_path: OwnedObjectPath = collection.path().to_owned().into();
                    // Dispatch items
6
                    if let Err(e) = collection.dispatch_items().await {
                        tracing::error!(
                            "Failed to dispatch items for migrated keyring '{}': {}",
                            name,
                            e
                        );
                        continue;
                    }
8
                    if let Err(e) = self
                        .object_server()
2
                        .at(collection.path(), collection.clone())
8
                        .await
                    {
                        tracing::error!(
                            "Failed to register migrated collection '{}' with object server: {}",
                            name,
                            e
                        );
                        continue;
                    }
10
                    self.collections
                        .lock()
8
                        .await
4
                        .insert(collection_path.clone(), collection.clone());
2
                    if alias == oo7::dbus::Service::DEFAULT_COLLECTION {
                        if let Err(e) = self
                            .object_server()
                            .at(DEFAULT_COLLECTION_ALIAS_PATH, collection)
                            .await
                        {
                            tracing::error!(
                                "Failed to register default alias for migrated collection '{}': {}",
                                name,
                                e
                            );
                        }
                    }
4
                    if let Ok(signal_emitter) =
                        self.signal_emitter(oo7::dbus::api::Service::PATH.as_ref().unwrap())
                    {
4
                        let _ =
                            Service::collection_created(&signal_emitter, &collection_path).await;
6
                        let _ = self.collections_changed(&signal_emitter).await;
                    }
4
                    tracing::info!("Migrated keyring '{}' added as collection", name);
4
                    migrated.push(name.clone());
2
                    to_remove.push(name.clone());
                }
                Err(e) => {
                    tracing::debug!(
                        "Failed to migrate v0 keyring '{}' with provided secret: {}",
                        name,
                        e
                    );
                }
            }
        }
2
        for name in &to_remove {
4
            pending.remove(name);
        }
2
        migrated
    }
}
#[cfg(test)]
mod tests {
    use oo7::dbus;
    use super::*;
    use crate::tests::TestServiceSetup;
    #[tokio::test]
    async fn open_session_plain() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        assert!(
            setup.aes_key.is_none(),
            "Plain session should not have AES key"
        );
        // Should have 2 collections: default + session
        assert_eq!(
            setup.collections.len(),
            2,
            "Expected default and session collections"
        );
        Ok(())
    }
    #[tokio::test]
    async fn open_session_encrypted() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::encrypted_session(false).await?;
        assert!(
            setup.server_public_key.is_some(),
            "Encrypted session should have server public key"
        );
        let key = setup.aes_key.unwrap().clone();
        assert_eq!((*key).as_ref().len(), 16, "AES key should be 16 bytes");
        Ok(())
    }
    #[tokio::test]
    async fn session_collection_only() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(false).await?;
        // Should have only session collection (no default)
        assert_eq!(
            setup.collections.len(),
            1,
            "Should have exactly one collection"
        );
        Ok(())
    }
    #[tokio::test]
    async fn search_items() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Search for items (should return empty initially)
        let (unlocked, locked) = setup
            .service_api
            .search_items(&[("application", "test-app")])
            .await?;
        assert!(
            unlocked.is_empty(),
            "Should have no unlocked items initially"
        );
        assert!(locked.is_empty(), "Should have no locked items initially");
        // Search with empty attributes - edge case
        let attributes: HashMap<&str, &str> = HashMap::default();
        let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
        assert!(
            locked.is_empty(),
            "Should have no locked items with empty search"
        );
        assert!(
            unlocked.is_empty(),
            "Should have no unlocked items with empty search"
        );
        // Test with both locked and unlocked items
        // Create items in default collection (unlocked)
        let secret1 = Secret::text("password1");
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1);
        setup.collections[0]
            .create_item(
                "Unlocked Item",
                &[("app", "testapp")],
                &dbus_secret1,
                false,
                None,
            )
            .await?;
        // Create item in default collection and lock it
        let secret2 = Secret::text("password2");
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2);
        let locked_item = setup.collections[0]
            .create_item(
                "Locked Item",
                &[("app", "testapp")],
                &dbus_secret2,
                false,
                None,
            )
            .await?;
        // Lock just this item (not the whole collection)
        let collection = setup
            .server
            .collection_from_path(setup.collections[0].inner().path())
            .await
            .expect("Collection should exist");
        let keyring = collection.keyring.read().await;
        let unlocked_keyring = keyring.as_ref().unwrap().as_unlocked();
        let locked_item = collection
            .item_from_path(locked_item.inner().path())
            .await
            .unwrap();
        locked_item.set_locked(true, unlocked_keyring).await?;
        // Search for items with the shared attribute
        let (unlocked, locked) = setup
            .service_api
            .search_items(&[("app", "testapp")])
            .await?;
        assert_eq!(unlocked.len(), 1, "Should find 1 unlocked item");
        assert_eq!(locked.len(), 1, "Should find 1 locked item");
        Ok(())
    }
    #[tokio::test]
    async fn get_secrets() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Test with empty items list - edge case
        let secrets = setup.service_api.secrets(&vec![], &setup.session).await?;
        assert!(
            secrets.is_empty(),
            "Should return empty secrets for empty items list"
        );
        // Create two items with different secrets
        let secret1 = Secret::text("password1");
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1.clone());
        let item1 = setup.collections[0]
            .create_item("Item 1", &[("app", "test1")], &dbus_secret1, false, None)
            .await?;
        let secret2 = Secret::text("password2");
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2.clone());
        let item2 = setup.collections[0]
            .create_item("Item 2", &[("app", "test2")], &dbus_secret2, false, None)
            .await?;
        // Get secrets for both items
        let item_paths = vec![item1.clone(), item2.clone()];
        let secrets = setup
            .service_api
            .secrets(&item_paths, &setup.session)
            .await?;
        // Should have both secrets
        assert_eq!(secrets.len(), 2, "Should retrieve both secrets");
        // Verify first secret
        let retrieved_secret1 = secrets.get(&item1).unwrap();
        assert_eq!(retrieved_secret1.value(), secret1.as_bytes());
        // Verify second secret
        let retrieved_secret2 = secrets.get(&item2).unwrap();
        assert_eq!(retrieved_secret2.value(), secret2.as_bytes());
        Ok(())
    }
    #[tokio::test]
    async fn get_secrets_multiple_collections() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Should have 2 collections: default (Login) and session
        assert_eq!(setup.collections.len(), 2);
        // Create item in default collection (index 0)
        let secret1 = Secret::text("default-password");
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1.clone());
        let item1 = setup.collections[0]
            .create_item(
                "Default Item",
                &[("app", "default-app")],
                &dbus_secret1,
                false,
                None,
            )
            .await?;
        // Create item in session collection (index 1)
        let secret2 = Secret::text("session-password");
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2.clone());
        let item2 = setup.collections[1]
            .create_item(
                "Session Item",
                &[("app", "session-app")],
                &dbus_secret2,
                false,
                None,
            )
            .await?;
        // Get secrets for both items from different collections
        let item_paths = vec![item1.clone(), item2.clone()];
        let secrets = setup
            .service_api
            .secrets(&item_paths, &setup.session)
            .await?;
        // Should have both secrets
        assert_eq!(
            secrets.len(),
            2,
            "Should retrieve secrets from both collections"
        );
        // Verify default collection secret
        let retrieved_secret1 = secrets.get(&item1).unwrap();
        assert_eq!(retrieved_secret1.value(), secret1.as_bytes());
        // Verify session collection secret
        let retrieved_secret2 = secrets.get(&item2).unwrap();
        assert_eq!(retrieved_secret2.value(), secret2.as_bytes());
        Ok(())
    }
    #[tokio::test]
    async fn read_alias() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Default collection should have "default" alias
        let default_collection = setup.service_api.read_alias("default").await?;
        assert!(
            default_collection.is_some(),
            "Default alias should return a collection"
        );
        // Verify it's the Login collection by checking its label
        let label = default_collection.as_ref().unwrap().label().await?;
        assert_eq!(
            label, "Login",
            "Default alias should point to Login collection"
        );
        // Non-existent alias should return None
        let nonexistent = setup.service_api.read_alias("nonexistent").await?;
        assert!(
            nonexistent.is_none(),
            "Non-existent alias should return None"
        );
        Ok(())
    }
    #[tokio::test]
    async fn set_alias() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Set alias for session collection
        setup
            .service_api
            .set_alias("my-alias", &setup.collections[1])
            .await?;
        // Read the alias back
        let alias_collection = setup.service_api.read_alias("my-alias").await?;
        assert!(
            alias_collection.is_some(),
            "Alias should return a collection"
        );
        assert_eq!(
            alias_collection.unwrap().inner().path(),
            setup.collections[1].inner().path(),
            "Alias should point to session collection"
        );
        Ok(())
    }
    #[tokio::test]
    async fn search_items_with_results() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create items in default collection
        let secret1 = Secret::text("password1");
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1);
        setup.collections[0]
            .create_item(
                "Firefox Login",
                &[("application", "firefox"), ("type", "login")],
                &dbus_secret1,
                false,
                None,
            )
            .await?;
        let secret2 = Secret::text("password2");
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2);
        setup.collections[0]
            .create_item(
                "Chrome Login",
                &[("application", "chrome"), ("type", "login")],
                &dbus_secret2,
                false,
                None,
            )
            .await?;
        // Create item in session collection
        let secret3 = Secret::text("password3");
        let dbus_secret3 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret3);
        setup.collections[1]
            .create_item(
                "Session Item",
                &[("application", "firefox"), ("type", "session")],
                &dbus_secret3,
                false,
                None,
            )
            .await?;
        // Search for all firefox items
        let (unlocked, locked) = setup
            .service_api
            .search_items(&[("application", "firefox")])
            .await?;
        assert_eq!(unlocked.len(), 2, "Should find 2 firefox items");
        assert!(locked.is_empty(), "Should have no locked items");
        // Search for login type items
        let (unlocked, locked) = setup.service_api.search_items(&[("type", "login")]).await?;
        assert_eq!(unlocked.len(), 2, "Should find 2 login items");
        assert!(locked.is_empty(), "Should have no locked items");
        // Search for chrome items
        let (unlocked, locked) = setup
            .service_api
            .search_items(&[("application", "chrome")])
            .await?;
        assert_eq!(unlocked.len(), 1, "Should find 1 chrome item");
        assert!(locked.is_empty(), "Should have no locked items");
        // Search for non-existent
        let (unlocked, locked) = setup
            .service_api
            .search_items(&[("application", "nonexistent")])
            .await?;
        assert!(unlocked.is_empty(), "Should find no items");
        assert!(locked.is_empty(), "Should have no locked items");
        Ok(())
    }
    #[tokio::test]
    async fn get_secrets_invalid_session() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create an item
        let secret = Secret::text("test-password");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret);
        let item = setup.collections[0]
            .create_item("Test Item", &[("app", "test")], &dbus_secret, false, None)
            .await?;
        // Try to get secrets with invalid session path
        let invalid_session =
            dbus::api::Session::new(&setup.client_conn, "/invalid/session/path").await?;
        let result = setup.service_api.secrets(&[item], &invalid_session).await;
        assert!(
            matches!(
                result,
                Err(oo7::dbus::Error::Service(
                    oo7::dbus::ServiceError::NoSession(_)
                ))
            ),
            "Should be NoSession error"
        );
        Ok(())
    }
    #[tokio::test]
    async fn set_alias_invalid_collection() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Try to set alias for non-existent collection
        let invalid_collection = dbus::api::Collection::new(
            &setup.client_conn,
            "/org/freedesktop/secrets/collection/nonexistent",
        )
        .await?;
        let result = setup
            .service_api
            .set_alias("test-alias", &invalid_collection)
            .await;
        assert!(
            matches!(
                result,
                Err(oo7::dbus::Error::Service(
                    oo7::dbus::ServiceError::NoSuchObject(_)
                ))
            ),
            "Should be NoSuchObject error"
        );
        Ok(())
    }
    #[tokio::test]
    async fn get_secrets_with_non_existent_items() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create one real item
        let secret = Secret::text("password1");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret.clone());
        let item1 = setup.collections[0]
            .create_item("Item 1", &[("app", "test")], &dbus_secret, false, None)
            .await?;
        // Create a fake item path that doesn't exist
        let fake_item = dbus::api::Item::new(
            &setup.client_conn,
            "/org/freedesktop/secrets/collection/Login/999",
        )
        .await?;
        // Request secrets for both real and fake items
        let item_paths = vec![item1.clone(), fake_item];
        let secrets = setup
            .service_api
            .secrets(&item_paths, &setup.session)
            .await?;
        // Should only get the secret for the real item
        assert_eq!(
            secrets.len(),
            1,
            "Should only retrieve secret for existing item"
        );
        assert!(secrets.contains_key(&item1), "Should have item1 secret");
        Ok(())
    }
    #[tokio::test]
    async fn search_items_across_collections() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        let collections = setup.service_api.collections().await?;
        assert_eq!(collections.len(), 2, "Should have 2 collections");
        // Create item in first collection
        let secret1 = Secret::text("password1");
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1);
        collections[0]
            .create_item(
                "Default Item",
                &[("shared", "attr")],
                &dbus_secret1,
                false,
                None,
            )
            .await?;
        // Create item in second collection with same attributes
        let secret2 = Secret::text("password2");
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2);
        collections[1]
            .create_item(
                "Session Item",
                &[("shared", "attr")],
                &dbus_secret2,
                false,
                None,
            )
            .await?;
        // Search should find items from both collections
        let (unlocked, locked) = setup
            .service_api
            .search_items(&[("shared", "attr")])
            .await?;
        assert_eq!(unlocked.len(), 2, "Should find items from both collections");
        assert!(locked.is_empty(), "Should have no locked items");
        Ok(())
    }
    #[tokio::test]
    async fn unlock_edge_cases() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Test 1: Empty object list
        let items: Vec<ObjectPath<'_>> = vec![];
        let unlocked = setup.service_api.unlock(&items, None).await?;
        assert!(unlocked.is_empty(), "Should return empty for empty input");
        // Test 2: Non-existent objects
        let fake_collection = dbus::api::Collection::new(
            &setup.client_conn,
            "/org/freedesktop/secrets/collection/NonExistent",
        )
        .await?;
        let fake_item = dbus::api::Item::new(
            &setup.client_conn,
            "/org/freedesktop/secrets/collection/Login/999",
        )
        .await?;
        let unlocked = setup
            .service_api
            .unlock(
                &[fake_collection.inner().path(), fake_item.inner().path()],
                None,
            )
            .await?;
        assert!(
            unlocked.is_empty(),
            "Should have no unlocked objects for non-existent paths"
        );
        // Test 3: Already unlocked objects
        let secret = Secret::text("test-password");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret);
        let item = setup.collections[0]
            .create_item("Test Item", &[("app", "test")], &dbus_secret, false, None)
            .await?;
        // Verify item is unlocked
        assert!(!item.is_locked().await?, "Item should be unlocked");
        // Try to unlock already unlocked item
        let unlocked = setup
            .service_api
            .unlock(&[item.inner().path()], None)
            .await?;
        assert_eq!(unlocked.len(), 1, "Should return the already-unlocked item");
        assert_eq!(
            unlocked[0].as_str(),
            item.inner().path().as_str(),
            "Should return the same item path"
        );
        // Also test with collection (starts unlocked by default)
        assert!(
            !setup.collections[0].is_locked().await?,
            "Collection should be unlocked"
        );
        let unlocked = setup
            .service_api
            .unlock(&[setup.collections[0].inner().path()], None)
            .await?;
        assert_eq!(
            unlocked.len(),
            1,
            "Should return the already-unlocked collection"
        );
        Ok(())
    }
    #[tokio::test]
    async fn lock_non_existent_objects() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::encrypted_session(true).await?;
        // Test with empty object list
        let items: Vec<ObjectPath<'_>> = vec![];
        let locked = setup.service_api.lock(&items, None).await?;
        assert!(locked.is_empty(), "Should return empty for empty input");
        // Test locking non-existent objects
        let fake_collection = dbus::api::Collection::new(
            &setup.client_conn,
            "/org/freedesktop/secrets/collection/NonExistent",
        )
        .await?;
        let fake_item = dbus::api::Item::new(
            &setup.client_conn,
            "/org/freedesktop/secrets/collection/Login/999",
        )
        .await?;
        let locked = setup
            .service_api
            .lock(
                &[fake_collection.inner().path(), fake_item.inner().path()],
                None,
            )
            .await?;
        assert!(
            locked.is_empty(),
            "Should have no locked objects for non-existent paths"
        );
        Ok(())
    }
    #[tokio::test]
    async fn unlock_collection_prompt() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Lock the collection using server-side API
        let collection = setup
            .server
            .collection_from_path(setup.collections[0].inner().path())
            .await
            .expect("Collection should exist");
        collection
            .set_locked(true, setup.keyring_secret.clone())
            .await?;
        assert!(
            setup.collections[0].is_locked().await?,
            "Collection should be locked"
        );
        // Test 1: Unlock with accept
        let unlocked = setup
            .service_api
            .unlock(&[setup.collections[0].inner().path()], None)
            .await?;
        assert_eq!(unlocked.len(), 1, "Should have unlocked 1 collection");
        assert_eq!(
            unlocked[0].as_str(),
            setup.collections[0].inner().path().as_str(),
            "Should return the collection path"
        );
        assert!(
            !setup.collections[0].is_locked().await?,
            "Collection should be unlocked after accepting prompt"
        );
        // Lock the collection again for dismiss test
        collection
            .set_locked(true, setup.keyring_secret.clone())
            .await?;
        assert!(
            setup.collections[0].is_locked().await?,
            "Collection should be locked again"
        );
        // Test 2: Unlock with dismiss
        setup.mock_prompter.set_accept(false).await;
        let result = setup
            .service_api
            .unlock(&[setup.collections[0].inner().path()], None)
            .await;
        assert!(
            matches!(result, Err(oo7::dbus::Error::Dismissed)),
            "Should return Dismissed error when prompt dismissed"
        );
        assert!(
            setup.collections[0].is_locked().await?,
            "Collection should still be locked after dismissing prompt"
        );
        Ok(())
    }
    #[tokio::test]
    async fn unlock_item_prompt() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create an item
        let secret = Secret::text("test-password");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret);
        let default_collection = setup.service_api.read_alias("default").await?.unwrap();
        let item = default_collection
            .create_item("Test Item", &[("app", "test")], &dbus_secret, false, None)
            .await?;
        // Lock the collection (which locks the item)
        let collection = setup
            .server
            .collection_from_path(default_collection.inner().path())
            .await
            .expect("Collection should exist");
        collection
            .set_locked(true, setup.keyring_secret.clone())
            .await?;
        assert!(
            item.is_locked().await?,
            "Item should be locked when collection is locked"
        );
        // Test 1: Unlock with accept
        let unlocked = setup
            .service_api
            .unlock(&[item.inner().path()], None)
            .await?;
        assert_eq!(unlocked.len(), 1, "Should have unlocked 1 item");
        assert_eq!(
            unlocked[0].as_str(),
            item.inner().path().as_str(),
            "Should return the item path"
        );
        assert!(
            !item.is_locked().await?,
            "Item should be unlocked after accepting prompt"
        );
        // Lock the item again for dismiss test
        collection
            .set_locked(true, setup.keyring_secret.clone())
            .await?;
        assert!(item.is_locked().await?, "Item should be locked again");
        // Test 2: Unlock with dismiss
        setup.mock_prompter.set_accept(false).await;
        let result = setup.service_api.unlock(&[item.inner().path()], None).await;
        assert!(
            matches!(result, Err(oo7::dbus::Error::Dismissed)),
            "Should return Dismissed error when prompt dismissed"
        );
        assert!(
            item.is_locked().await?,
            "Item should still be locked after dismissing prompt"
        );
        Ok(())
    }
    #[tokio::test]
    async fn lock_item_in_unlocked_collection() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create an item (starts unlocked)
        let secret = Secret::text("test-password");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret);
        let item = setup.collections[0]
            .create_item("Test Item", &[("app", "test")], &dbus_secret, false, None)
            .await?;
        assert!(!item.is_locked().await?, "Item should start unlocked");
        assert!(
            !setup.collections[0].is_locked().await?,
            "Collection should be unlocked"
        );
        // When collection is unlocked, locking an item should happen directly without a
        // prompt
        let locked = setup.service_api.lock(&[item.inner().path()], None).await?;
        assert_eq!(locked.len(), 1, "Should have locked 1 item");
        assert_eq!(
            locked[0].as_str(),
            item.inner().path().as_str(),
            "Should return the item path"
        );
        assert!(item.is_locked().await?, "Item should be locked directly");
        // Unlock the item again (using service API to unlock just the item)
        let unlocked = setup
            .service_api
            .unlock(&[item.inner().path()], None)
            .await?;
        assert_eq!(unlocked.len(), 1, "Should have unlocked 1 item");
        assert!(!item.is_locked().await?, "Item should be unlocked again");
        // Locking again should work the same way (no prompt)
        let locked = setup.service_api.lock(&[item.inner().path()], None).await?;
        assert_eq!(locked.len(), 1, "Should have locked 1 item again");
        assert!(item.is_locked().await?, "Item should be locked again");
        Ok(())
    }
    #[tokio::test]
    async fn lock_collection_no_prompt() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Collection starts unlocked
        assert!(
            !setup.collections[0].is_locked().await?,
            "Collection should start unlocked"
        );
        // Lock the collection
        let locked = setup
            .service_api
            .lock(&[setup.collections[0].inner().path()], None)
            .await?;
        assert_eq!(locked.len(), 1, "Should have locked 1 collection");
        assert_eq!(
            locked[0].as_str(),
            setup.collections[0].inner().path().as_str(),
            "Should return the collection path"
        );
        assert!(
            setup.collections[0].is_locked().await?,
            "Collection should be locked instantly"
        );
        // Unlock the collection
        let collection = setup
            .server
            .collection_from_path(setup.collections[0].inner().path())
            .await
            .expect("Collection should exist");
        collection
            .set_locked(false, setup.keyring_secret.clone())
            .await?;
        assert!(
            !setup.collections[0].is_locked().await?,
            "Collection should be unlocked"
        );
        // Lock again to verify it works multiple times
        let locked = setup
            .service_api
            .lock(&[setup.collections[0].inner().path()], None)
            .await?;
        assert_eq!(locked.len(), 1, "Should have locked 1 collection again");
        assert!(
            setup.collections[0].is_locked().await?,
            "Collection should be locked again"
        );
        Ok(())
    }
    #[tokio::test]
    #[serial_test::serial(xdg_env)]
    async fn create_collection_basic() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Get initial collection count
        let initial_collections = setup.service_api.collections().await?;
        let initial_count = initial_collections.len();
        // Create a new collection
        let collection = setup
            .service_api
            .create_collection("MyNewKeyring", Some("my-custom-alias"), None)
            .await?;
        // Verify collection appears in collections list
        let collections = setup.service_api.collections().await?;
        assert_eq!(
            collections.len(),
            initial_count + 1,
            "Should have one more collection"
        );
        // Verify the collection label
        let label = collection.label().await?;
        assert_eq!(
            label, "MyNewKeyring",
            "Collection should have correct label"
        );
        // Verify the keyring file exists on disk
        let server_collection = setup
            .server
            .collection_from_path(collection.inner().path())
            .await
            .expect("Collection should exist on server");
        let keyring_guard = server_collection.keyring.read().await;
        let keyring_path = keyring_guard.as_ref().unwrap().path().unwrap();
        assert!(
            keyring_path.exists(),
            "Keyring file should exist on disk at {:?}",
            keyring_path
        );
        // Verify the alias was set
        let alias_collection = setup.service_api.read_alias("my-custom-alias").await?;
        assert!(
            alias_collection.is_some(),
            "Should be able to read collection by alias"
        );
        assert_eq!(
            alias_collection.unwrap().inner().path(),
            collection.inner().path(),
            "Alias should point to the new collection"
        );
        tokio::fs::remove_file(keyring_path).await?;
        Ok(())
    }
    #[tokio::test]
    #[serial_test::serial(xdg_env)]
    async fn create_collection_signal() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Subscribe to CollectionCreated signal
        let signal_stream = setup.service_api.receive_collection_created().await?;
        tokio::pin!(signal_stream);
        // Create a new collection
        let collection = setup
            .service_api
            .create_collection("TestKeyring", None, None)
            .await?;
        // Wait for signal with timeout
        let signal_result =
            tokio::time::timeout(tokio::time::Duration::from_secs(1), signal_stream.next()).await;
        assert!(
            signal_result.is_ok(),
            "Should receive CollectionCreated signal"
        );
        let signal = signal_result.unwrap();
        assert!(signal.is_some(), "Signal should not be None");
        let signal_collection = signal.unwrap();
        assert_eq!(
            signal_collection.inner().path().as_str(),
            collection.inner().path().as_str(),
            "Signal should contain the created collection path"
        );
        let server_collection = setup
            .server
            .collection_from_path(collection.inner().path())
            .await
            .expect("Collection should exist on server");
        let keyring_guard = server_collection.keyring.read().await;
        let keyring_path = keyring_guard.as_ref().unwrap().path().unwrap();
        tokio::fs::remove_file(keyring_path).await?;
        Ok(())
    }
    #[tokio::test]
    #[serial_test::serial(xdg_env)]
    async fn create_collection_and_add_items() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create a new collection
        let collection = setup
            .service_api
            .create_collection("ItemTestKeyring", None, None)
            .await?;
        // Verify collection is unlocked and ready for items
        assert!(
            !collection.is_locked().await?,
            "New collection should be unlocked"
        );
        // Create an item in the new collection
        let secret = oo7::Secret::text("hello-world-test");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret.clone());
        let item = collection
            .create_item(
                "Test Item",
                &[("app", "test-app")],
                &dbus_secret,
                false,
                None,
            )
            .await?;
        // Verify item was created
        let items = collection.items().await?;
        assert_eq!(items.len(), 1, "Should have one item in new collection");
        assert_eq!(
            items[0].inner().path(),
            item.inner().path(),
            "Item path should match"
        );
        // Verify we can retrieve the secret
        let retrieved_secret = item.secret(&setup.session).await?;
        assert_eq!(
            retrieved_secret.value(),
            secret.as_bytes(),
            "Should be able to retrieve secret from item in new collection"
        );
        let server_collection = setup
            .server
            .collection_from_path(collection.inner().path())
            .await
            .expect("Collection should exist on server");
        let keyring_guard = server_collection.keyring.read().await;
        let keyring_path = keyring_guard.as_ref().unwrap().path().unwrap();
        tokio::fs::remove_file(&keyring_path).await?;
        Ok(())
    }
    #[tokio::test]
    #[serial_test::serial(xdg_env)]
    async fn create_collection_dismissed() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Get initial collection count
        let initial_collections = setup.service_api.collections().await?;
        let initial_count = initial_collections.len();
        // Set mock prompter to dismiss
        setup.mock_prompter.set_accept(false).await;
        // Try to create a collection
        let result = setup
            .service_api
            .create_collection("DismissedKeyring", None, None)
            .await;
        // Should get Dismissed error
        assert!(
            matches!(result, Err(oo7::dbus::Error::Dismissed)),
            "Should return Dismissed error when prompt dismissed"
        );
        // Verify collection was NOT created
        let collections = setup.service_api.collections().await?;
        assert_eq!(
            collections.len(),
            initial_count,
            "Should not have created a new collection after dismissal"
        );
        Ok(())
    }
    #[tokio::test]
    async fn complete_collection_creation_no_pending() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Try to complete collection creation with a prompt path that has no pending
        // collection
        let fake_prompt_path =
            ObjectPath::try_from("/org/freedesktop/secrets/prompt/p999").unwrap();
        let secret = Secret::from("test-password-long-enough");
        let result = setup
            .server
            .complete_collection_creation(&fake_prompt_path, secret)
            .await;
        // Should get NoSuchObject error
        assert!(
            matches!(result, Err(ServiceError::NoSuchObject(_))),
            "Should return NoSuchObject error when no pending collection exists"
        );
        Ok(())
    }
    #[tokio::test]
    #[serial_test::serial(xdg_env)]
    async fn discover_v1_keyrings() -> Result<(), Box<dyn std::error::Error>> {
        let service = Service::default();
        // Set up a temporary data directory
        let temp_dir = tempfile::tempdir()?;
        unsafe { std::env::set_var("XDG_DATA_HOME", temp_dir.path()) };
        // Create v1 keyrings directory
        let v1_dir = temp_dir.path().join("keyrings/v1");
        tokio::fs::create_dir_all(&v1_dir).await?;
        // Test 1: Empty directory
        let discovered = service.discover_keyrings(None).await?;
        assert!(
            discovered.is_empty(),
            "Should discover no keyrings in empty directory"
        );
        // Create multiple keyrings with different passwords
        // Add items to each so password validation works
        let secret1 = Secret::from("password-for-work");
        let keyring1 = UnlockedKeyring::open("work", secret1.clone()).await?;
        keyring1
            .create_item(
                "Work Item",
                &[("type", "work")],
                Secret::text("work-secret"),
                false,
            )
            .await?;
        keyring1.write().await?;
        let secret2 = Secret::from("password-for-personal");
        let keyring2 = UnlockedKeyring::open("personal", secret2.clone()).await?;
        keyring2
            .create_item(
                "Personal Item",
                &[("type", "personal")],
                Secret::text("personal-secret"),
                false,
            )
            .await?;
        keyring2.write().await?;
        // Create a "login" keyring which should get the default alias
        let secret3 = Secret::from("password-for-login");
        let keyring3 = UnlockedKeyring::open("login", secret3.clone()).await?;
        keyring3
            .create_item(
                "Login Item",
                &[("type", "login")],
                Secret::text("login-secret"),
                false,
            )
            .await?;
        keyring3.write().await?;
        // Create some non-keyring files that should be skipped
        tokio::fs::write(v1_dir.join("README.txt"), b"This is a readme").await?;
        tokio::fs::write(v1_dir.join("config.json"), b"{}").await?;
        tokio::fs::create_dir(v1_dir.join("subdir")).await?;
        // Test 2: Discover without any password, all should be locked
        let discovered = service.discover_keyrings(None).await?;
        assert_eq!(discovered.len(), 3, "Should discover 3 keyrings");
        for (_, _, keyring) in &discovered {
            assert!(
                keyring.is_locked(),
                "All keyrings should be locked without secret"
            );
        }
        // Test 3: Discover with one password, only that keyring should be unlocked
        let discovered = service.discover_keyrings(Some(secret1.clone())).await?;
        assert_eq!(discovered.len(), 3, "Should discover 3 keyrings");
        let work_keyring = discovered
            .iter()
            .find(|(label, _, _)| label == "Work")
            .unwrap();
        assert!(
            !work_keyring.2.is_locked(),
            "Work keyring should be unlocked with correct password"
        );
        let personal_keyring = discovered
            .iter()
            .find(|(label, _, _)| label == "Personal")
            .unwrap();
        assert!(
            personal_keyring.2.is_locked(),
            "Personal keyring should be locked with wrong password"
        );
        // Test 4: Verify login keyring gets default alias
        let login_keyring = discovered
            .iter()
            .find(|(label, _, _)| label == "Login")
            .unwrap();
        assert_eq!(
            login_keyring.1,
            oo7::dbus::Service::DEFAULT_COLLECTION,
            "Login keyring should have default alias"
        );
        assert!(
            login_keyring.2.is_locked(),
            "Login keyring should be locked with wrong password"
        );
        // Test 5: Verify labels are properly capitalized
        let labels: Vec<_> = discovered
            .iter()
            .map(|(label, _, _)| label.as_str())
            .collect();
        assert!(labels.contains(&"Work"), "Should have Work with capital W");
        assert!(
            labels.contains(&"Personal"),
            "Should have Personal with capital P"
        );
        assert!(
            labels.contains(&"Login"),
            "Should have Login with capital L"
        );
        // Clean up
        unsafe { std::env::remove_var("XDG_DATA_HOME") };
        Ok(())
    }
    #[tokio::test]
    #[serial_test::serial(xdg_env)]
    async fn discover_v0_keyrings() -> Result<(), Box<dyn std::error::Error>> {
        let service = Service::default();
        let temp_dir = tempfile::tempdir()?;
        unsafe { std::env::set_var("XDG_DATA_HOME", temp_dir.path()) };
        let keyrings_dir = temp_dir.path().join("keyrings");
        let v1_dir = keyrings_dir.join("v1");
        tokio::fs::create_dir_all(&keyrings_dir).await?;
        tokio::fs::create_dir_all(&v1_dir).await?;
        // Copy the existing v0 keyring fixture
        let v0_secret = Secret::from("test");
        let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .join("client/fixtures/legacy.keyring");
        let v0_path = keyrings_dir.join("legacy.keyring");
        tokio::fs::copy(&fixture_path, &v0_path).await?;
        // Create a v1 keyring for mixed scenario
        let v1_secret = Secret::from("v1-password");
        let v1_keyring = UnlockedKeyring::open("modern", v1_secret.clone()).await?;
        v1_keyring
            .create_item(
                "V1 Item",
                &[("type", "v1")],
                Secret::text("v1-secret"),
                false,
            )
            .await?;
        v1_keyring.write().await?;
        // Test 1: Discover without secret, v0 marked for migration, v1 locked
        let discovered = service.discover_keyrings(None).await?;
        assert_eq!(discovered.len(), 1, "Should discover v1 keyring only");
        assert!(discovered[0].2.is_locked(), "V1 should be locked");
        let pending = service.pending_migrations.lock().await;
        assert_eq!(pending.len(), 1, "V0 should be pending migration");
        assert!(pending.contains_key("legacy"));
        drop(pending);
        // Test 2: Discover with v0 secret, v0 migrated, v1 locked
        service.pending_migrations.lock().await.clear();
        let discovered = service.discover_keyrings(Some(v0_secret.clone())).await?;
        assert_eq!(discovered.len(), 2, "Should discover both keyrings");
        let legacy = discovered.iter().find(|(l, _, _)| l == "Legacy").unwrap();
        assert!(!legacy.2.is_locked(), "V0 should be migrated and unlocked");
        assert_eq!(
            service.pending_migrations.lock().await.len(),
            0,
            "No pending after successful migration"
        );
        // Verify v1 file was created
        let v1_migrated = temp_dir.path().join("keyrings/v1/legacy.keyring");
        assert!(v1_migrated.exists(), "V1 file should exist after migration");
        // Test 3: Discover with wrong v0 secret,  marked for pending migration
        tokio::fs::remove_file(&v1_migrated).await?;
        service.pending_migrations.lock().await.clear();
        // Restore the v0 file for this test
        tokio::fs::copy(&fixture_path, &v0_path).await?;
        let wrong_secret = Secret::from("wrong-password");
        let discovered = service.discover_keyrings(Some(wrong_secret)).await?;
        assert_eq!(
            discovered.len(),
            1,
            "Only v1 should be discovered with wrong v0 password"
        );
        assert_eq!(
            service.pending_migrations.lock().await.len(),
            1,
            "V0 should be pending with wrong password"
        );
        unsafe { std::env::remove_var("XDG_DATA_HOME") };
        Ok(())
    }
}