Skip to content

Kotlin/Swift local date#1154

Open
david-livefront wants to merge 2 commits into
mainfrom
kotlin-lacal-date
Open

Kotlin/Swift local date#1154
david-livefront wants to merge 2 commits into
mainfrom
kotlin-lacal-date

Conversation

@david-livefront

@david-livefront david-livefront commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-38604

📔 Objective

This is my first SDK PR and Claude helped me manage a lot of the heavy lifting, please let me know if I am properly honoring the best practices.

This PR updates the date values in the SDK for some of the new cipher types to utilize the Rust NaiveDate and translate it to a Java LocalDate.

🚨 Breaking Changes

This will create a breaking change for the Kotlin output of the SDK where previously certain date Strings will now be converted to the type-safe LocalDate class. This is required to enforce a consistent date format that can be parsed an utilized properly across the platforms.

@david-livefront david-livefront requested review from a team as code owners June 1, 2026 21:54
@david-livefront david-livefront added the ai-review-vnext Request a Claude code review using the vNext workflow label Jun 1, 2026
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Reviewed the change migrating passport and drivers-license date fields from Option<String> to Option<NaiveDate>, including the new UniFFI NaiveDate custom type (Kotlin LocalDate, Swift Date via the hand-written NaiveDateFormatter), the WASM NaiveDate TypeScript alias, and the encrypt/decrypt plus blob conversion paths. The stored *DataV1 blob and Passport/DriversLicense structs remain string-based, so the encrypted wire format is unchanged and existing ciphertext stays decryptable. The primary outstanding concern is silent data loss for stored dates that are not valid ISO 8601, which is already tracked in an open review thread.

Code Review Details
  • ⚠️ : Silent data loss when a decrypted/stored date string is not valid ISO 8601 — .and_then(|s: String| s.parse().ok()) discards the value, and a subsequent save persists None, permanently losing the original date. Affects date_of_birth, issue_date, expiration_date in both Passport/DriversLicense decrypt and the *DataV1View conversions. Already tracked in an existing unresolved thread on crates/bitwarden-vault/src/cipher/passport.rs.

No new inline findings were posted; the substantive issue above is already captured in an open thread and is not duplicated here.

