use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; mod audit; mod merkle; mod reconciliation; mod roster; pub(crate) use merkle::{ManifestMerkleIndex, ManifestMerkleNode, manifest_root_hash}; #[cfg(test)] pub(crate) use reconciliation::{ClockOrder, compare_version_clocks}; pub(crate) use reconciliation::{ReconciliationAction, is_conflict_artifact, reconcile_manifests}; pub(crate) use roster::{FolderRoster, MemberRole, RosterMember}; pub type DeviceId = String; pub type FolderId = Uuid; pub type BlobHash = String; pub type RelativePath = String; pub type VersionClock = BTreeMap; /// Version of the signed folder roster and manifest model. pub const FOLDER_PROTOCOL_VERSION: u16 = 2; /// Encode data used in signed payloads and Merkle hashes. /// /// Appa's protocol types use declaration-ordered structs and `BTreeMap` for /// every map in these payloads. Changing either representation changes the /// bytes, so it requires a matching protocol version bump. pub(crate) fn canonical_json_bytes(value: &T) -> anyhow::Result> { Ok(serde_json::to_vec(value)?) } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FolderMode { #[default] SendReceive, SendOnly, ReceiveOnly, } impl FolderMode { pub fn can_send(self) -> bool { self != Self::ReceiveOnly } pub fn can_receive(self) -> bool { self != Self::SendOnly } } #[cfg(test)] mod folder_mode_tests { use super::FolderMode; #[test] fn exposes_the_expected_send_and_receive_permissions() { assert!(FolderMode::SendReceive.can_send()); assert!(FolderMode::SendReceive.can_receive()); assert!(FolderMode::SendOnly.can_send()); assert!(!FolderMode::SendOnly.can_receive()); assert!(!FolderMode::ReceiveOnly.can_send()); assert!(FolderMode::ReceiveOnly.can_receive()); } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum EntryKind { File, Directory, Deleted, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Entry { pub path: RelativePath, pub kind: EntryKind, pub blob_hash: Option, pub size_bytes: Option, pub modified_at: OffsetDateTime, pub clock: VersionClock, pub author_device_id: DeviceId, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Manifest { pub folder_id: FolderId, pub entries: BTreeMap, } impl Manifest { pub fn empty(folder_id: FolderId) -> Self { Self { folder_id, entries: BTreeMap::new(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn accepts_an_owner_signed_roster() -> anyhow::Result<()> { let owner_identity = iroh::SecretKey::generate(); let owner = RosterMember { device_id: owner_identity.public().to_string(), display_name: Some("desktop".to_owned()), role: MemberRole::Owner, }; let roster = FolderRoster::create(Uuid::new_v4(), "secret".to_owned(), owner)? .sign(&owner_identity)?; roster.validate() } #[test] fn advances_the_roster_epoch_when_a_member_joins() -> anyhow::Result<()> { let owner_identity = iroh::SecretKey::generate(); let owner = RosterMember { device_id: owner_identity.public().to_string(), display_name: None, role: MemberRole::Owner, }; let mut roster = FolderRoster::create(Uuid::new_v4(), "secret".to_owned(), owner)?; let member_identity = iroh::SecretKey::generate(); let roster = roster .add_member(RosterMember { device_id: member_identity.public().to_string(), display_name: Some("laptop".to_owned()), role: MemberRole::Member, })? .sign(&owner_identity)?; assert_eq!(roster.epoch, 2); assert!(roster.contains_member(&member_identity.public().to_string())); roster.validate() } #[test] fn prevents_read_only_members_from_publishing_changes() -> anyhow::Result<()> { let owner_identity = iroh::SecretKey::generate(); let read_only_identity = iroh::SecretKey::generate(); let roster = FolderRoster::create( Uuid::new_v4(), "secret".to_owned(), RosterMember { device_id: owner_identity.public().to_string(), display_name: None, role: MemberRole::Owner, }, )?; let roster = roster.add_member(RosterMember { device_id: read_only_identity.public().to_string(), display_name: None, role: MemberRole::ReadOnly, })?; assert!(!roster.can_publish(&read_only_identity.public().to_string())); assert!(roster.can_publish(&owner_identity.public().to_string())); Ok(()) } #[test] fn rejects_a_tampered_roster() -> anyhow::Result<()> { let owner_identity = iroh::SecretKey::generate(); let owner = RosterMember { device_id: owner_identity.public().to_string(), display_name: None, role: MemberRole::Owner, }; let mut roster = FolderRoster::create(Uuid::new_v4(), "secret".to_owned(), owner)? .sign(&owner_identity)?; roster.epoch += 1; assert!(roster.validate().is_err()); Ok(()) } fn entry(path: &str, author: &str, counter: u64, hash: &str) -> Entry { Entry { path: path.to_owned(), kind: EntryKind::File, blob_hash: Some(hash.to_owned()), size_bytes: Some(1), modified_at: OffsetDateTime::UNIX_EPOCH, clock: BTreeMap::from([(author.to_owned(), counter)]), author_device_id: author.to_owned(), } } #[test] fn detects_concurrent_clocks() { let left = BTreeMap::from([("first".to_owned(), 2), ("second".to_owned(), 1)]); let right = BTreeMap::from([("first".to_owned(), 1), ("second".to_owned(), 2)]); assert_eq!( compare_version_clocks(&left, &right), ClockOrder::Concurrent ); } #[test] fn applies_a_newer_tombstone() { let folder_id = Uuid::new_v4(); let local = Manifest { folder_id, entries: BTreeMap::from([("note.txt".to_owned(), entry("note.txt", "a", 1, "one"))]), }; let mut tombstone = entry("note.txt", "a", 2, ""); tombstone.kind = EntryKind::Deleted; tombstone.blob_hash = None; let remote = Manifest { folder_id, entries: BTreeMap::from([("note.txt".to_owned(), tombstone.clone())]), }; assert_eq!( reconcile_manifests(&local, &remote), vec![ReconciliationAction::ApplyRemote { entry: tombstone }] ); } #[test] fn keeps_a_concurrent_local_tombstone() { let folder_id = Uuid::new_v4(); let mut local_tombstone = entry("movie.mkv", "delete-device", 2, ""); local_tombstone.kind = EntryKind::Deleted; local_tombstone.blob_hash = None; local_tombstone.clock.insert("edit-device".to_owned(), 1); let mut remote_file = entry("movie.mkv", "edit-device", 2, "movie-hash"); remote_file.clock.insert("delete-device".to_owned(), 1); let local = Manifest { folder_id, entries: BTreeMap::from([("movie.mkv".to_owned(), local_tombstone.clone())]), }; let remote = Manifest { folder_id, entries: BTreeMap::from([("movie.mkv".to_owned(), remote_file)]), }; assert_eq!( reconcile_manifests(&local, &remote), vec![ReconciliationAction::KeepLocal { entry: local_tombstone, }] ); } #[test] fn names_conflicts_deterministically() { let folder_id = Uuid::new_v4(); let local_entry = entry("docs/note.txt", "alpha-device", 1, "one"); let remote_entry = entry("docs/note.txt", "beta-device", 1, "two"); let local = Manifest { folder_id, entries: BTreeMap::from([(local_entry.path.clone(), local_entry)]), }; let remote = Manifest { folder_id, entries: BTreeMap::from([(remote_entry.path.clone(), remote_entry)]), }; assert!(matches!( &reconcile_manifests(&local, &remote)[0], ReconciliationAction::ResolveConflict { conflict_path, .. } if conflict_path == "docs/note (conflict-beta-dev-two).txt" )); } #[test] fn resolves_concurrent_conflict_artifacts_without_nesting() { let folder_id = Uuid::new_v4(); let local_entry = entry("note (conflict-alpha-one).txt", "alpha-device", 1, "one"); let expected_entry = local_entry.clone(); let remote_entry = entry("note (conflict-alpha-one).txt", "beta-device", 1, "two"); let local = Manifest { folder_id, entries: BTreeMap::from([(local_entry.path.clone(), local_entry)]), }; let remote = Manifest { folder_id, entries: BTreeMap::from([(remote_entry.path.clone(), remote_entry.clone())]), }; assert_eq!( reconcile_manifests(&local, &remote), vec![ReconciliationAction::ApplyRemote { entry: expected_entry }] ); } } pub(crate) use audit::{AuditEvent, AuditEventKind};