1
// org.freedesktop.Secret.Collection
2

            
3
use std::{
4
    collections::HashMap,
5
    sync::Arc,
6
    time::{Duration, SystemTime},
7
};
8

            
9
use oo7::{
10
    Secret,
11
    dbus::{
12
        ServiceError,
13
        api::{DBusSecretInner, Properties},
14
    },
15
    file::Keyring,
16
};
17
use tokio::sync::{Mutex, RwLock};
18
use zbus::{interface, object_server::SignalEmitter, proxy::Defaults, zvariant};
19
use zvariant::{ObjectPath, OwnedObjectPath};
20

            
21
use crate::{
22
    Service,
23
    error::{Error, custom_service_error},
24
    item,
25
};
26

            
27
#[derive(Debug, Clone)]
28
pub struct Collection {
29
    // Properties
30
    items: Arc<Mutex<Vec<item::Item>>>,
31
    label: Arc<Mutex<String>>,
32
    created: Duration,
33
    modified: Arc<Mutex<Duration>>,
34
    // Other attributes
35
    alias: Arc<Mutex<String>>,
36
    pub(crate) keyring: Arc<RwLock<Option<Keyring>>>,
37
    service: Service,
38
    item_index: Arc<RwLock<u32>>,
39
    path: OwnedObjectPath,
40
}
41

            
42
#[interface(name = "org.freedesktop.Secret.Collection")]
43
impl Collection {
44
    #[zbus(out_args("prompt"))]
45
8
    pub async fn delete(&self) -> Result<OwnedObjectPath, ServiceError> {
46
        // Check if collection is locked
47
4
        if self.is_locked().await {
48
            // Create a prompt to unlock and delete the collection
49
            let prompt = crate::prompt::Prompt::new(
50
4
                self.service.clone(),
51
2
                crate::prompt::PromptRole::Unlock,
52
4
                self.label().await,
53
4
                Some(self.clone()),
54
            )
55
6
            .await;
56
4
            let prompt_path = OwnedObjectPath::from(prompt.path().clone());
57

            
58
2
            let collection = self.clone();
59
10
            let action =
60
                crate::prompt::PromptAction::new(move |unlock_secret: Secret| async move {
61
                    // Unlock the collection
62
4
                    collection.set_locked(false, Some(unlock_secret)).await?;
63

            
64
2
                    collection.delete_unlocked().await?;
65

            
66
4
                    Ok(zvariant::Value::new(OwnedObjectPath::default())
67
2
                        .try_into_owned()
68
2
                        .unwrap())
69
                });
70

            
71
2
            prompt.set_action(action).await;
72

            
73
6
            self.service
74
4
                .register_prompt(prompt_path.clone(), prompt.clone())
75
4
                .await;
76

            
77
8
            self.service
78
                .object_server()
79
2
                .at(&prompt_path, prompt)
80
6
                .await?;
81

            
82
2
            tracing::debug!(
83
                "Delete prompt created at `{}` for locked collection `{}`",
84
                prompt_path,
85
                self.path
86
            );
87

            
88
2
            return Ok(prompt_path);
89
        }
90

            
91
4
        self.delete_unlocked().await?;
92
2
        Ok(OwnedObjectPath::default())
93
    }
94

            
95
8
    async fn delete_unlocked(&self) -> Result<(), ServiceError> {
96
4
        let keyring = self.keyring.read().await;
97
4
        let keyring = keyring.as_ref().unwrap().as_unlocked();
98

            
99
2
        let object_server = self.service.object_server();
100

            
101
        // Remove all items from the object server
102
4
        let items = self.items.lock().await;
103
6
        for item in items.iter() {
104
6
            object_server.remove::<item::Item, _>(item.path()).await?;
105
        }
106
2
        drop(items);
107

            
108
        // Delete the keyring file if it's persistent
109
2
        if let Some(path) = keyring.path() {
110
            tokio::fs::remove_file(&path).await.map_err(|err| {
111
                custom_service_error(&format!("Failed to delete keyring file: {err}"))
112
            })?;
113
            tracing::debug!("Deleted keyring file: {}", path.display());
114
        }
115

            
116
        // Emit CollectionDeleted signal before removing from object server
117
4
        let service_path = oo7::dbus::api::Service::PATH.as_ref().unwrap();
118
2
        let signal_emitter = self.service.signal_emitter(service_path)?;
119
4
        Service::collection_deleted(&signal_emitter, &self.path).await?;
120

            
121
        // Remove collection from object server
122
2
        object_server.remove::<Collection, _>(&self.path).await?;
123

            
124
        // Notify service to remove from collections list
125
2
        self.service.remove_collection(&self.path).await;
126

            
127
2
        tracing::info!("Collection `{}` deleted.", self.path);
128

            
129
2
        Ok(())
130
    }
131

            
132
    #[zbus(out_args("results"))]
133
2
    pub async fn search_items(
134
        &self,
135
        attributes: HashMap<String, String>,
136
    ) -> Result<Vec<OwnedObjectPath>, ServiceError> {
137
8
        let results = self
138
2
            .search_inner_items(&attributes)
139
6
            .await?
140
            .iter()
141
6
            .map(|item| item.path().clone().into())
142
            .collect::<Vec<OwnedObjectPath>>();
143

            
144
2
        if results.is_empty() {
145
4
            tracing::debug!(
146
                "Items with attributes {:?} does not exist in collection: {}.",
147
                attributes,
148
                self.path
149
            );
150
        } else {
151
4
            tracing::debug!(
152
                "Items with attributes {:?} found in collection: {}.",
153
                attributes,
154
                self.path
155
            );
156
        }
157

            
158
2
        Ok(results)
159
    }
160

            
161
    #[zbus(out_args("item", "prompt"))]
162
2
    pub async fn create_item(
163
        &self,
164
        properties: Properties,
165
        secret: DBusSecretInner,
166
        replace: bool,
167
    ) -> Result<(OwnedObjectPath, OwnedObjectPath), ServiceError> {
168
7
        if self.is_locked().await {
169
            // Create a prompt to unlock the collection and create the item
170
            let prompt = crate::prompt::Prompt::new(
171
4
                self.service.clone(),
172
2
                crate::prompt::PromptRole::Unlock,
173
4
                self.label().await,
174
4
                Some(self.clone()),
175
            )
176
6
            .await;
177
4
            let prompt_path = OwnedObjectPath::from(prompt.path().clone());
178

            
179
2
            let collection = self.clone();
180
10
            let action =
181
                crate::prompt::PromptAction::new(move |unlock_secret: Secret| async move {
182
4
                    collection.set_locked(false, Some(unlock_secret)).await?;
183

            
184
8
                    let item_path = collection
185
2
                        .create_item_unlocked(properties, secret, replace)
186
6
                        .await?;
187

            
188
4
                    Ok(zvariant::Value::new(item_path).try_into_owned().unwrap())
189
                });
190

            
191
2
            prompt.set_action(action).await;
192

            
193
6
            self.service
194
4
                .register_prompt(prompt_path.clone(), prompt.clone())
195
4
                .await;
196

            
197
8
            self.service
198
                .object_server()
199
2
                .at(&prompt_path, prompt)
200
6
                .await?;
201

            
202
2
            tracing::debug!(
203
                "CreateItem prompt created at `{}` for locked collection `{}`",
204
                prompt_path,
205
                self.path
206
            );
207

            
208
4
            return Ok((OwnedObjectPath::default(), prompt_path));
209
        }
210

            
211
10
        let item_path = self
212
2
            .create_item_unlocked(properties, secret, replace)
213
10
            .await?;
214

            
215
2
        Ok((item_path, OwnedObjectPath::default()))
216
    }
217

            
218
2
    async fn create_item_unlocked(
219
        &self,
220
        properties: Properties,
221
        secret: DBusSecretInner,
222
        replace: bool,
223
    ) -> Result<OwnedObjectPath, ServiceError> {
224
4
        let keyring = self.keyring.read().await;
225
4
        let keyring = keyring.as_ref().unwrap().as_unlocked();
226

            
227
2
        let DBusSecretInner(session_path, iv, secret_bytes, content_type) = secret;
228
4
        let label = properties.label();
229
        // Safe to unwrap as an item always has attributes
230
2
        let mut attributes = properties.attributes().unwrap().to_owned();
231

            
232
4
        let Some(session) = self.service.session(&session_path).await else {
233
4
            tracing::error!("The session `{}` does not exist.", session_path);
234
4
            return Err(ServiceError::NoSession(format!(
235
                "The session `{session_path}` does not exist."
236
            )));
237
        };
238

            
239
4
        let secret = match session.aes_key() {
240
4
            Some(key) => oo7::crypto::decrypt(secret_bytes, &key, &iv)
241
4
                .map_err(|err| custom_service_error(&format!("Failed to decrypt secret {err}.")))?,
242
2
            None => zeroize::Zeroizing::new(secret_bytes),
243
        };
244

            
245
        // Ensure content-type attribute is stored
246
4
        if !attributes.contains_key(oo7::CONTENT_TYPE_ATTRIBUTE) {
247
4
            attributes.insert(
248
4
                oo7::CONTENT_TYPE_ATTRIBUTE.to_owned(),
249
4
                content_type.as_str().to_owned(),
250
            );
251
        }
252

            
253
8
        let item = keyring
254
2
            .create_item(label, &attributes, secret, replace)
255
8
            .await
256
2
            .map_err(|err| custom_service_error(&format!("Failed to create a new item {err}.")))?;
257

            
258
4
        let n_items = *self.item_index.read().await;
259
4
        let item_path = OwnedObjectPath::try_from(format!("{}/{n_items}", self.path)).unwrap();
260

            
261
        let item = item::Item::new(
262
2
            item,
263
4
            self.service.clone(),
264
4
            self.path.clone(),
265
2
            item_path.clone(),
266
        );
267
4
        *self.item_index.write().await = n_items + 1;
268

            
269
2
        let object_server = self.service.object_server();
270
2
        let signal_emitter = self.service.signal_emitter(&self.path)?;
271

            
272
        // Remove any existing items with the same attributes
273
2
        if replace {
274
4
            let existing_items = self.search_inner_items(&attributes).await?;
275
6
            if !existing_items.is_empty() {
276
4
                let mut items = self.items.lock().await;
277
8
                for existing in &existing_items {
278
4
                    let existing_path = existing.path();
279

            
280
6
                    items.retain(|i| i.path() != existing_path);
281
4
                    object_server.remove::<item::Item, _>(existing_path).await?;
282
4
                    Self::item_deleted(&signal_emitter, existing_path).await?;
283

            
284
2
                    tracing::debug!("Replaced item `{}`", existing_path);
285
                }
286
2
                drop(items);
287
            }
288
        }
289

            
290
4
        self.items.lock().await.push(item.clone());
291

            
292
2
        object_server.at(&item_path, item).await?;
293

            
294
2
        self.update_modified().await?;
295

            
296
2
        Self::item_created(&signal_emitter, &item_path).await?;
297
2
        self.items_changed(&signal_emitter).await?;
298

            
299
2
        tracing::info!("Item `{item_path}` created.");
300

            
301
2
        Ok(item_path)
302
    }
303

            
304
    #[zbus(property, name = "Items")]
305
8
    pub async fn items(&self) -> Vec<OwnedObjectPath> {
306
10
        self.items
307
            .lock()
308
6
            .await
309
            .iter()
310
6
            .map(|i| i.path().to_owned().into())
311
            .collect()
312
    }
313

            
314
    #[zbus(property, name = "Label")]
315
8
    pub async fn label(&self) -> String {
316
4
        self.label.lock().await.clone()
317
    }
318

            
319
    #[zbus(property, name = "Label")]
320
8
    pub async fn set_label(&self, label: &str) -> Result<(), zbus::Error> {
321
4
        if self.is_locked().await {
322
4
            tracing::error!("Cannot set label of a locked collection `{}`", self.path);
323
2
            return Err(zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed(
324
4
                format!("Cannot set label of a locked collection `{}`.", self.path),
325
            ))));
326
        }
