From 5154b65f20671d4b1043d3781870de94985ec351 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Thu, 22 Oct 2026 13:28:48 +0500 Subject: [PATCH 1/3] feat(core): model scoped checkout binding and Git write admission --- crates/domain/src/lib.rs | 3 + crates/domain/src/repositories/workspace.rs | 148 +++++++++++++++++- crates/domain/src/state.rs | 13 +- crates/domain/tests/repository_workspace.rs | 162 ++++++++++++++++++++ standarts.lock | 2 +- 5 files changed, 325 insertions(+), 3 deletions(-) diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 3718a95..c75129d 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -119,6 +119,9 @@ pub enum Permission { LocalNetwork, UserService, Clipboard, + /// Bounded Git checkout changes and existing-commit synchronization. + /// Catalog reading and generic process execution never imply this grant. + GitWrite, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] diff --git a/crates/domain/src/repositories/workspace.rs b/crates/domain/src/repositories/workspace.rs index 65e1474..85dfc20 100644 --- a/crates/domain/src/repositories/workspace.rs +++ b/crates/domain/src/repositories/workspace.rs @@ -2,7 +2,7 @@ //! Paths are relative to the installation's authorized Developer/nddev root. //! These observations never grant filesystem, credential or Git authority. use super::{BranchComparison, Changes, HeadKind, LocalGitStatus, catalog::*}; -use crate::modules::ModuleError; +use crate::{Permission, modules::ModuleError}; use serde::{Deserialize, Serialize}; /// Stable provider identity is distinct from a mutable, portable locator. @@ -49,6 +49,152 @@ impl CheckoutLocation { pub fn https_url(&self) -> String { format!("https://github.com/{}/{}.git", self.owner, self.repository) } + /// Recognize only explicit GitHub origins. This does not resolve SSH + /// aliases/configuration or authorize that transport: I/O always uses the + /// fixed HTTPS endpoint with its own protected credential. + pub fn matches_origin(&self, origin: &str) -> bool { + let path = origin + .strip_prefix("https://github.com/") + .or_else(|| origin.strip_prefix("git@github.com:")) + .or_else(|| origin.strip_prefix("ssh://git@github.com/")) + .or_else(|| origin.strip_prefix("ssh://git@github.com:22/")); + path.is_some_and(|path| { + path.eq_ignore_ascii_case(&self.relative_path()) + || path.eq_ignore_ascii_case(&format!("{}.git", self.relative_path())) + }) + } +} + +/// A local witness, not an authorization capability. The adapter derives this +/// from its original authenticated NDS scope; it never accepts it from a UI. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspacePrincipal { + pub origin: String, + pub tenant: String, + pub user: String, + pub device: String, +} +impl WorkspacePrincipal { + pub fn validate(&self) -> Result<(), ModuleError> { + if self.origin.is_empty() + || self.origin.len() > 2048 + || !self.origin.bytes().all(|b| b.is_ascii_graphic()) + || [&self.tenant, &self.user, &self.device] + .into_iter() + .any(|id| !crate::valid_identifier(id)) + { + Err(ModuleError::InvalidInput) + } else { + Ok(()) + } + } +} + +/// The encrypted NDS record and local Git marker use the same portable model. +/// No absolute path, credential or provider success is stored in this witness. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CheckoutBinding { + pub id: String, + pub principal: WorkspacePrincipal, + pub location: CheckoutLocation, +} +impl CheckoutBinding { + pub fn validate(&self) -> Result<(), ModuleError> { + self.principal.validate()?; + self.location.validate()?; + if uuid::Uuid::parse_str(&self.id).is_ok_and(|id| !id.is_nil() && id.to_string() == self.id) + { + Ok(()) + } else { + Err(ModuleError::InvalidInput) + } + } + pub fn entity_id(&self) -> String { + format!( + "nds:checkout:{}:{}", + self.principal.device, + self.location.repository_id.value() + ) + } +} + +/// Dedicated bounded Git authority; catalog/observation consent remains smaller. +pub fn write_permissions() -> std::collections::BTreeSet { + std::collections::BTreeSet::from([ + Permission::ReadOnly, + Permission::CredentialStore, + Permission::LocalNetwork, + Permission::GitWrite, + ]) +} + +pub enum CheckoutPresence { + Missing, + Occupied, + Repository { + origin: String, + active_writer: bool, + binding: Option>, + }, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionBlock { + PathOccupied, + WrongRemote, + ActiveWriter, + DifferentAccount, + DifferentRepository, + RelocationRequired, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionDecision { + Clone, + Adopt, + AlreadyConnected, + Blocked(ConnectionBlock), +} +pub fn connection_decision( + principal: &WorkspacePrincipal, + location: &CheckoutLocation, + presence: &CheckoutPresence, +) -> Result { + use ConnectionDecision::*; + principal.validate()?; + location.validate()?; + let CheckoutPresence::Repository { + origin, + active_writer, + binding, + } = presence + else { + return Ok(if matches!(presence, CheckoutPresence::Missing) { + Clone + } else { + Blocked(ConnectionBlock::PathOccupied) + }); + }; + if *active_writer { + return Ok(Blocked(ConnectionBlock::ActiveWriter)); + } + if !location.matches_origin(origin) { + return Ok(Blocked(ConnectionBlock::WrongRemote)); + } + let Some(binding) = binding else { + return Ok(Adopt); + }; + binding.validate()?; + Ok(if binding.principal != *principal { + Blocked(ConnectionBlock::DifferentAccount) + } else if binding.location.repository_id != location.repository_id { + Blocked(ConnectionBlock::DifferentRepository) + } else if binding.location != *location { + Blocked(ConnectionBlock::RelocationRequired) + } else { + AlreadyConnected + }) } fn portable_component(value: &str) -> bool { diff --git a/crates/domain/src/state.rs b/crates/domain/src/state.rs index a1414a9..3e3324a 100644 --- a/crates/domain/src/state.rs +++ b/crates/domain/src/state.rs @@ -26,6 +26,10 @@ pub enum State { Repository { observation: crate::repositories::catalog::observations::RepositoryObservation, }, + /// Device-owned filesystem witness; reading this fact never grants Git I/O. + RepositoryCheckout { + binding: crate::repositories::workspace::CheckoutBinding, + }, Device { display_name: String, platform: String, @@ -86,7 +90,9 @@ impl Snapshot { || self.schema_version != match (&self.data, kind) { ( - State::Repository { .. } | State::GithubSource { .. }, + State::Repository { .. } + | State::GithubSource { .. } + | State::RepositoryCheckout { .. }, EntityKind::RepositoryState, ) | (State::ModuleConfiguration { .. }, EntityKind::ModuleState) => 2, @@ -180,6 +186,11 @@ impl Snapshot { (State::Repository { observation }, EntityKind::RepositoryState) => { observation.validate().is_ok() && observation.key().entity_id() == entity } + (State::RepositoryCheckout { binding }, EntityKind::RepositoryState) => { + binding.validate().is_ok() + && binding.principal.device == writer.as_str() + && binding.entity_id() == entity + } _ => false, }; if valid { diff --git a/crates/domain/tests/repository_workspace.rs b/crates/domain/tests/repository_workspace.rs index 00cddd0..940652b 100644 --- a/crates/domain/tests/repository_workspace.rs +++ b/crates/domain/tests/repository_workspace.rs @@ -27,6 +27,168 @@ fn clean(ahead: u32, behind: u32) -> LocalGitStatus { partial_reasons: vec![], } } +fn principal() -> WorkspacePrincipal { + WorkspacePrincipal { + origin: "https://nds.example.invalid".into(), + tenant: "company".into(), + user: "owner".into(), + device: "device".into(), + } +} +fn binding() -> CheckoutBinding { + CheckoutBinding { + id: "10000000-0000-4000-8000-000000000001".into(), + principal: principal(), + location: CheckoutLocation::from_repository(&repository("owner", "source")).unwrap(), + } +} +#[test] +fn only_explicit_github_origins_match_without_consulting_ambient_ssh_config() { + let location = binding().location; + for origin in [ + "https://github.com/owner/source.git", + "https://github.com/Owner/Source", + "git@github.com:owner/source.git", + "ssh://git@github.com/owner/source.git", + "ssh://git@github.com:22/owner/source", + ] { + assert!(location.matches_origin(origin), "{origin}"); + } + for origin in [ + "https://github.com.evil.invalid/owner/source.git", + "https://github.com@evil.invalid/owner/source.git", + "https://token@github.com/owner/source.git", + "https://github.com/owner/source.git?redirect=1", + "https://github.com/owner/../source.git", + "git@github-alias:owner/source.git", + "ssh://git@github.com:2222/owner/source.git", + "file:///owner/source", + "https://github.com/owner/source.git\n", + "https://github.com/other/source.git", + ] { + assert!(!location.matches_origin(origin), "{origin}"); + } +} +#[test] +fn catalog_and_process_permissions_never_implicitly_grant_git_changes() { + use nddev_device_sync_domain::{ + Permission, + modules::control::{Configuration, Readiness}, + }; + let mut permissions = write_permissions(); + permissions.remove(&Permission::GitWrite); + permissions.insert(Permission::ProcessExecution); + let mut setting = Configuration { + enabled: true, + permissions, + }; + assert_eq!( + setting.admit(true, true, &write_permissions()), + Err(Readiness::PermissionRequired) + ); + setting.permissions.insert(Permission::GitWrite); + assert_eq!(setting.admit(true, true, &write_permissions()), Ok(())); + setting.enabled = false; + assert_eq!( + setting.admit(true, true, &write_permissions()), + Err(Readiness::Disabled) + ); +} +#[test] +fn connection_preserves_account_identity_repository_identity_and_existing_paths() { + let expected = binding(); + let presence = |binding| CheckoutPresence::Repository { + origin: "git@github.com:owner/source.git".into(), + active_writer: false, + binding, + }; + let decide = + |presence| connection_decision(&expected.principal, &expected.location, &presence).unwrap(); + assert_eq!(decide(CheckoutPresence::Missing), ConnectionDecision::Clone); + assert_eq!( + decide(CheckoutPresence::Occupied), + ConnectionDecision::Blocked(ConnectionBlock::PathOccupied) + ); + assert_eq!(decide(presence(None)), ConnectionDecision::Adopt); + assert_eq!( + decide(presence(Some(Box::new(expected.clone())))), + ConnectionDecision::AlreadyConnected + ); + for field in ["origin", "tenant", "user", "device"] { + let mut other = expected.clone(); + match field { + "origin" => other.principal.origin = "https://other.example.invalid".into(), + "tenant" => other.principal.tenant = "other-company".into(), + "user" => other.principal.user = "other-owner".into(), + _ => other.principal.device = "other-device".into(), + } + assert_eq!( + decide(presence(Some(Box::new(other)))), + ConnectionDecision::Blocked(ConnectionBlock::DifferentAccount) + ); + } + let mut other = expected.clone(); + other.location.repository_id = RepositoryId::new(20).unwrap(); + assert_eq!( + decide(presence(Some(Box::new(other)))), + ConnectionDecision::Blocked(ConnectionBlock::DifferentRepository) + ); + let mut other = expected.clone(); + other.location.owner_id = GithubAccountId::new(8).unwrap(); + assert_eq!( + decide(presence(Some(Box::new(other)))), + ConnectionDecision::Blocked(ConnectionBlock::RelocationRequired) + ); + assert_eq!( + decide(CheckoutPresence::Repository { + origin: expected.location.https_url(), + active_writer: true, + binding: None + }), + ConnectionDecision::Blocked(ConnectionBlock::ActiveWriter) + ); +} +#[test] +fn encrypted_checkout_state_is_a_device_fact_with_exact_entity_and_writer() { + use nddev_device_sync_domain::{ + DeviceId, + state::{Snapshot, State}, + sync::EntityKind, + }; + let binding = binding(); + let entity = binding.entity_id(); + let mut state = Snapshot { + schema_version: 2, + observed_at_ms: 100, + data: State::RepositoryCheckout { binding }, + }; + let device = DeviceId::new("device").unwrap(); + assert!( + state + .validate(&device, EntityKind::RepositoryState, &entity) + .is_ok() + ); + assert!( + state + .validate( + &DeviceId::new("other").unwrap(), + EntityKind::RepositoryState, + &entity + ) + .is_err() + ); + assert!( + state + .validate(&device, EntityKind::RepositoryState, "github:repo:19") + .is_err() + ); + state.schema_version = 1; + assert!( + state + .validate(&device, EntityKind::RepositoryState, &entity) + .is_err() + ); +} #[test] fn portable_paths_keep_identity_across_rename_and_transfer() { let old = CheckoutLocation::from_repository(&repository("personal", "source")).unwrap(); diff --git a/standarts.lock b/standarts.lock index 9cf75c4..47c349d 100644 --- a/standarts.lock +++ b/standarts.lock @@ -1,4 +1,4 @@ source_repo = "NDDev-Technocracy-OpenNetwork-NDS/nddev-device-sync" source_release = "unreleased" -source_commit = "35bbc79ce03c21a00717dec533119cf4f6ad32ef" +source_commit = "64be2a4bb1f44127a26b71b3f1be51833214484e" license = "AGPL-3.0-only" From b014cc5bdd9adc31896b3a95f642d80b7e7b0334 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Thu, 22 Oct 2026 13:38:39 +0500 Subject: [PATCH 2/3] fix(core): share connection intent across the device fleet --- crates/domain/src/repositories/workspace.rs | 19 +++--- crates/domain/src/state.rs | 15 +++-- crates/domain/tests/repository_workspace.rs | 68 ++++++++++----------- standarts.lock | 2 +- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/crates/domain/src/repositories/workspace.rs b/crates/domain/src/repositories/workspace.rs index 85dfc20..10df7d8 100644 --- a/crates/domain/src/repositories/workspace.rs +++ b/crates/domain/src/repositories/workspace.rs @@ -46,6 +46,9 @@ impl CheckoutLocation { pub fn collision_key(&self) -> String { self.relative_path().to_ascii_lowercase() } + pub fn connection_entity_id(&self) -> String { + connection_entity_id(self.repository_id) + } pub fn https_url(&self) -> String { format!("https://github.com/{}/{}.git", self.owner, self.repository) } @@ -65,6 +68,10 @@ impl CheckoutLocation { } } +pub fn connection_entity_id(repository: RepositoryId) -> String { + format!("nds:connection:{}", repository.value()) +} + /// A local witness, not an authorization capability. The adapter derives this /// from its original authenticated NDS scope; it never accepts it from a UI. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -91,8 +98,9 @@ impl WorkspacePrincipal { } } -/// The encrypted NDS record and local Git marker use the same portable model. -/// No absolute path, credential or provider success is stored in this witness. +/// Device-local Git witness over the canonical connection location. NDS keeps +/// owner-wide connection intent, not one replicated marker per device. No +/// absolute path, credential or provider success is stored in this witness. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CheckoutBinding { @@ -111,13 +119,6 @@ impl CheckoutBinding { Err(ModuleError::InvalidInput) } } - pub fn entity_id(&self) -> String { - format!( - "nds:checkout:{}:{}", - self.principal.device, - self.location.repository_id.value() - ) - } } /// Dedicated bounded Git authority; catalog/observation consent remains smaller. diff --git a/crates/domain/src/state.rs b/crates/domain/src/state.rs index 3e3324a..8d5fb6d 100644 --- a/crates/domain/src/state.rs +++ b/crates/domain/src/state.rs @@ -26,9 +26,10 @@ pub enum State { Repository { observation: crate::repositories::catalog::observations::RepositoryObservation, }, - /// Device-owned filesystem witness; reading this fact never grants Git I/O. - RepositoryCheckout { - binding: crate::repositories::workspace::CheckoutBinding, + /// Owner-wide connection intent. Physical markers remain device-local; + /// this fact never grants Git I/O or duplicates the catalog per device. + RepositoryConnection { + location: crate::repositories::workspace::CheckoutLocation, }, Device { display_name: String, @@ -92,7 +93,7 @@ impl Snapshot { ( State::Repository { .. } | State::GithubSource { .. } - | State::RepositoryCheckout { .. }, + | State::RepositoryConnection { .. }, EntityKind::RepositoryState, ) | (State::ModuleConfiguration { .. }, EntityKind::ModuleState) => 2, @@ -186,10 +187,8 @@ impl Snapshot { (State::Repository { observation }, EntityKind::RepositoryState) => { observation.validate().is_ok() && observation.key().entity_id() == entity } - (State::RepositoryCheckout { binding }, EntityKind::RepositoryState) => { - binding.validate().is_ok() - && binding.principal.device == writer.as_str() - && binding.entity_id() == entity + (State::RepositoryConnection { location }, EntityKind::RepositoryState) => { + location.validate().is_ok() && location.connection_entity_id() == entity } _ => false, }; diff --git a/crates/domain/tests/repository_workspace.rs b/crates/domain/tests/repository_workspace.rs index 940652b..7ef7c2c 100644 --- a/crates/domain/tests/repository_workspace.rs +++ b/crates/domain/tests/repository_workspace.rs @@ -149,45 +149,45 @@ fn connection_preserves_account_identity_repository_identity_and_existing_paths( ); } #[test] -fn encrypted_checkout_state_is_a_device_fact_with_exact_entity_and_writer() { +fn connection_intent_is_owner_wide_and_bounded_at_1024_repositories_on_32_devices() { use nddev_device_sync_domain::{ DeviceId, state::{Snapshot, State}, sync::EntityKind, }; - let binding = binding(); - let entity = binding.entity_id(); - let mut state = Snapshot { - schema_version: 2, - observed_at_ms: 100, - data: State::RepositoryCheckout { binding }, - }; - let device = DeviceId::new("device").unwrap(); - assert!( - state - .validate(&device, EntityKind::RepositoryState, &entity) - .is_ok() - ); - assert!( - state - .validate( - &DeviceId::new("other").unwrap(), - EntityKind::RepositoryState, - &entity - ) - .is_err() - ); - assert!( - state - .validate(&device, EntityKind::RepositoryState, "github:repo:19") - .is_err() - ); - state.schema_version = 1; - assert!( - state - .validate(&device, EntityKind::RepositoryState, &entity) - .is_err() - ); + let mut ids = std::collections::BTreeSet::new(); + for repository_id in 1..=1024 { + let mut location = binding().location; + location.repository_id = RepositoryId::new(repository_id).unwrap(); + let entity = location.connection_entity_id(); + let state = Snapshot { + schema_version: 2, + observed_at_ms: 100, + data: State::RepositoryConnection { location }, + }; + for device in 1..=32 { + assert!( + state + .validate( + &DeviceId::new(format!("device-{device}")).unwrap(), + EntityKind::RepositoryState, + &entity + ) + .is_ok() + ); + ids.insert(entity.clone()); + } + assert!( + state + .validate( + &DeviceId::new("device").unwrap(), + EntityKind::RepositoryState, + "github:repo:19" + ) + .is_err() + ); + } + assert_eq!(ids.len(), 1024); } #[test] fn portable_paths_keep_identity_across_rename_and_transfer() { diff --git a/standarts.lock b/standarts.lock index 47c349d..9976891 100644 --- a/standarts.lock +++ b/standarts.lock @@ -1,4 +1,4 @@ source_repo = "NDDev-Technocracy-OpenNetwork-NDS/nddev-device-sync" source_release = "unreleased" -source_commit = "64be2a4bb1f44127a26b71b3f1be51833214484e" +source_commit = "549ff0f3e7b58e9eab6a52b1c682002a45ee4fbc" license = "AGPL-3.0-only" From e052fa0ee00bde55cda24746df2dc07f6ed996e7 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Thu, 22 Oct 2026 14:19:15 +0500 Subject: [PATCH 3/3] refactor(core): validate direct checkout connection requests --- crates/domain/src/repositories/workspace.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/domain/src/repositories/workspace.rs b/crates/domain/src/repositories/workspace.rs index 10df7d8..f44ac63 100644 --- a/crates/domain/src/repositories/workspace.rs +++ b/crates/domain/src/repositories/workspace.rs @@ -17,11 +17,24 @@ pub struct CheckoutLocation { } impl CheckoutLocation { pub fn from_repository(repository: &RepositorySummary) -> Result { + Self::new( + repository.id, + repository.owner.id, + repository.owner.login.clone(), + repository.name.clone(), + ) + } + pub fn new( + repository_id: RepositoryId, + owner_id: GithubAccountId, + owner: String, + repository: String, + ) -> Result { let location = Self { - repository_id: repository.id, - owner_id: repository.owner.id, - owner: repository.owner.login.clone(), - repository: repository.name.clone(), + repository_id, + owner_id, + owner, + repository, }; location.validate()?; Ok(location)