refactor(core): remove experimental saved device contract

This commit is contained in:
2026-08-12 17:00:28 +02:00
parent 5b57917f52
commit 2c941dc623
20 changed files with 46 additions and 162 deletions

View File

@@ -54,7 +54,7 @@ src/
receive.rs # receive, download, export, OutputSinkFile
lifecycle.rs # cancel share, delete, status, access mode, shutdown
provider.rs # provider messages, per-peer transfer progress
saved_devices.rs # experimental saved-device pairing, forget, block
saved_devices.rs # saved-device pairing, forget, block
targeted.rs # saved-device targeted transfers
persistence.rs # AppDataStores / persistence open (domain stores)
invitation/ # invitation-transfer domain store (type name: Repository)

View File

@@ -42,7 +42,7 @@ bytes through Kotlin memory.
`stopped`.
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`,
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`,
plus experimental `pairing` and `targeted_transfer` (see catalog below).
`pairing`, and `targeted_transfer` (see catalog below).
- Events are sent to `CoreEventSink` immediately and persisted through the event
hub. `list_events` flushes queued persistence before reading SQLite.
- `shutdown()` is idempotent and flushes events before stopping the router.

View File

@@ -1,5 +1,3 @@
#![allow(deprecated)]
use anyhow::Context;
use iroh::RelayUrl;
use iroh_blobs::Hash;
@@ -29,25 +27,6 @@ pub fn saved_device_capabilities() -> SavedDeviceCapabilities {
}
}
#[deprecated(note = "use SavedDeviceCapabilities")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct ExperimentalSavedDeviceCapabilities {
pub domain_contract_version: u16,
pub relationship_protocol_version: u16,
pub targeted_transfer_protocol_version: u16,
}
#[deprecated(note = "use saved_device_capabilities")]
#[uniffi::export]
pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabilities {
let capabilities = saved_device_capabilities();
ExperimentalSavedDeviceCapabilities {
domain_contract_version: capabilities.domain_contract_version,
relationship_protocol_version: capabilities.relationship_protocol_version,
targeted_transfer_protocol_version: capabilities.targeted_transfer_protocol_version,
}
}
/// Public view of a single-use pairing window after a completed transfer.
///
/// The eligibility capability itself never crosses this boundary.

View File

@@ -1,4 +1,4 @@
//! Experimental saved-device mutual-consent relationships.
//! Saved-device mutual-consent relationships.
//!
//! Pending outgoing/incoming states, directional grants bound to relationship
//! generation, and Saved only after mutual acknowledgement.

View File

@@ -14,8 +14,6 @@ mod logging;
mod pairing_eligibility;
mod persistence;
mod runtime;
#[cfg(test)]
mod secret;
#[allow(
dead_code,
reason = "the private custody seam is activated by platform credential adapters"
@@ -26,15 +24,13 @@ mod ticket;
mod transfer_state;
mod util;
#[allow(deprecated)]
pub use api::{
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
experimental_saved_device_capabilities, saved_device_capabilities, CoreEvent, CoreEventSink,
CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship,
DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary,
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice,
SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
saved_device_capabilities, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
CoreRelayMode, CoreStorageUsage, DeviceRelationship, DeviceRelationshipState,
PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink,
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferState,
TicketInspection, TransferAccessMode, TransferMetadata,
};

View File

@@ -1,5 +1,3 @@
#![allow(deprecated)]
use std::{future::Future, path::PathBuf, sync::Arc};
use anyhow::Context;
@@ -317,7 +315,6 @@ impl VnidropCore {
}
}
#[allow(deprecated)]
#[uniffi::export]
impl VnidropCore {
#[uniffi::constructor]
@@ -371,18 +368,6 @@ impl VnidropCore {
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
}
/// Compatibility constructor retained during the protected-initialization expand phase.
#[deprecated(note = "use initialize_with_limits_and_network_config")]
#[uniffi::constructor]
pub fn initialize_with_experimental_saved_devices(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
}
pub fn status(&self) -> RuntimeStatus {
self.block_on(self.inner.status())
}

View File

@@ -6,7 +6,7 @@
//! - [`receive`] — ticket receive, download, export
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
//! - [`provider`] — blob provider events and per-connection send progress
//! - [`saved_devices`] — experimental saved-device pairing, forget, block
//! - [`saved_devices`] — saved-device pairing, forget, block
//! - [`targeted`] — saved-device targeted transfers
mod delivery;

View File

@@ -1,4 +1,4 @@
//! Runtime operations for experimental saved devices and device relationships.
//! Runtime operations for saved devices and device relationships.
use std::sync::Arc;

View File

@@ -1,71 +0,0 @@
use std::{
fs::OpenOptions,
io::{self, Write},
path::Path,
str::FromStr,
};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use anyhow::{Context, Result};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
pub(crate) async fn load_or_create_secret(app_data_dir: &Path) -> Result<SecretKey> {
if let Ok(secret) = std::env::var("IROH_SECRET") {
return SecretKey::from_str(&secret).context("invalid IROH_SECRET");
}
let path = app_data_dir.join("iroh.secret");
match tokio::fs::read_to_string(&path).await {
Ok(secret) => {
let bytes = HEXLOWER
.decode(secret.trim().as_bytes())
.context("invalid persisted iroh secret encoding")?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("invalid persisted iroh secret length"))?;
restrict_permissions(&path).await?;
Ok(SecretKey::from_bytes(&bytes))
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let secret = SecretKey::generate();
let encoded = HEXLOWER.encode(&secret.to_bytes());
// Create with owner-only mode on Unix so the key is never briefly 0644.
write_secret_file(&path, encoded.as_bytes()).await?;
restrict_permissions(&path).await?;
Ok(secret)
}
Err(error) => Err(error.into()),
}
}
async fn write_secret_file(path: &Path, bytes: &[u8]) -> Result<()> {
let path = path.to_path_buf();
let bytes = bytes.to_vec();
tokio::task::spawn_blocking(move || {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(&path)
.with_context(|| format!("failed to create {}", path.display()))?;
file.write_all(&bytes)
.with_context(|| format!("failed to write {}", path.display()))?;
file.sync_all()
.with_context(|| format!("failed to sync {}", path.display()))?;
Ok::<(), anyhow::Error>(())
})
.await?
}
async fn restrict_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?;
// Windows: file lives under the user profile app-data dir with default ACLs
// limited to the current user. No portable owner-only API in std.
let _ = path;
Ok(())
}

View File

@@ -37,8 +37,6 @@ mod platform_contract_windows_tests;
mod repository_tests;
#[path = "tests/runtime.rs"]
mod runtime_tests;
#[path = "tests/secret.rs"]
mod secret_tests;
#[path = "tests/secure_secret_android.rs"]
mod secure_secret_android_tests;
#[cfg(any(target_os = "macos", target_os = "ios"))]

View File

@@ -10,6 +10,9 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
let lib = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs"));
for forbidden in [
"initialize_with_experimental_saved_devices",
"experimental_saved_device_capabilities",
"ExperimentalSavedDeviceCapabilities",
"fn list_contacts(",
"fn send_to_contact(",
"fn poll_contacts_for_offers(",
@@ -58,7 +61,6 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
"fn forget_saved_device(",
"fn share_files(",
"fn receive(",
"experimental_saved_device_capabilities",
"saved_device_capabilities",
] {
assert!(

View File

@@ -1,4 +1,4 @@
//! Android platform contract for the experimental saved-device foundation.
//! Android platform contract for the saved-device foundation.
//!
//! These tests drive the public UniFFI surface through an
//! [`AndroidSecureSecretStore`] backed by an in-process Keystore fake so the
@@ -929,6 +929,9 @@ fn android_public_surface_omits_raw_secrets_and_generic_mutation() {
path.display()
);
}
assert!(!kotlin.contains("initializeWithExperimentalSavedDevices"));
assert!(!kotlin.contains("ExperimentalSavedDeviceCapabilities"));
assert!(!kotlin.contains("experimentalSavedDeviceCapabilities"));
assert!(
kotlin.contains("initializeWithLimitsAndNetworkConfig"),
"production protected Android init must remain on the public binding surface"

View File

@@ -53,7 +53,7 @@ impl RecordingSink {
/// `AppleKeychainSecretStore` + profile scoping. CLI unit tests lack the app
/// Keychain entitlement, so the system Keychain returns Unavailable; the
/// injectable API exercises the identical adapter path. Swift XCTest covers
/// the real experimental constructor under the app entitlements.
/// the standard protected constructor under the app entitlements.
struct KeychainNode {
data_dir: tempfile::TempDir,
api: RecordingKeychain,
@@ -208,7 +208,7 @@ impl Drop for KeychainNode {
}
}
fn try_experimental_keychain_init(app_data_dir: &Path) -> Result<Arc<VnidropCore>, VnidropError> {
fn try_keychain_init(app_data_dir: &Path) -> Result<Arc<VnidropCore>, VnidropError> {
VnidropCore::initialize_with_limits_and_network_config(
app_data_dir.to_string_lossy().into_owned(),
Arc::new(RecordingSink {
@@ -593,7 +593,7 @@ fn recover_authoritative_state(
}
#[test]
fn experimental_keychain_identity_survives_core_restart() {
fn keychain_identity_survives_core_restart() {
let node = KeychainNode::new();
let endpoint_id = node.core().status().endpoint_id.clone();
assert!(!endpoint_id.is_empty());
@@ -610,18 +610,18 @@ fn experimental_keychain_identity_survives_core_restart() {
// constructor must also preserve identity. Headless cargo often lacks that
// entitlement and maps it to Unavailable — that path is covered by Swift.
let live = tempfile::tempdir().unwrap();
match try_experimental_keychain_init(live.path()) {
match try_keychain_init(live.path()) {
Ok(core) => {
let id = core.status().endpoint_id.clone();
core.shutdown();
drop(core);
let restarted = try_experimental_keychain_init(live.path()).expect("restart");
let restarted = try_keychain_init(live.path()).expect("restart");
assert_eq!(restarted.status().endpoint_id, id);
restarted.shutdown();
cleanup_scoped_keychain(live.path());
}
Err(VnidropError::SecureStorageUnavailable { .. }) => {}
Err(error) => panic!("unexpected experimental init failure: {error:?}"),
Err(error) => panic!("unexpected protected init failure: {error:?}"),
}
}
@@ -840,6 +840,9 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
swift.display()
);
}
assert!(!source.contains("initializeWithExperimentalSavedDevices"));
assert!(!source.contains("ExperimentalSavedDeviceCapabilities"));
assert!(!source.contains("experimentalSavedDeviceCapabilities"));
assert!(
source.contains("initializeWithLimitsAndNetworkConfig"),
"Swift bindings must expose standard protected initialization"
@@ -865,6 +868,8 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
)
.expect("api.rs");
for forbidden in [
"ExperimentalSavedDeviceCapabilities",
"experimental_saved_device_capabilities",
"SecretMaterial",
"SecretHandle",
"SecureSecretStore",

View File

@@ -546,7 +546,7 @@ fn secret_service_identity_survives_core_restart() {
#[cfg(target_os = "linux")]
#[test]
fn experimental_secret_service_identity_survives_core_restart_on_linux() {
fn secret_service_identity_survives_core_restart_on_linux() {
// Regression: protected init used to call blocking Secret Service on the
// Tokio worker that drives `CoreInner::start`, which nested `block_on` and
// aborted desktop startup with "Cannot start a runtime from within a runtime".
@@ -560,7 +560,7 @@ fn experimental_secret_service_identity_survives_core_restart_on_linux() {
CoreLimits::default(),
CoreNetworkConfig::default(),
)
.expect("experimental Linux Secret Service core");
.expect("Linux Secret Service core");
let endpoint_id = core.status().endpoint_id.clone();
assert!(!endpoint_id.is_empty());
assert!(!data_dir.path().join("iroh.secret").exists());
@@ -585,7 +585,7 @@ fn experimental_secret_service_identity_survives_core_restart_on_linux() {
{
std::thread::sleep(Duration::from_millis(25));
}
Err(error) => panic!("restart experimental Linux core: {error:?}"),
Err(error) => panic!("restart Linux core: {error:?}"),
}
};
assert_eq!(restarted.status().endpoint_id, endpoint_id);
@@ -842,7 +842,7 @@ fn linux_public_bindings_omit_raw_secrets_and_generic_mutation() {
}
assert!(
public_facade.contains("initialize_with_limits_and_network_config"),
"facade must expose experimental saved-device init"
"facade must expose standard protected initialization"
);
assert!(
!public_facade.contains("fn set_state(") && !public_facade.contains("fn mutate_state("),

View File

@@ -315,7 +315,7 @@ fn windows_dpapi_identity_survives_core_restart() {
#[cfg(target_os = "windows")]
#[test]
fn real_windows_dpapi_experimental_init_preserves_identity() {
fn real_windows_dpapi_init_preserves_identity() {
let data_dir = tempfile::tempdir().unwrap();
let path = data_dir.path().to_string_lossy().into_owned();
let sink = Arc::new(RecordingSink {
@@ -327,7 +327,7 @@ fn real_windows_dpapi_experimental_init_preserves_identity() {
CoreLimits::default(),
CoreNetworkConfig::default(),
)
.expect("experimental windows core");
.expect("Windows core");
let first = core.status().endpoint_id.clone();
core.shutdown();
drop(core);
@@ -341,7 +341,7 @@ fn real_windows_dpapi_experimental_init_preserves_identity() {
CoreLimits::default(),
CoreNetworkConfig::default(),
)
.expect("restarted experimental windows core");
.expect("restarted Windows core");
assert_eq!(restarted.status().endpoint_id, first);
restarted.shutdown();
}

View File

@@ -1,22 +0,0 @@
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::secret::load_or_create_secret;
#[tokio::test]
async fn persists_with_restricted_permissions() {
let temp = tempfile::tempdir().unwrap();
let first = load_or_create_secret(temp.path()).await.unwrap();
let second = load_or_create_secret(temp.path()).await.unwrap();
assert_eq!(first.to_bytes(), second.to_bytes());
#[cfg(unix)]
assert_eq!(
std::fs::metadata(temp.path().join("iroh.secret"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}

View File

@@ -8,7 +8,7 @@ use vnidrop::{
};
#[test]
fn saved_device_protocols_are_explicitly_experimental_and_versioned() {
fn saved_device_protocols_are_explicitly_versioned() {
assert_eq!(
saved_device_capabilities(),
SavedDeviceCapabilities {
@@ -97,7 +97,7 @@ fn targeted_transfer_transitions_are_validated_by_the_domain() {
}
#[test]
fn experimental_domain_seam_does_not_change_multi_receiver_shares() {
fn saved_device_domain_seam_does_not_change_multi_receiver_shares() {
let source_dir = tempfile::tempdir().unwrap();
let first_output = tempfile::tempdir().unwrap();
let second_output = tempfile::tempdir().unwrap();