327

            
328
2
        *self.label.lock().await = label.to_owned();
329

            
330
8
        self.update_modified()
331
6
            .await
332
2
            .map_err(|err| zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed(err.to_string()))))?;
333

            
334
2
        let service_path = oo7::dbus::api::Service::PATH.as_ref().unwrap();
335
4
        let signal_emitter = self
336
            .service
337
2
            .signal_emitter(service_path)
338
2
            .map_err(|err| zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed(err.to_string()))))?;
339
4
        Service::collection_changed(&signal_emitter, &self.path).await?;
340

            
341
4
        let signal_emitter = self
342
            .service
343
2
            .signal_emitter(&self.path)
344
2
            .map_err(|err| zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed(err.to_string()))))?;
345
4
        self.label_changed(&signal_emitter).await?;
346

            
347
2
        Ok(())
348
    }
349

            
350
    #[zbus(property, name = "Locked")]
351
12
    pub async fn is_locked(&self) -> bool {
352
16
        self.keyring
353
            .read()
354
12
            .await
355
            .as_ref()
356
12
            .map(|k| k.is_locked())
357
            .unwrap_or(true)
358
    }
359

            
360
    #[zbus(property, name = "Created")]
361
2
    pub fn created_at(&self) -> u64 {
362
2
        self.created.as_secs()
363
    }