Comment on lines +91 to +96
date_of_birth: self
.date_of_birth
.decrypt(ctx, key)
.ok()
.flatten()
.and_then(|s: String| s.parse().ok()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QUESTION: Silent data loss when the decrypted date string is not a valid ISO 8601 date.

Details

.and_then(|s: String| s.parse().ok()) discards the value when parsing fails, so any existing encrypted data containing a non-NaiveDate-parseable string (e.g. "06/15/1985", "June 15, 1990", an empty string, or any locale-specific format a prior client may have stored before this type-safety pass) decrypts successfully but appears as None in the PassportView. The encrypted string is still on the server, but the user-facing field silently empties.

This same pattern repeats for issue_date (109-114) and expiration_date (115-120), and again in DriversLicense::decrypt and the blob From<&PassportDataV1> / From<&DriversLicenseDataV1> conversions.

Given these cipher types are recent (commit e504da35) and clients have not yet shipped UIs that write to them, real-world impact is likely small — but two questions are worth confirming before merge:

  1. Is silent coercion to None on parse failure the desired behavior, or should decrypt surface a CryptoError (or at minimum emit a tracing::warn!) so non-ISO data is observable rather than disappearing?
  2. The existing test_recorded_*_test_vector tests assert backwards-compat for the encrypted-string format, but they only cover values that already happen to be ISO. Worth a unit test asserting the chosen behavior for a non-parseable decrypted string so the contract is locked in.

@sonarqubecloud

sonarqubecloud Bot commented Jun 1, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🔍 SDK Breaking Change Detection

SDK Version: kotlin-lacal-date (afce2d3)

⚠️ If breaking changes are detected, a corresponding pull request addressing them must be ready for merge in the affected client repository.

Client Status Details
android ❌ Breaking changes detected Compilation failed with new SDK version. A corresponding pull request addressing the breaking changes must be ready for merge in bitwarden/android. - View Details
typescript ❌ Breaking changes detected Compilation failed with new SDK version. A corresponding pull request addressing the breaking changes must be ready for merge in bitwarden/clients. - View Details

Breaking change detection uses the build of the SDK from this branch, including any incompatibities pre-existing on or merged into this branch. Check the workflow logs to confirm.
Results update as workflows complete.

@codecov

codecov Bot commented Jun 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.22481% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 84.14%. Comparing base (7bbd062) to head (8605774).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
crates/bitwarden-core/src/uniffi_support.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1154      +/-   ##
==========================================
+ Coverage   84.12%   84.14%   +0.02%     
==========================================
  Files         446      446              
  Lines       58817    58912      +95     
==========================================
+ Hits        49478    49572      +94     
- Misses       9339     9340       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coroiu coroiu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether NaiveData is the correct usage here or not will have to be up to Vault (my understanding is that timezone data will be lost), but the bigger hurdle for this PR is WASM support. You'll have to confirm what type of object NaiveDate is translated into on the TypeScript side and then add a custom typescript section for it, similar to how DateTime looks (search for it in the code to find where to put it:

/**
 * RFC3339 compliant date-time string.
 * @typeParam T - Not used in JavaScript.
 */
export type DateTime<T = unknown> = string;

Most of the changes seem to be within Vaults domain so make sure to reach out to them for help and guidance!

@nick-livefront nick-livefront left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether NaiveData is the correct usage here or not will have to be up to Vault (my understanding is that timezone data will be lost)

These specific dates do not have timezone data, they're only YYYY-MM-DD dates.

I do agree with the custom types approach that @coroiu suggested as the NaiveDate gets converted to a string on the TS side. The other option I considered was adding #[cfg_attr(feature = "wasm", tsify(type = "string"))] to each property but that would be tedious to maintain.

I'll send this to our internal channel to see if there are any other opinions but I think you could continue with the custom type.

@david-livefront david-livefront changed the title Kotlin lacal date Kotlin local date Jun 2, 2026
@david-livefront david-livefront force-pushed the kotlin-lacal-date branch 2 times, most recently from 942c74a to 8605774 Compare June 2, 2026 20:45
@jengstrom-bw jengstrom-bw removed their request for review June 22, 2026 16:10
@david-livefront david-livefront force-pushed the kotlin-lacal-date branch 2 times, most recently from 507c348 to fbfb3e5 Compare July 1, 2026 22:01
Comment thread crates/bitwarden-core/uniffi.toml Outdated
Comment on lines +23 to +33
into_custom = """
{
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = "yyyy-MM-dd"
formatter.isLenient = false
return formatter.date(from: {})
}()
"""
from_custom = """

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Swift into_custom is not a valid single expression and will break the Swift build.

Details and fix

UniFFI substitutes into_custom as the right-hand side of an expression that must yield a single Date. This template expands to two independent, unconnected expressions:

{
    let formatter = DateFormatter()
    ...
    formatter.date(from: <val>)   // Date? computed then discarded
}()
ISO8601DateFormatter().date(from: <val> + "T00:00:00Z")!   // second, separate expression

The immediately-invoked closure's result is discarded, and it is followed by a second standalone expression with no operator joining them — this is not a valid single Swift expression and will fail to compile in the generated BitwardenSdk sources built by build-swift.yml on every PR. It looks like two candidate implementations were accidentally left in.

Additionally, the trailing ! force-unwrap will crash at runtime if the stored string is not a parseable date (existing encrypted data may contain arbitrary strings).

Pick one implementation and return a single value without force-unwrapping, e.g.:

into_custom = """
{
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.timeZone = TimeZone(identifier: "UTC")
    formatter.dateFormat = "yyyy-MM-dd"
    formatter.isLenient = false
    return formatter.date(from: {}) ?? Date(timeIntervalSince1970: 0)
}()
"""

(choose an appropriate fallback for unparseable input rather than crashing).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude this has been addressed

@claude claude Bot Jul 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @david-livefront's task in 1m 22s —— View job


Re: Swift into_custom at uniffi.toml:33

Thanks @david-livefront — I've re-read the current uniffi.toml. The original blocker is resolved: the two accidental candidate implementations are gone, and into_custom/from_custom are now each a single, valid IIFE expression. 👍

However, one part of the earlier soundness concern still stands, so I don't think the Swift build will pass yet:

into_custom returns Date? where a non-optional Date is required.

{
    let formatter = DateFormatter()
    ...
    return formatter.date(from: {})   // ← DateFormatter.date(from:) is Date?
}()

UniFFI renders into_custom as the return value of a converter function whose return type is type_name = "Date" (non-optional). Since DateFormatter.date(from:) yields Date?, the generated BitwardenCoreFFI sources will fail to compile with a "cannot convert value of type Date? to expected type Date" error — which build-swift.yml runs on every PR.

In practice the Rust side always lowers via obj.to_string(), so the string handed to into_custom is always a valid yyyy-MM-dd. That means a fallback is only there to satisfy the type checker and will never realistically be hit — so a nil-coalesce is clean and avoids a force-unwrap crash:

into_custom = """
{
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.timeZone = TimeZone(identifier: "UTC")
    formatter.dateFormat = "yyyy-MM-dd"
    formatter.isLenient = false
    return formatter.date(from: {}) ?? Date(timeIntervalSince1970: 0)
}()
"""

Fix this →


For completeness on the other two threads from earlier:

  • WASM/TypeScript (@coroiu's point): resolved — NaiveDate is now declared as a custom TS type in custom_types.rs:24.
  • Silent data-loss on non-ISO decrypt (passport.rs / drivers_license.rs): still open for Vault to weigh in on, but as noted, real-world impact is likely small given these cipher types are new.

Want me to push the one-line Swift fix to this branch?

@david-livefront david-livefront changed the title Kotlin local date Kotlin/Swift local date Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow breaking-change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants