Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/domain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
170 changes: 165 additions & 5 deletions crates/domain/src/repositories/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,11 +17,24 @@ pub struct CheckoutLocation {
}
impl CheckoutLocation {
pub fn from_repository(repository: &RepositorySummary) -> Result<Self, ModuleError> {
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<Self, ModuleError> {
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)
Expand All @@ -46,9 +59,156 @@ 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)
}
/// 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()))
})
}
}

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)]
#[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(())
}
}
}

/// 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 {
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)
}
}
}

/// Dedicated bounded Git authority; catalog/observation consent remains smaller.
pub fn write_permissions() -> std::collections::BTreeSet<Permission> {
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<Box<CheckoutBinding>>,
},
}
#[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<ConnectionDecision, ModuleError> {
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 {
Expand Down
12 changes: 11 additions & 1 deletion crates/domain/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub enum State {
Repository {
observation: crate::repositories::catalog::observations::RepositoryObservation,
},
/// 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,
platform: String,
Expand Down Expand Up @@ -86,7 +91,9 @@ impl Snapshot {
|| self.schema_version
!= match (&self.data, kind) {
(
State::Repository { .. } | State::GithubSource { .. },
State::Repository { .. }
| State::GithubSource { .. }
| State::RepositoryConnection { .. },
EntityKind::RepositoryState,
)
| (State::ModuleConfiguration { .. }, EntityKind::ModuleState) => 2,
Expand Down Expand Up @@ -180,6 +187,9 @@ impl Snapshot {
(State::Repository { observation }, EntityKind::RepositoryState) => {
observation.validate().is_ok() && observation.key().entity_id() == entity
}
(State::RepositoryConnection { location }, EntityKind::RepositoryState) => {
location.validate().is_ok() && location.connection_entity_id() == entity
}
_ => false,
};
if valid {
Expand Down
Loading