364

            
365
    #[zbus(property, name = "Modified")]
366
8
    pub async fn modified_at(&self) -> u64 {
367
4
        self.modified.lock().await.as_secs()
368
    }
369

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

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

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

            
389
impl Collection {
390
16
    pub async fn new(label: &str, alias: &str, service: Service, keyring: Keyring) -> Self {
391
8
        let modified = keyring.modified_time().await;
392
7
        let created = keyring.created_time().await.unwrap_or(modified);
393

            
394
5
        let sanitized_label = label
395
            .chars()
396
8
            .map(|c| {
397
6
                if c.is_alphanumeric() || c == '_' {
398
5
                    c
399
                } else {
400
                    '_'
401
                }
402
            })
403
            .collect::<String>();
404

            
405
        Self {
406
8
            items: Default::default(),
407
8
            label: Arc::new(Mutex::new(label.to_owned())),
408
8
            modified: Arc::new(Mutex::new(modified)),
409
8
            alias: Arc::new(Mutex::new(alias.to_owned())),
410
10
            item_index: Arc::new(RwLock::new(0)),
411
6
            path: OwnedObjectPath::try_from(format!(
412
                "/org/freedesktop/secrets/collection/{sanitized_label}"
413
            ))
414
            .expect("Sanitized label should always produce valid object path"),
415
            created,
416
            service,
417
8
            keyring: Arc::new(RwLock::new(Some(keyring))),
418
        }
419
    }
420

            
421
5
    pub fn path(&self) -> &ObjectPath<'_> {
422
3
        &self.path
423
    }
424

            
425
8
    pub async fn set_alias(&self, alias: &str) {
426
2
        *self.alias.lock().await = alias.to_owned();
427
    }
428

            
429
8
    pub async fn alias(&self) -> String {
430
4
        self.alias.lock().await.clone()
431
    }
432

            
433
2
    pub async fn search_inner_items(
434
        &self,
435
        attributes: &HashMap<String, String>,
436
    ) -> Result<Vec<item::Item>, ServiceError> {
437
        // If collection is locked, we can't search
438
4
        if self.is_locked().await {
439
            return Ok(Vec::new());
440
        }
441

            
442
4
        let keyring_guard = self.keyring.read().await;
443
4
        let keyring = keyring_guard.as_ref().unwrap().as_unlocked();
444

            
445
6
        let key = keyring
446
            .key()
447
8
            .await
448
2
            .map_err(|err| custom_service_error(&format!("Failed to derive key: {err}")))?;
449

            
450
2
        let mut matching_items = Vec::new();
451
4
        let items = self.items.lock().await;
452

            
453
8
        for item_wrapper in items.iter() {
454
6
            let inner = item_wrapper.inner.lock().await;
455
4
            let file_item = inner.as_ref().unwrap();
456

            
457
            // Use the oo7::file::Item's matches_attributes method
458
2
            if file_item.matches_attributes(attributes, &key) {
459
2
                matching_items.push(item_wrapper.clone());
460
            }
461
        }
462

            
463
2
        Ok(matching_items)
464
    }
