diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 2b51fd0c..0b37d8e7 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -25,7 +25,7 @@ use crate::{ MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, }, - util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, + util::{NumberOrString, deser_double_opt_str, deser_opt_nonempty_str, save_temp_file}, }; use super::folders::FolderData; @@ -297,7 +297,9 @@ pub struct CipherData { // when using older client versions, or if the operation doesn't involve // updating an existing cipher. last_known_revision_date: Option, - archived_date: Option, + // Absent = leave archive state unchanged; null = unarchive; string = set archived date. + #[serde(default, deserialize_with = "deser_double_opt_str")] + archived_date: Option>, } #[derive(Debug, Deserialize)] @@ -537,11 +539,17 @@ pub async fn update_cipher_from_data( cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?; cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?; - if let Some(dt_str) = data.archived_date { - match NaiveDateTime::parse_from_str(&dt_str, "%+") { + match &data.archived_date { + // Field omitted: leave archive state unchanged. + None => {} + // Explicit JSON null: unarchive (matches Bitwarden cloud). + Some(None) => { + cipher.unarchive(&headers.user.uuid, conn).await?; + } + Some(Some(dt_str)) => match NaiveDateTime::parse_from_str(dt_str, "%+") { Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?, Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"), - } + }, } if ut != UpdateType::None { diff --git a/src/util.rs b/src/util.rs index 0e8a93e4..5935a44a 100644 --- a/src/util.rs +++ b/src/util.rs @@ -680,6 +680,100 @@ where })) } +/// Deserialize a field that can be absent, JSON `null`, or a string. +/// +/// - Field absent → `None` (caller should leave the existing value unchanged) +/// - JSON `null` → `Some(None)` (caller should clear the value) +/// - JSON string → `Some(Some(value))` +/// +/// Needed because plain `Option` collapses both absent and `null` into `None`. +pub fn deser_double_opt_str<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + use serde::Deserialize; + use serde::de::{self, Visitor}; + use std::fmt; + + struct DoubleOptStrVisitor; + + impl<'de> Visitor<'de> for DoubleOptStrVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a string or null") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(Some(Some(value.to_owned()))) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + Ok(Some(Some(value))) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(Some(None)) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(Some(None)) + } + + fn visit_some(self, deserializer: D2) -> Result + where + D2: Deserializer<'de>, + { + Ok(Some(Some(String::deserialize(deserializer)?))) + } + } + + deserializer.deserialize_option(DoubleOptStrVisitor) +} + +#[cfg(test)] +mod double_opt_str_tests { + use super::deser_double_opt_str; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + struct Sample { + #[serde(default, deserialize_with = "deser_double_opt_str")] + archived_date: Option>, + } + + #[test] + fn absent_field_means_no_change() { + let parsed: Sample = serde_json::from_str(r#"{"other":1}"#).unwrap(); + assert_eq!(parsed.archived_date, None); + } + + #[test] + fn null_field_means_clear() { + let parsed: Sample = serde_json::from_str(r#"{"archivedDate":null}"#).unwrap(); + assert_eq!(parsed.archived_date, Some(None)); + } + + #[test] + fn string_field_means_set() { + let parsed: Sample = serde_json::from_str(r#"{"archivedDate":"2026-08-11T12:00:00.000Z"}"#).unwrap(); + assert_eq!(parsed.archived_date, Some(Some("2026-08-11T12:00:00.000Z".to_string()))); + } +} + #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] pub enum NumberOrString {