465

            
466
8
    pub async fn item_from_path(&self, path: &ObjectPath<'_>) -> Option<item::Item> {
467
4
        let items = self.items.lock().await;
468

            
469
8
        items.iter().find(|i| i.path() == path).cloned()
470
    }
471

            
472
2
    pub async fn set_locked(
473
        &self,
474
        locked: bool,
475
        secret: Option<Secret>,
476
    ) -> Result<(), ServiceError> {
477
4
        let mut keyring_guard = self.keyring.write().await;
478

            
479
6
        if let Some(old_keyring) = keyring_guard.take() {
480
4
            let new_keyring = match (old_keyring, locked) {
481
2
                (Keyring::Unlocked(unlocked), true) => {
482
4
                    let items = self.items.lock().await;
483
6
                    for item in items.iter() {
484
6
                        item.set_locked(locked, &unlocked).await?;
485
                    }
486
2
                    drop(items);
487

            
488
2
                    Keyring::Locked(unlocked.lock())
489
                }
490
2
                (Keyring::Locked(locked_kr), false) => {
491
4
                    let secret = secret.ok_or_else(|| {
492
                        custom_service_error("Cannot unlock collection without a secret")
493
                    })?;
494

            
495
8
                    let keyring_path = locked_kr.path().map(|p| p.to_path_buf());
496

            
497
4
                    let unlocked = match locked_kr.unlock(secret).await {
498
2
                        Ok(unlocked) => unlocked,
499
2
                        Err(err) => {
500
                            // Reload the locked keyring from disk before returning error
501
4
                            if let Some(path) = keyring_path {
502
8
                                if let Ok(reloaded) = oo7::file::LockedKeyring::load(&path).await {
503
2
                                    *keyring_guard = Some(Keyring::Locked(reloaded));
504
                                }
505
                            }
506
6
                            return Err(custom_service_error(&format!(
507
                                "Failed to unlock keyring: {err}"
508
                            )));
509
                        }
510
                    };
511

            
512
4
                    let items = self.items.lock().await;
513
6
                    for item in items.iter() {
514
6
                        item.set_locked(locked, &unlocked).await?;
515
                    }
516
2
                    drop(items);
517

            
518
2
                    Keyring::Unlocked(unlocked)
519
                }
520
                (other, _) => other,
521
            };
522
2
            *keyring_guard = Some(new_keyring);
523
        }
524

            
525
2
        drop(keyring_guard);
526

            
527
        // Emit signals
528
2
        let signal_emitter = self.service.signal_emitter(&self.path)?;
529
4
        self.locked_changed(&signal_emitter).await?;
530

            
531
2
        let service_path = oo7::dbus::api::Service::PATH.as_ref().unwrap();
532
2
        let signal_emitter = self.service.signal_emitter(service_path)?;
533
4
        Service::collection_changed(&signal_emitter, &self.path).await?;
534

            
535
1
        tracing::debug!(
536
            "Collection: {} is {}.",
537
            self.path,
538
            if locked { "locked" } else { "unlocked" }
539
        );
540

            
541
2
        Ok(())
542
    }
543

            
544
15
    pub async fn dispatch_items(&self) -> Result<(), Error> {
545
8
        let keyring_guard = self.keyring.read().await;
546
8
        let keyring = keyring_guard.as_ref().unwrap();
547

            
548
11
        let keyring_items = keyring.items().await?;
549
8
        let mut items = self.items.lock().await;
550
8
        let object_server = self.service.object_server();
551
3
        let mut n_items = 1;
552

            
553
10
        for keyring_item in keyring_items {
554
4
            let item_path = OwnedObjectPath::try_from(format!("{}/{n_items}", self.path)).unwrap();
555
            let item = item::Item::new(
556
4
                keyring_item.map_err(Error::InvalidItem)?,
557
4
                self.service.clone(),
558
4
                self.path.clone(),
559
2
                item_path.clone(),
560
            );
561
2
            n_items += 1;
562

            
563
4
            items.push(item.clone());
564
6
            object_server.at(item_path, item).await?;
565
        }
566

            
567
5
        *self.item_index.write().await = n_items;
568

            
569
3
        Ok(())
570
    }
571

            
572
8
    pub async fn delete_item(&self, path: &ObjectPath<'_>) -> Result<(), ServiceError> {
573
4
        let Some(item) = self.item_from_path(path).await else {
574
            return Err(ServiceError::NoSuchObject(format!(
575
                "Item `{path}` does not exist."
576
            )));
577
        };
578

            
579
4
        if item.is_locked().await {
580
            return Err(ServiceError::IsLocked(format!(
581
                "Cannot delete a locked item `{path}`"
582
            )));
583
        }
584

            
585
4
        if self.is_locked().await {
586
            return Err(ServiceError::IsLocked(format!(
587
                "Cannot delete an item `{path}`  in a locked collection "
588
            )));
589
        }
590

            
591
4
        let attributes = item.attributes().await.map_err(|err| {
592
            custom_service_error(&format!("Failed to read item attributes {err}"))
593
        })?;
594

            
595
4
        let keyring = self.keyring.read().await;
596
4
        let keyring = keyring.as_ref().unwrap().as_unlocked();
597

            
598
6
        keyring
599
2
            .delete(&attributes)
600
6
            .await
601
2
            .map_err(|err| custom_service_error(&format!("Failed to deleted item {err}.")))?;
602

            
603
4
        let mut items = self.items.lock().await;
604
8
        items.retain(|item| item.path() != path);
605
2
        drop(items);
606

            
607
2
        self.update_modified().await?;
608

            
609
2
        let signal_emitter = self.service.signal_emitter(&self.path)?;
610
4
        self.items_changed(&signal_emitter).await?;
611

            
612
2
        Ok(())
613
    }
614

            
615
    /// Update the modified timestamp and emit the PropertiesChanged signal
616
8
    async fn update_modified(&self) -> Result<(), ServiceError> {
617
6
        let now = SystemTime::now()
618
2
            .duration_since(SystemTime::UNIX_EPOCH)
619
            .unwrap();
620
2
        *self.modified.lock().await = now;
621

            
622
2
        let signal_emitter = self.service.signal_emitter(&self.path)?;
623
4
        self.modified_changed(&signal_emitter).await?;
624

            
625
2
        Ok(())
626
    }
627
}
628

            
629
#[cfg(test)]
630
mod tests {
631
    use std::sync::Arc;
632

            
633
    use oo7::dbus;
634
    use tokio_stream::StreamExt;
635

            
636
    use crate::tests::TestServiceSetup;
637

            
638
    #[tokio::test]
639
    async fn create_item_plain() -> Result<(), Box<dyn std::error::Error>> {
640
        let setup = TestServiceSetup::plain_session(true).await?;
641

            
642
        // Get initial modified timestamp
643
        let initial_modified = setup.collections[0].modified().await?;
644

            
645
        // Wait to ensure timestamp will be different
646
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
647

            
648
        // Create an item using the proper API
649
        let secret = oo7::Secret::text("my-secret-password");
650
        let dbus_secret = dbus::api::DBusSecret::new(setup.session, secret.clone());
651

            
652
        let item = setup.collections[0]
653
            .create_item(
654
                "Test Item",
655
                &[("application", "test-app"), ("type", "password")],
656
                &dbus_secret,
657
                false,
658
                None,
659
            )
660
            .await?;
661

            
662
        // Verify item exists in collection
663
        let items = setup.collections[0].items().await?;
664
        assert_eq!(items.len(), 1, "Collection should have one item");
665
        assert_eq!(items[0].inner().path(), item.inner().path());
666

            
667
        // Verify item label
668
        let label = item.label().await?;
669
        assert_eq!(label, "Test Item");
670

            
671
        // Verify modified timestamp was updated
672
        let new_modified = setup.collections[0].modified().await?;
673
        assert!(
674
            new_modified > initial_modified,
675
            "Modified timestamp should be updated after creating item"
676
        );
677

            
678
        Ok(())
679
    }
680

            
681
    #[tokio::test]
682
    async fn create_item_encrypted() -> Result<(), Box<dyn std::error::Error>> {
683
        let setup = TestServiceSetup::encrypted_session(true).await?;
684
        let aes_key = setup.aes_key.unwrap();
685

            
686
        // Create an encrypted item using the proper API
687
        let secret = oo7::Secret::text("my-encrypted-secret");
688
        let dbus_secret = dbus::api::DBusSecret::new_encrypted(setup.session, secret, &aes_key)?;
689

            
690
        let item = setup.collections[0]
691
            .create_item(
692
                "Test Encrypted Item",
693
                &[("application", "test-app"), ("type", "encrypted-password")],
694
                &dbus_secret,
695
                false,
696
                None,
697
            )
698
            .await?;
699

            
700
        // Verify item exists
701
        let items = setup.collections[0].items().await?;
702
        assert_eq!(items.len(), 1, "Collection should have one item");
703
        assert_eq!(items[0].inner().path(), item.inner().path());
704

            
705
        Ok(())
706
    }
707

            
708
    #[tokio::test]
709
    async fn search_items_after_creation() -> Result<(), Box<dyn std::error::Error>> {
710
        let setup = TestServiceSetup::plain_session(true).await?;
711

            
712
        // Create two items with different attributes
713
        let secret1 = oo7::Secret::text("password1");
714
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1);
715

            
716
        setup.collections[0]
717
            .create_item(
718
                "Firefox Password",
719
                &[("application", "firefox"), ("username", "user1")],
720
                &dbus_secret1,
721
                false,
722
                None,
723
            )
724
            .await?;
725

            
726
        let secret2 = oo7::Secret::text("password2");
727
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2);
728

            
729
        setup.collections[0]
730
            .create_item(
731
                "Chrome Password",
732
                &[("application", "chrome"), ("username", "user2")],
733
                &dbus_secret2,
734
                false,
735
                None,
736
            )
737
            .await?;
738

            
739
        // Search for firefox item
740
        let firefox_attrs = &[("application", "firefox")];
741
        let firefox_items = setup.collections[0].search_items(firefox_attrs).await?;
742

            
743
        assert_eq!(firefox_items.len(), 1, "Should find one firefox item");
744

            
745
        // Search for chrome item
746
        let chrome_items = setup.collections[0]
747
            .search_items(&[("application", "chrome")])
748
            .await?;
749

            
750
        assert_eq!(chrome_items.len(), 1, "Should find one chrome item");
751

            
752
        // Search for non-existent item
753
        let nonexistent_items = setup.collections[0]
754
            .search_items(&[("application", "nonexistent")])
755
            .await?;
756

            
757
        assert_eq!(
758
            nonexistent_items.len(),
759
            0,
760
            "Should find no nonexistent items"
761
        );
762

            
763
        Ok(())
764
    }
765

            
766
    #[tokio::test]
767
    async fn search_items_subset_matching() -> Result<(), Box<dyn std::error::Error>> {
768
        let setup = TestServiceSetup::plain_session(true).await?;
769

            
770
        // Create an item with multiple attributes (url and username)
771
        let secret = oo7::Secret::text("my-password");
772
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret);
773

            
774
        setup.collections[0]
775
            .create_item(
776
                "Zed Login",
777
                &[("url", "https://zed.dev"), ("username", "alice")],
778
                &dbus_secret,
779
                false,
780
                None,
781
            )
782
            .await?;
783

            
784
        // Search with only the url attribute (subset of stored attributes)
785
        let results = setup.collections[0]
786
            .search_items(&[("url", "https://zed.dev")])
787
            .await?;
788

            
789
        assert_eq!(
790
            results.len(),
791
            1,
792
            "Should find item when searching with subset of its attributes"
793
        );
794

            
795
        // Search with only the username attribute (another subset)
796
        let results = setup.collections[0]
797
            .search_items(&[("username", "alice")])
798
            .await?;
799

            
800
        assert_eq!(
801
            results.len(),
802
            1,
803
            "Should find item when searching with different subset of its attributes"
804
        );
805

            
806
        // Search with both attributes (exact match)
807
        let results = setup.collections[0]
808
            .search_items(&[("url", "https://zed.dev"), ("username", "alice")])
809
            .await?;
810

            
811
        assert_eq!(
812
            results.len(),
813
            1,
814
            "Should find item when searching with all its attributes"
815
        );
816

            
817
        // Search with superset of attributes (should not match)
818
        let results = setup.collections[0]
819
            .search_items(&[
820
                ("url", "https://zed.dev"),
821
                ("username", "alice"),
822
                ("extra", "attribute"),
823
            ])
824
            .await?;
825

            
826
        assert_eq!(
827
            results.len(),
828
            0,
829
            "Should not find item when searching with superset of its attributes"
830
        );
831

            
832
        Ok(())
833
    }
834

            
835
    #[tokio::test]
836
    async fn create_item_with_replace() -> Result<(), Box<dyn std::error::Error>> {
837
        let setup = TestServiceSetup::plain_session(true).await?;
838

            
839
        // Create first item
840
        let secret1 = oo7::Secret::text("original-password");
841
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1.clone());
842

            
843
        let item1 = setup.collections[0]
844
            .create_item(
845
                "Test Item",
846
                &[("application", "myapp"), ("username", "user")],
847
                &dbus_secret1,
848
                false,
849
                None,
850
            )
851
            .await?;
852

            
853
        // Verify one item exists
854
        let items = setup.collections[0].items().await?;
855
        assert_eq!(items.len(), 1, "Should have one item");
856

            
857
        // Get the secret from first item
858
        let retrieved1 = item1.secret(&setup.session).await?;
859
        assert_eq!(retrieved1.value(), secret1.as_bytes());
860

            
861
        // Create second item with same attributes and replace=true
862
        let secret2 = oo7::Secret::text("replaced-password");
863
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2.clone());
864

            
865
        let item2 = setup.collections[0]
866
            .create_item(
867
                "Test Item",
868
                &[("application", "myapp"), ("username", "user")],
869
                &dbus_secret2,
870
                true, // replace=true
871
                None,
872
            )
873
            .await?;
874

            
875
        // Should still have only one item (replaced)
876
        let items = setup.collections[0].items().await?;
877
        assert_eq!(items.len(), 1, "Should still have one item after replace");
878

            
879
        // Verify the new item has the updated secret
880
        let retrieved2 = item2.secret(&setup.session).await?;
881
        assert_eq!(retrieved2.value(), secret2.as_bytes());
882

            
883
        Ok(())
884
    }
885

            
886
    #[tokio::test]
887
    async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
888
        let setup = TestServiceSetup::plain_session(true).await?;
889

            
890
        // Get the Login collection via alias (don't rely on collection ordering)
891
        let login_collection = setup
892
            .service_api
893
            .read_alias("default")
894
            .await?
895
            .expect("Default collection should exist");
896

            
897
        // Get initial label (should be "Login" for default collection)
898
        let label = login_collection.label().await?;
899
        assert_eq!(label, "Login");
900

            
901
        // Get initial modified timestamp
902
        let initial_modified = login_collection.modified().await?;
903

            
904
        // Wait to ensure timestamp will be different
905
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
906

            
907
        // Set new label
908
        login_collection.set_label("My Custom Collection").await?;
909

            
910
        // Verify new label
911
        let label = login_collection.label().await?;
912
        assert_eq!(label, "My Custom Collection");
913

            
914
        // Verify modified timestamp was updated
915
        let new_modified = login_collection.modified().await?;
916
        assert!(
917
            new_modified > initial_modified,
918
            "Modified timestamp should be updated after label change"
919
        );
920

            
921
        Ok(())
922
    }
923

            
924
    #[tokio::test]
925
    async fn timestamps() -> Result<(), Box<dyn std::error::Error>> {
926
        let setup = TestServiceSetup::plain_session(true).await?;
927

            
928
        // Get created timestamp
929
        let created = setup.collections[0].created().await?;
930
        assert!(created.as_secs() > 0, "Created timestamp should be set");
931

            
932
        // Get modified timestamp
933
        let modified = setup.collections[0].modified().await?;
934
        assert!(modified.as_secs() > 0, "Modified timestamp should be set");
935

            
936
        // Created and modified should be close (within a second for new collection)
937
        let diff = if created > modified {
938
            created.as_secs() - modified.as_secs()
939
        } else {
940
            modified.as_secs() - created.as_secs()
941
        };
942
        assert!(diff <= 1, "Created and modified should be within 1 second");
943

            
944
        Ok(())
945
    }
946

            
947
    #[tokio::test]
948
    async fn create_item_invalid_session() -> Result<(), Box<dyn std::error::Error>> {
949
        let setup = TestServiceSetup::plain_session(true).await?;
950

            
951
        // Create an item using the proper API
952
        let secret = oo7::Secret::text("my-secret-password");
953
        let invalid_session =
954
            dbus::api::Session::new(&setup.client_conn, "/invalid/session/path").await?;
955
        let dbus_secret = dbus::api::DBusSecret::new(Arc::new(invalid_session), secret.clone());
956

            
957
        let result = setup.collections[0]
958
            .create_item(
959
                "Test Item",
960
                &[("application", "test-app"), ("type", "password")],
961
                &dbus_secret,
962
                false,
963
                None,
964
            )
965
            .await;
966

            
967
        assert!(
968
            matches!(
969
                result,
970
                Err(oo7::dbus::Error::Service(
971
                    oo7::dbus::ServiceError::NoSession(_)
972
                ))
973
            ),
974
            "Should be NoSession error"
975
        );
976

            
977
        Ok(())
978
    }
979

            
980
    #[tokio::test]
981
    async fn item_created_signal() -> Result<(), Box<dyn std::error::Error>> {
982
        let setup = TestServiceSetup::plain_session(true).await?;
983

            
984
        // Subscribe to ItemCreated signal
985
        let signal_stream = setup.collections[0].receive_item_created().await?;
986
        tokio::pin!(signal_stream);
987

            
988
        // Create an item
989
        let secret = oo7::Secret::text("test-secret");
990
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret);
991

            
992
        let item = setup.collections[0]
993
            .create_item("Test Item", &[("app", "test")], &dbus_secret, false, None)
994
            .await?;
995

            
996
        // Wait for signal with timeout
997
        let signal_result =
998
            tokio::time::timeout(tokio::time::Duration::from_secs(1), signal_stream.next()).await;
999

            
        assert!(signal_result.is_ok(), "Should receive ItemCreated signal");
        let signal = signal_result.unwrap();
        assert!(signal.is_some(), "Signal should not be None");
        let signal_item = signal.unwrap();
        assert_eq!(
            signal_item.inner().path().as_str(),
            item.inner().path().as_str(),
            "Signal should contain the created item path"
        );
        Ok(())
    }
    #[tokio::test]
    async fn item_deleted_signal() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create an item
        let secret = oo7::Secret::text("test-secret");
        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?;
        let item_path = item.inner().path().to_owned();
        // Subscribe to ItemDeleted signal
        let signal_stream = setup.collections[0].receive_item_deleted().await?;
        tokio::pin!(signal_stream);
        // Delete the item
        item.delete(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 ItemDeleted signal");
        let signal = signal_result.unwrap();
        assert!(signal.is_some(), "Signal should not be None");
        let signal_item = signal.unwrap();
        assert_eq!(
            signal_item.as_str(),
            item_path.as_str(),
            "Signal should contain the deleted item path"
        );
        Ok(())
    }
    #[tokio::test]
    async fn collection_changed_signal() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Subscribe to CollectionChanged signal
        let signal_stream = setup.service_api.receive_collection_changed().await?;
        tokio::pin!(signal_stream);
        // Change the collection label
        setup.collections[0]
            .set_label("Updated Collection Label")
            .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 CollectionChanged signal after label change"
        );
        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(),
            setup.collections[0].inner().path().as_str(),
            "Signal should contain the changed collection path"
        );
        Ok(())
    }
    #[tokio::test]
    async fn delete_collection() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Create some items in the collection
        let secret1 = oo7::Secret::text("password1");
        let dbus_secret1 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret1);
        setup.collections[0]
            .create_item("Item 1", &[("app", "test")], &dbus_secret1, false, None)
            .await?;
        let secret2 = oo7::Secret::text("password2");
        let dbus_secret2 = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret2);
        setup.collections[0]
            .create_item("Item 2", &[("app", "test")], &dbus_secret2, false, None)
            .await?;
        // Verify items were created
        let items = setup.collections[0].items().await?;
        assert_eq!(items.len(), 2, "Should have 2 items before deletion");
        // Get collection path for later verification
        let collection_path = setup.collections[0].inner().path().to_owned();
        // Verify collection exists in service
        let collections_before = setup.service_api.collections().await?;
        let initial_count = collections_before.len();
        // Delete the collection
        setup.collections[0].delete(None).await?;
        // Give the system a moment to process the deletion
        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
        // Verify collection is no longer in service's collection list
        let collections_after = setup.service_api.collections().await?;
        assert_eq!(
            collections_after.len(),
            initial_count - 1,
            "Service should have one less collection after deletion"
        );
        // Verify the specific collection is not in the list
        let collection_paths: Vec<_> = collections_after
            .iter()
            .map(|c| c.inner().path().as_str())
            .collect();
        assert!(
            !collection_paths.contains(&collection_path.as_str()),
            "Deleted collection should not be in service collections list"
        );
        Ok(())
    }
    #[tokio::test]
    async fn collection_deleted_signal() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Subscribe to CollectionDeleted signal
        let signal_stream = setup.service_api.receive_collection_deleted().await?;
        tokio::pin!(signal_stream);
        let collection_path = setup.collections[0].inner().path().to_owned();
        // Delete the collection
        setup.collections[0].delete(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 CollectionDeleted signal"
        );
        let signal = signal_result.unwrap();
        assert!(signal.is_some(), "Signal should not be None");
        let signal_collection = signal.unwrap();
        assert_eq!(
            signal_collection.as_str(),
            collection_path.as_str(),
            "Signal should contain the deleted collection path"
        );
        Ok(())
    }
    #[tokio::test]
    async fn create_item_in_locked_collection() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        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"
        );
        let secret = oo7::Secret::text("test-password");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret.clone());
        let item = setup.collections[0]
            .create_item(
                "Test Item",
                &[("app", "test"), ("type", "password")],
                &dbus_secret,
                false,
                None,
            )
            .await?;
        assert!(
            !setup.collections[0].is_locked().await?,
            "Collection should be unlocked after prompt"
        );
        let items = setup.collections[0].items().await?;
        assert_eq!(items.len(), 1, "Collection should have one item");
        assert_eq!(
            items[0].inner().path(),
            item.inner().path(),
            "Created item should be in the collection"
        );
        let label = item.label().await?;
        assert_eq!(label, "Test Item", "Item should have correct label");
        let attributes = item.attributes().await?;
        assert_eq!(attributes.get("app"), Some(&"test".to_string()));
        assert_eq!(attributes.get("type"), Some(&"password".to_string()));
        let retrieved_secret = item.secret(&setup.session).await?;
        assert_eq!(retrieved_secret.value(), secret.as_bytes());
        Ok(())
    }
    #[tokio::test]
    async fn delete_locked_collection_with_prompt() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        let default_collection = setup.default_collection().await?;
        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!(
            default_collection.is_locked().await?,
            "Collection should be locked"
        );
        let collection_path = default_collection.inner().path().to_owned();
        // Get initial collection count
        let collections_before = setup.service_api.collections().await?;
        let initial_count = collections_before.len();
        // Delete the locked collection
        default_collection.delete(None).await?;
        // Give the system a moment to process the deletion
        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
        // Verify collection was deleted
        let collections_after = setup.service_api.collections().await?;
        assert_eq!(
            collections_after.len(),
            initial_count - 1,
            "Collection should be deleted after prompt"
        );
        // Verify the specific collection is not in the list
        let collection_paths: Vec<_> = collections_after
            .iter()
            .map(|c| c.inner().path().as_str())
            .collect();
        assert!(
            !collection_paths.contains(&collection_path.as_str()),
            "Deleted collection should not be in service collections list"
        );
        Ok(())
    }
    #[tokio::test]
    async fn unlock_retry() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        let default_collection = setup.default_collection().await?;
        let secret = oo7::Secret::text("test-secret-data");
        let dbus_secret = dbus::api::DBusSecret::new(Arc::clone(&setup.session), secret);
        default_collection
            .create_item("Test Item", &[("app", "test")], &dbus_secret, false, None)
            .await?;
        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!(
            default_collection.is_locked().await?,
            "Collection should be locked"
        );
        setup
            .mock_prompter
            .set_password_queue(vec![
                oo7::Secret::from("wrong-password"),
                oo7::Secret::from("wrong-password2"),
                oo7::Secret::from("test-password-long-enough"),
            ])
            .await;
        let unlocked = setup
            .service_api
            .unlock(&[default_collection.inner().path()], None)
            .await?;
        assert_eq!(unlocked.len(), 1, "Should have unlocked 1 collection");
        assert_eq!(
            unlocked[0].as_str(),
            default_collection.inner().path().as_str(),
            "Should return the collection path"
        );
        assert!(
            !default_collection.is_locked().await?,
            "Collection should be unlocked after retry with correct password"
        );
        Ok(())
    }
    #[tokio::test]
    async fn locked_collection_operations() -> Result<(), Box<dyn std::error::Error>> {
        let setup = TestServiceSetup::plain_session(true).await?;
        // Verify collection is unlocked initially
        assert!(
            !setup.collections[0].is_locked().await?,
            "Collection should start unlocked"
        );
        // Lock the collection
        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?;
        // Verify collection is now locked
        assert!(
            setup.collections[0].is_locked().await?,
            "Collection should be locked"
        );
        // Test 1: set_label should fail with IsLocked
        let result = setup.collections[0].set_label("New Label").await;
        assert!(
            matches!(result, Err(oo7::dbus::Error::ZBus(zbus::Error::FDO(_)))),
            "set_label should fail with IsLocked error, got: {:?}",
            result
        );
        // Verify read-only operations still work on locked collections
        assert!(
            setup.collections[0].label().await.is_ok(),
            "Should be able to read label of locked collection"
        );
        let items = setup.collections[0].items().await?;
        assert!(
            items.is_empty(),
            "Should be able to read items (empty) from locked collection"
        );
        Ok(())
    }
}