diff --git a/Cargo.lock b/Cargo.lock index 0cdaa477..db812ef9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6131,9 +6131,11 @@ dependencies = [ "tokio-tungstenite 0.29.0", "toml", "tower", + "url", "uuid", "workdir", "worker", + "workspace-api", ] [[package]] diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index c6dee233..739b50d4 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::fmt; +use workspace_api::{RepositoryObservedStatus, RepositorySource}; const DEFAULT_WORKSPACE_LIMIT: usize = 200; @@ -44,8 +45,13 @@ pub struct CreateBackendWorkspaceRepositoryRecord { pub repository_id: String, pub name: String, pub kind: String, - pub uri: String, + pub provider: Option, + pub source: RepositorySource, pub default_ref: Option, + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + pub observed_at: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index 1b24d55b..9171e23d 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -41,9 +41,11 @@ tar.workspace = true thiserror = { workspace = true } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } toml.workspace = true +url.workspace = true uuid = { workspace = true, features = ["v7"] } tower = { workspace = true, features = ["util"], optional = true } worker.workspace = true +workspace-api = { path = "../workspace-api" } workdir.workspace = true [dev-dependencies] diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index e745e9eb..26d592c1 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -2,7 +2,6 @@ use crate::identity::{RuntimeWorkerRef, WorkerId, WorkerRef}; use crate::interaction::WorkerInput; use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; fn is_false(value: &bool) -> bool { !*value @@ -85,9 +84,9 @@ impl std::ops::Deref for RepositorySelector { pub struct WorkingDirectoryRepository { pub id: String, pub provider: String, - pub uri: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_path: Option, + pub source: workspace_api::RepositorySource, + pub source_revision: u64, + pub source_fingerprint: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub selector: Option, } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index ed1538f4..d741e0e5 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -2631,8 +2631,12 @@ mod tests { repository: WorkingDirectoryRepository { id: "repo-main".to_string(), provider: "git".to_string(), - uri: ".".to_string(), - local_path: Some(repo.to_path_buf()), + source: workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: repo.display().to_string(), + }, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), selector: Some(RepositorySelector::from("HEAD")), }, materializer: MaterializerKind::LocalGitWorktree, diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index c675281b..ddf53105 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -318,18 +318,36 @@ impl LocalGitWorktreeMaterializer { ), )); } - if is_remote_uri(&request.repository.uri) { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_remote_repository_unsupported", - "remote repository URI materialization is not implemented in v0", - )); - } - - let source_path = request - .repository - .local_path - .clone() - .unwrap_or_else(|| PathBuf::from(&request.repository.uri)); + let source_path = match request.repository.source.kind { + workspace_api::RepositorySourceKind::LocalPath => { + PathBuf::from(&request.repository.source.uri) + } + workspace_api::RepositorySourceKind::File => { + url::Url::parse(&request.repository.source.uri) + .ok() + .and_then(|uri| uri.to_file_path().ok()) + .ok_or_else(|| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_source_invalid", + "configured file Repository source is invalid", + ) + })? + } + workspace_api::RepositorySourceKind::Ssh + | workspace_api::RepositorySourceKind::Http + | workspace_api::RepositorySourceKind::Https => { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_remote_repository_access_required", + "remote Repository materialization requires an explicit authenticated access and trust handle", + )); + } + workspace_api::RepositorySourceKind::Invalid => { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_source_invalid", + "configured Repository source is invalid and cannot be materialized", + )); + } + }; let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"]) .map(|value| PathBuf::from(value.trim())) .map_err(|_| { @@ -661,10 +679,6 @@ fn path_str(path: &Path) -> Result { }) } -fn is_remote_uri(uri: &str) -> bool { - uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:") -} - fn sanitize_path_component(value: &str) -> String { let sanitized = value .chars() @@ -793,8 +807,12 @@ mod tests { repository: WorkingDirectoryRepository { id: "repo-main".to_string(), provider: "git".to_string(), - uri: ".".to_string(), - local_path: Some(repo.to_path_buf()), + source: workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: repo.display().to_string(), + }, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), selector: Some(RepositorySelector::from("HEAD")), }, materializer: MaterializerKind::LocalGitWorktree, @@ -908,19 +926,21 @@ mod tests { let runtime_root = tempfile::tempdir().unwrap(); let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path()); let mut remote = request(Path::new(".")); - remote.repository.local_path = None; - remote.repository.uri = "https://example.invalid/repo.git".to_string(); + remote.repository.source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Https, + uri: "https://example.invalid/repo.git".to_string(), + }; let error = materializer .materialize(&worker_ref(1), &remote) .unwrap_err(); assert_eq!( error.code, - "working_directory_remote_repository_unsupported" + "working_directory_remote_repository_access_required" ); let mut non_git = remote; non_git.repository.provider = "archive".to_string(); - non_git.repository.uri = ".".to_string(); + non_git.repository.source.uri = ".".to_string(); let error = materializer .materialize(&worker_ref(2), &non_git) .unwrap_err(); diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index bbafb9e7..db00728a 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -7,6 +7,88 @@ use serde::{Deserialize, Serialize}; use workdir::workspace::WorkingDirectorySummary; +/// Provider-neutral classification of an authoritative Repository source. +/// +/// Local paths remain distinct from network Git transports so callers cannot +/// accidentally treat an unmaterialized remote as a server-local filesystem path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RepositorySourceKind { + LocalPath, + File, + Ssh, + Http, + Https, + /// A legacy value that could not be classified during migration. It remains + /// inspectable but every provider operation must fail closed. + Invalid, +} + +impl RepositorySourceKind { + pub const fn is_remote(self) -> bool { + matches!(self, Self::Ssh | Self::Http | Self::Https) + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::LocalPath => "local_path", + Self::File => "file", + Self::Ssh => "ssh", + Self::Http => "http", + Self::Https => "https", + Self::Invalid => "invalid", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "local_path" => Self::LocalPath, + "file" => Self::File, + "ssh" => Self::Ssh, + "http" => Self::Http, + "https" => Self::Https, + "invalid" => Self::Invalid, + _ => return None, + }) + } +} + +/// Stable Repository source identity stored by Workspace authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepositorySource { + pub kind: RepositorySourceKind, + /// Canonical source representation. This is an absolute local path for + /// `local_path`, and a normalized URI/remote specification otherwise. + pub uri: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryObservedStatus { + Unverified, + Ready, + Invalid, +} + +impl RepositoryObservedStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Unverified => "unverified", + Self::Ready => "ready", + Self::Invalid => "invalid", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "unverified" => Self::Unverified, + "ready" => Self::Ready, + "invalid" => Self::Invalid, + _ => return None, + }) + } +} + pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search"; pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search"; diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index ba08454c..de441fd4 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -471,14 +471,18 @@ fn resolve_repository( let provider = normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase(); let uri = normalize_required_string("repository uri", &config.uri)?; - let path = resolve_repository_uri(workspace_root, &id, &uri)?; + let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?; let display_name = normalize_optional_string(config.display_name.as_deref()); let default_selector = normalize_optional_string(config.default_selector.as_deref()); Ok(ConfiguredRepository { id, provider, - uri, + source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), + source, + source_revision: 1, + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, path, display_name, default_selector, @@ -517,13 +521,41 @@ fn validate_repository_id(id: &str) -> Result<()> { } } -fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result { - if uri.contains("://") { - return Err(Error::Config(format!( - "repository `{id}` uses a remote URI, but remote repository materialization is not implemented" - ))); +fn resolve_repository_source( + workspace_root: &Path, + id: &str, + uri: &str, +) -> Result<(workspace_api::RepositorySource, Option)> { + match crate::repository_source::parse_repository_source(uri) { + Ok(source) => { + let path = match source.kind { + workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)), + workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri) + .ok() + .and_then(|uri| uri.to_file_path().ok()), + workspace_api::RepositorySourceKind::Ssh + | workspace_api::RepositorySourceKind::Http + | workspace_api::RepositorySourceKind::Https => None, + workspace_api::RepositorySourceKind::Invalid => { + return Err(Error::Config(format!( + "repository `{id}` has an invalid source" + ))); + } + }; + Ok((source, path)) + } + Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => { + let path = resolve_workspace_path(workspace_root, Path::new(uri)); + let source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: path.to_string_lossy().into_owned(), + }; + Ok((source, Some(path))) + } + Err(error) => Err(Error::Config(format!( + "repository `{id}` has an invalid source: {error}" + ))), } - Ok(resolve_workspace_path(workspace_root, Path::new(uri))) } pub(crate) fn resolve_remote_runtime( @@ -741,13 +773,13 @@ default_selector = "HEAD" assert_eq!(repository.id, "main"); assert_eq!(repository.provider, "git"); - assert_eq!(repository.path, dir.path()); + assert_eq!(repository.path.as_deref(), Some(dir.path())); assert_eq!(repository.display_name.as_deref(), Some("Main")); assert_eq!(repository.default_selector.as_deref(), Some("HEAD")); } #[test] - fn remote_repository_uri_fails_closed() { + fn remote_repository_source_is_preserved_without_a_local_path() { let dir = tempfile::tempdir().unwrap(); let config = WorkspaceBackendConfigFile::parse_str( r#" @@ -759,17 +791,15 @@ uri = "https://example.com/org/repo.git" "test", ) .unwrap(); - let error = match config.resolve(dir.path(), identity()) { - Ok(_) => panic!("remote repository URI should fail closed"), - Err(error) => error, - }; + let resolved = config.resolve(dir.path(), identity()).unwrap(); + let repository = &resolved.server.repositories[0]; - assert!( - error - .to_string() - .contains("remote repository materialization is not implemented"), - "unexpected error: {error}" + assert_eq!( + repository.source.kind, + workspace_api::RepositorySourceKind::Https ); + assert_eq!(repository.source.uri, "https://example.com/org/repo.git"); + assert!(repository.path.is_none()); } #[test] diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index d8ca871d..5d18ef12 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -20,6 +20,7 @@ pub mod records; #[cfg(feature = "typescript")] pub use records::ticket_api_typescript; pub mod repositories; +pub mod repository_source; pub mod resource_broker; pub mod retention; pub mod runtime_subscription; diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 0ccee280..36045227 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -711,14 +711,36 @@ fn infer_workspace_root_from_repositories( ))); }; - let repository_path = PathBuf::from(&repository.uri); - if !repository_path.is_absolute() { - return Err(CliError(format!( - "repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths", - repository.repository_id, repository.uri - ))); + match repository.source.kind { + workspace_api::RepositorySourceKind::LocalPath => { + let repository_path = PathBuf::from(&repository.source.uri); + if !repository_path.is_absolute() { + return Err(CliError(format!( + "repository `{}` has relative local source `{}`; local repository sources used by serve must be absolute paths", + repository.repository_id, repository.source.uri + ))); + } + Ok(repository_path) + } + workspace_api::RepositorySourceKind::File => url::Url::parse(&repository.source.uri) + .ok() + .and_then(|uri| uri.to_file_path().ok()) + .ok_or_else(|| { + CliError(format!( + "repository `{}` has invalid file source `{}`", + repository.repository_id, repository.source.uri + )) + }), + workspace_api::RepositorySourceKind::Ssh + | workspace_api::RepositorySourceKind::Http + | workspace_api::RepositorySourceKind::Https => Ok( + ServerConfig::default_workspace_backend_data_root(&workspace.workspace_id), + ), + workspace_api::RepositorySourceKind::Invalid => Err(CliError(format!( + "repository `{}` has an invalid migrated source and cannot be used by serve", + repository.repository_id + ))), } - Ok(repository_path) } fn parse_config_command(args: &[String]) -> Result { diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 21b15634..d639b6e7 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -5,6 +5,7 @@ use std::{ }; use serde::{Deserialize, Serialize}; +use workspace_api::{RepositoryObservedStatus, RepositorySource}; pub type RepositoryId = String; pub type RepositorySelector = String; @@ -13,8 +14,12 @@ pub type RepositorySelector = String; pub struct ConfiguredRepository { pub id: RepositoryId, pub provider: String, - pub uri: String, - pub path: PathBuf, + pub source: RepositorySource, + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + pub observed_at: Option, + pub path: Option, pub display_name: Option, pub default_selector: Option, } @@ -25,6 +30,12 @@ pub struct RepositorySummary { pub display_name: String, pub kind: String, pub provider: String, + pub source: RepositorySource, + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub observed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_selector: Option, pub record_authority: String, @@ -262,9 +273,17 @@ impl RepositoryRegistryReader { descendant: &str, ) -> Result<(), RepositoryLookupError> { let repository = self.merge_repository(id)?; + let repository_path = + repository + .path + .as_ref() + .ok_or_else(|| RepositoryLookupError::ProviderFailure { + id: id.into(), + operation: "repository source is not materialized for local Git access".into(), + })?; let status = Command::new("git") .arg("-C") - .arg(&repository.path) + .arg(repository_path) .args(["merge-base", "--is-ancestor", ancestor, descendant]) .status() .map_err(|_| RepositoryLookupError::ProviderFailure { @@ -307,6 +326,14 @@ impl RepositoryRegistryReader { .unwrap_or_else(|| repository.id.clone()); let mut diagnostics = Vec::new(); let git = match repository.provider.as_str() { + "git" if repository.path.is_none() => { + diagnostics.push(RepositoryDiagnostic { + severity: "info".to_string(), + code: "repository_source_unverified".to_string(), + message: "Remote Repository source is registered but is not materialized for server-local inspection.".to_string(), + }); + None + } "git" => match self.inspect_git(repository) { Ok(git) => Some(git), Err(message) => { @@ -335,6 +362,11 @@ impl RepositoryRegistryReader { display_name, kind: repository.provider.clone(), provider: repository.provider.clone(), + source: repository.source.clone(), + source_revision: repository.source_revision, + source_fingerprint: repository.source_fingerprint.clone(), + observed_status: repository.observed_status, + observed_at: repository.observed_at.clone(), default_selector: repository.default_selector.clone(), record_authority: "workspace-control-plane".to_string(), git, @@ -346,12 +378,15 @@ impl RepositoryRegistryReader { &self, repository: &ConfiguredRepository, ) -> Result { - let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?; - let branch = git_stdout(&repository.path, ["branch", "--show-current"]) + let path = repository.path.as_ref().ok_or_else(|| { + "Repository source is not materialized for local Git inspection.".to_string() + })?; + let head = git_stdout(path, ["rev-parse", "HEAD"])?; + let branch = git_stdout(path, ["branch", "--show-current"]) .ok() .and_then(|value| non_empty_string(value.trim())); - let status = git_stdout(&repository.path, ["status", "--porcelain"])?; - let remotes = git_stdout(&repository.path, ["remote", "-v"]) + let status = git_stdout(path, ["status", "--porcelain"])?; + let remotes = git_stdout(path, ["remote", "-v"]) .map(|raw| parse_remotes(&raw)) .unwrap_or_default(); Ok(GitRepositorySummary { @@ -369,8 +404,11 @@ impl RepositoryRegistryReader { limit: usize, ) -> Result, String> { let limit_arg = format!("-{limit}"); + let path = repository.path.as_ref().ok_or_else(|| { + "Repository source is not materialized for local Git log access.".to_string() + })?; let output = git_stdout( - &repository.path, + path, [ "log", "--date=iso-strict", @@ -419,11 +457,16 @@ fn merge_git_stdout( operation: &str, args: &[&str], ) -> Result { - git_stdout(&repository.path, args.iter().copied()).map_err(|_| { - RepositoryLookupError::ProviderFailure { + let path = repository + .path + .as_ref() + .ok_or_else(|| RepositoryLookupError::ProviderFailure { id: repository.id.clone(), - operation: operation.into(), - } + operation: "repository source is not materialized for local Git access".into(), + })?; + git_stdout(path, args.iter().copied()).map_err(|_| RepositoryLookupError::ProviderFailure { + id: repository.id.clone(), + operation: operation.into(), }) } @@ -593,6 +636,47 @@ mod tests { assert_eq!(projection.diagnostics[0].code, "repository_config_empty"); } + #[test] + fn remote_source_is_visible_but_local_provider_operations_fail_closed() { + let source = RepositorySource { + kind: workspace_api::RepositorySourceKind::Ssh, + uri: "git@example.test:org/repository.git".to_string(), + }; + let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { + id: "remote".into(), + display_name: Some("Remote".into()), + provider: "git".into(), + source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), + source, + source_revision: 1, + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, + path: None, + default_selector: Some("main".into()), + }]); + + let projection = reader.list(); + let summary = &projection.items[0]; + assert_eq!( + summary.source.kind, + workspace_api::RepositorySourceKind::Ssh + ); + assert_eq!( + summary.observed_status, + RepositoryObservedStatus::Unverified + ); + assert!(summary.git.is_none()); + assert_eq!(summary.diagnostics[0].code, "repository_source_unverified"); + + let repository = reader.merge_repository("remote").unwrap(); + let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err(); + assert!(matches!( + error, + RepositoryLookupError::ProviderFailure { operation, .. } + if operation.contains("not materialized") + )); + } + #[test] fn merge_evidence_is_resolved_by_repository_identity() { let temp = tempfile::tempdir().unwrap(); @@ -675,12 +759,22 @@ mod tests { .success() ); + let source_descriptor = RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: path.display().to_string(), + }; let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { id: "main".into(), display_name: Some("Main".into()), provider: "git".into(), - path: path.to_path_buf(), - uri: path.display().to_string(), + source_fingerprint: crate::repository_source::repository_source_fingerprint( + &source_descriptor, + ), + source: source_descriptor, + source_revision: 1, + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, + path: Some(path.to_path_buf()), default_selector: Some("main".into()), }]); let target = reader.observe_merge_target("main", Some("main")).unwrap(); diff --git a/crates/workspace-server/src/repository_source.rs b/crates/workspace-server/src/repository_source.rs new file mode 100644 index 00000000..204d408e --- /dev/null +++ b/crates/workspace-server/src/repository_source.rs @@ -0,0 +1,230 @@ +use std::path::Path; + +use sha2::{Digest, Sha256}; +use url::Url; +use workspace_api::{RepositorySource, RepositorySourceKind}; + +use crate::{Error, Result}; + +const MAX_REPOSITORY_SOURCE_BYTES: usize = 4096; + +/// Parse and canonicalize a user-authored Git source without accessing the +/// filesystem or network. +pub fn parse_repository_source(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > MAX_REPOSITORY_SOURCE_BYTES { + return Err(Error::InvalidInput(format!( + "initial repository source must be between 1 and {MAX_REPOSITORY_SOURCE_BYTES} bytes" + ))); + } + if value.chars().any(char::is_control) { + return Err(Error::InvalidInput( + "initial repository source must not contain control characters".to_string(), + )); + } + + if Path::new(value).is_absolute() { + return Ok(RepositorySource { + kind: RepositorySourceKind::LocalPath, + uri: value.to_string(), + }); + } + + if is_scp_like_ssh(value) { + validate_scp_like_ssh(value)?; + return Ok(RepositorySource { + kind: RepositorySourceKind::Ssh, + uri: value.to_string(), + }); + } + + let parsed = Url::parse(value).map_err(|_| { + Error::InvalidInput( + "initial repository source must be an absolute local path or a supported Git URI" + .to_string(), + ) + })?; + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(Error::InvalidInput( + "initial repository source must not contain query parameters or fragments".to_string(), + )); + } + if parsed.password().is_some() { + return Err(Error::InvalidInput( + "initial repository source must not embed a password or token".to_string(), + )); + } + + let kind = match parsed.scheme() { + "file" => { + if !parsed.username().is_empty() { + return Err(Error::InvalidInput( + "file repository URI must not contain user information".to_string(), + )); + } + if parsed.host_str().is_some_and(|host| host != "localhost") { + return Err(Error::InvalidInput( + "file repository URI host must be empty or localhost".to_string(), + )); + } + parsed.to_file_path().map_err(|_| { + Error::InvalidInput("file repository URI must contain an absolute path".to_string()) + })?; + RepositorySourceKind::File + } + "ssh" => { + require_remote_host_and_path(&parsed)?; + RepositorySourceKind::Ssh + } + "http" | "https" => { + if !parsed.username().is_empty() { + return Err(Error::InvalidInput( + "HTTP repository URI must not contain user information".to_string(), + )); + } + require_remote_host_and_path(&parsed)?; + if parsed.scheme() == "http" { + RepositorySourceKind::Http + } else { + RepositorySourceKind::Https + } + } + scheme => { + return Err(Error::InvalidInput(format!( + "unsupported initial repository source scheme `{scheme}`" + ))); + } + }; + + Ok(RepositorySource { + kind, + uri: parsed.to_string(), + }) +} + +/// Classify persisted pre-source-contract rows without guessing a usable remote +/// when the legacy value is malformed. No filesystem or network access occurs. +pub fn classify_legacy_repository_source(value: &str) -> RepositorySource { + parse_repository_source(value).unwrap_or_else(|_| RepositorySource { + kind: RepositorySourceKind::Invalid, + uri: value.trim().to_string(), + }) +} + +pub fn repository_source_fingerprint(source: &RepositorySource) -> String { + let payload = serde_json::to_vec(source).expect("Repository source serializes"); + let mut hasher = Sha256::new(); + hasher.update(b"yoi.repository-source.v1\0"); + hasher.update(payload); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + format!("sha256:{encoded}") +} + +fn require_remote_host_and_path(parsed: &Url) -> Result<()> { + if parsed.host_str().is_none() || parsed.path().is_empty() || parsed.path() == "/" { + return Err(Error::InvalidInput( + "remote repository URI must contain a host and repository path".to_string(), + )); + } + Ok(()) +} + +fn is_scp_like_ssh(value: &str) -> bool { + !value.contains("://") + && value + .split_once(':') + .is_some_and(|(identity, _)| identity.contains('@')) +} + +fn validate_scp_like_ssh(value: &str) -> Result<()> { + let (identity, path) = value.split_once(':').ok_or_else(|| { + Error::InvalidInput("scp-like SSH source must contain `host:path`".to_string()) + })?; + let (username, host) = identity.split_once('@').ok_or_else(|| { + Error::InvalidInput("scp-like SSH source must contain `user@host:path`".to_string()) + })?; + if username.is_empty() + || host.is_empty() + || path.is_empty() + || username.contains('@') + || username.contains(':') + || host.contains('@') + || path.starts_with('-') + || value.contains('?') + || value.contains('#') + || value.chars().any(char::is_whitespace) + { + return Err(Error::InvalidInput( + "scp-like SSH source must use `user@host:path` without credentials or parameters" + .to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_local_file_ssh_http_and_https_sources_without_io() { + let cases = [ + ("/runtime/repos/project", RepositorySourceKind::LocalPath), + ("file:///runtime/repos/project", RepositorySourceKind::File), + ( + "ssh://git@example.test/org/project.git", + RepositorySourceKind::Ssh, + ), + ( + "git@example.test:org/project.git", + RepositorySourceKind::Ssh, + ), + ( + "http://git.test/org/project.git", + RepositorySourceKind::Http, + ), + ( + "https://git.test/org/project.git", + RepositorySourceKind::Https, + ), + ]; + for (source, expected_kind) in cases { + assert_eq!(parse_repository_source(source).unwrap().kind, expected_kind); + } + } + + #[test] + fn rejects_relative_unsupported_and_credential_bearing_sources() { + for source in [ + "relative/project", + "ftp://git.test/project.git", + "https://user@git.test/project.git", + "https://git.test/project.git?token=secret", + "ssh://git:secret@git.test/project.git", + "git@example.test:", + "git:secret@example.test:org/project.git", + "https://git.test/project.git\nother", + ] { + assert!( + parse_repository_source(source).is_err(), + "accepted {source:?}" + ); + } + } + + #[test] + fn fingerprint_uses_canonical_source_identity() { + let first = parse_repository_source(" https://EXAMPLE.test/a/../project.git ").unwrap(); + let second = parse_repository_source("https://example.test/project.git").unwrap(); + assert_eq!(first, second); + assert_eq!( + repository_source_fingerprint(&first), + repository_source_fingerprint(&second) + ); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dc8d2b58..a524555b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -281,20 +281,23 @@ impl ServerConfig { workspace.workspace_id )) })?; - let workspace_root = PathBuf::from(&primary.uri); - if !workspace_root.is_absolute() { - return Err(Error::Config(format!( - "Workspace {} repository uri is not an absolute local path", - workspace.workspace_id - ))); - } + let workspace_data_root = + Self::default_workspace_backend_data_root(&workspace.workspace_id); + let primary_path = repository_local_path(&primary.source); + let workspace_root = primary_path + .clone() + .unwrap_or_else(|| workspace_data_root.clone()); let repositories = repositories .into_iter() .map(|repository| ConfiguredRepository { id: repository.repository_id, provider: repository.provider.unwrap_or(repository.kind), - path: PathBuf::from(&repository.uri), - uri: repository.uri, + path: repository_local_path(&repository.source), + source: repository.source, + source_revision: repository.source_revision, + source_fingerprint: repository.source_fingerprint, + observed_status: repository.observed_status, + observed_at: repository.observed_at, display_name: Some(repository.name), default_selector: repository.default_ref, }) @@ -321,6 +324,19 @@ impl ServerConfig { } } +fn repository_local_path(source: &workspace_api::RepositorySource) -> Option { + match source.kind { + workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)), + workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri) + .ok() + .and_then(|uri| uri.to_file_path().ok()), + workspace_api::RepositorySourceKind::Ssh + | workspace_api::RepositorySourceKind::Http + | workspace_api::RepositorySourceKind::Https + | workspace_api::RepositorySourceKind::Invalid => None, + } +} + const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20; const ORCHESTRATOR_ATTENTION_PROMPT_NAME: &str = "internal.workspace_orchestrator_queue_attention"; @@ -1517,10 +1533,12 @@ fn import_configured_repositories( .unwrap_or_else(|| repository.id.clone()), kind: repository.provider.clone(), provider: Some(repository.provider.clone()), - uri: repository.uri.clone(), + source: repository.source.clone(), default_ref: repository.default_selector.clone(), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: repository.source_revision, + source_fingerprint: repository.source_fingerprint.clone(), + observed_status: repository.observed_status, + observed_at: repository.observed_at.clone(), created_at: now.clone(), updated_at: now.clone(), })?; @@ -1540,35 +1558,25 @@ fn load_configured_repositories_from_store( } fn configured_repository_from_record( - workspace_root: &Path, + _workspace_root: &Path, record: RepositoryRecord, ) -> Result { let provider = record.provider.unwrap_or_else(|| record.kind.clone()); - if record.uri.contains("://") { - return Err(Error::Config(format!( - "repository `{}` uses a remote URI, but remote repository materialization is not implemented", - record.repository_id - ))); - } - let path = resolve_backend_path(workspace_root, Path::new(&record.uri)); + let path = repository_local_path(&record.source); Ok(ConfiguredRepository { id: record.repository_id, provider, - uri: record.uri, path, + source: record.source, + source_revision: record.source_revision, + source_fingerprint: record.source_fingerprint, + observed_status: record.observed_status, + observed_at: record.observed_at, display_name: Some(record.name), default_selector: record.default_ref, }) } -fn resolve_backend_path(workspace_root: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - workspace_root.join(path) - } -} - fn build_server_auth_router(api: ServerAuthApi) -> Router { Router::new() .route("/api/auth/config", get(get_auth_config)) @@ -9818,8 +9826,9 @@ fn working_directory_request_from_repository( repository: WorkingDirectoryRepository { id: repository.id.clone(), provider: repository.provider.clone(), - uri: repository.uri.clone(), - local_path: Some(repository.path.clone()), + source: repository.source.clone(), + source_revision: repository.source_revision, + source_fingerprint: repository.source_fingerprint.clone(), selector: selector .map(|selector| RuntimeRepositorySelector::from(selector.to_string())) .or_else(|| { @@ -12851,8 +12860,9 @@ fn working_directory_request_for_browser( repository: WorkingDirectoryRepository { id: repository.id.clone(), provider: "git".to_string(), - uri: repository.path.to_string_lossy().to_string(), - local_path: Some(repository.path.clone()), + source: repository.source.clone(), + source_revision: repository.source_revision, + source_fingerprint: repository.source_fingerprint.clone(), selector: selector.map(RuntimeRepositorySelector), }, materializer: MaterializerKind::LocalGitWorktree, @@ -13763,10 +13773,15 @@ mod tests { name: "Foreign".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - uri: dir.path().join("foreign").display().to_string(), + source: workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: dir.path().join("foreign").display().to_string(), + }, default_ref: Some("HEAD".to_string()), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), }) @@ -15023,6 +15038,51 @@ mod tests { } } + #[test] + fn catalog_workspace_with_remote_source_does_not_require_server_local_repository() { + let base = test_server_config(tempfile::tempdir().unwrap().path()); + let workspace = WorkspaceRecord { + workspace_id: "remote-workspace".to_string(), + display_name: "Remote Workspace".to_string(), + state: "active".to_string(), + owner_account_id: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + let source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Https, + uri: "https://example.test/org/repository.git".to_string(), + }; + let repositories = vec![RepositoryRecord { + workspace_id: "remote-workspace".to_string(), + repository_id: "main".to_string(), + name: "Main".to_string(), + kind: "git".to_string(), + provider: Some("git".to_string()), + source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), + source, + default_ref: Some("main".to_string()), + source_revision: 1, + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }]; + + let scoped = base + .for_catalog_workspace(&workspace, repositories) + .unwrap(); + assert!(scoped.repositories[0].path.is_none()); + assert_eq!( + scoped.repositories[0].source.kind, + workspace_api::RepositorySourceKind::Https + ); + assert_ne!( + scoped.workspace_root, + PathBuf::from("https://example.test/org/repository.git") + ); + } + fn test_server_config(workspace_root: impl Into) -> ServerConfig { let workspace_root = workspace_root.into(); let store_root = workspace_root.join(".test-embedded-runtime-store"); @@ -15030,11 +15090,19 @@ mod tests { .with_embedded_runtime_store_root(store_root); config.database_path = workspace_root.join(".test-yoi-server.db"); config.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml")); + let source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: workspace_root.display().to_string(), + }; config.repositories = vec![ConfiguredRepository { id: TEST_REPOSITORY_ID.to_string(), provider: "git".to_string(), - uri: workspace_root.display().to_string(), - path: workspace_root, + source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), + source, + source_revision: 1, + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, + path: Some(workspace_root), display_name: Some("Test Repository".to_string()), default_selector: Some("HEAD".to_string()), }]; @@ -18150,10 +18218,15 @@ mod tests { name: repository_id.to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - uri: api.config.workspace_root.display().to_string(), + source: workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: api.config.workspace_root.display().to_string(), + }, default_ref: Some("HEAD".to_string()), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), }) @@ -20175,8 +20248,15 @@ mod tests { config.repositories = vec![ConfiguredRepository { id: "files".to_string(), provider: "local_fs".to_string(), - uri: ".".to_string(), - path: root.path().to_path_buf(), + source: workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: root.path().display().to_string(), + }, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, + path: Some(root.path().to_path_buf()), display_name: None, default_selector: None, }]; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index c49960af..d66051f2 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -14,6 +14,7 @@ use uuid::Uuid; use worker_runtime::identity::{ LegacyWorkerIdentityMapping, RuntimeWorkerRef, WorkerId, legacy_worker_identity_mapping_digest, }; +use workspace_api::{RepositoryObservedStatus, RepositorySource}; use crate::{Error, Result}; @@ -241,6 +242,11 @@ const MIGRATIONS: &[Migration] = &[ name: "generalize Ticket assignments to role principals", apply: generalize_ticket_role_assignments, }, + Migration { + version: 44, + name: "create Repository source authority", + apply: create_repository_source_authority, + }, ]; struct Migration { @@ -295,10 +301,12 @@ pub struct RepositoryRecord { pub name: String, pub kind: String, pub provider: Option, - pub uri: String, + pub source: RepositorySource, pub default_ref: Option, - pub auth_ref_kind: Option, - pub auth_ref_key: Option, + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + pub observed_at: Option, pub created_at: String, pub updated_at: String, } @@ -1770,8 +1778,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { read_workspace_record, )?; let repository = tx.query_row( - r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at + r#"SELECT workspace_id, repository_id, name, kind, provider, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, params![workspace.workspace_id, record.repository.repository_id], read_repository_record, @@ -1822,8 +1831,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let existing_repository = tx .query_row( - r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at + r#"SELECT workspace_id, repository_id, name, kind, provider, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, params![record.repository.workspace_id, record.repository.repository_id], read_repository_record, @@ -1861,19 +1871,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; tx.execute( r#"INSERT INTO repositories ( - workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, + workspace_id, repository_id, name, kind, provider, uri, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, params![ record.repository.workspace_id, record.repository.repository_id, record.repository.name, record.repository.kind, record.repository.provider, - record.repository.uri, + record.repository.source.uri, + record.repository.source.kind.as_str(), + record.repository.source.uri, record.repository.default_ref, - record.repository.auth_ref_kind, - record.repository.auth_ref_key, + record.repository.source_revision, + record.repository.source_fingerprint, + record.repository.observed_status.as_str(), + record.repository.observed_at, record.repository.created_at, record.repository.updated_at, ], @@ -2053,17 +2068,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.execute( r#"INSERT INTO repositories ( - workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + workspace_id, repository_id, name, kind, provider, uri, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) ON CONFLICT(workspace_id, repository_id) DO UPDATE SET name = excluded.name, kind = excluded.kind, provider = excluded.provider, - uri = excluded.uri, default_ref = excluded.default_ref, - auth_ref_kind = excluded.auth_ref_kind, - auth_ref_key = excluded.auth_ref_key, + observed_status = excluded.observed_status, + observed_at = excluded.observed_at, updated_at = excluded.updated_at"#, params![ record.workspace_id, @@ -2071,10 +2086,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.name, record.kind, record.provider, - record.uri, + record.source.uri, + record.source.kind.as_str(), + record.source.uri, record.default_ref, - record.auth_ref_kind, - record.auth_ref_key, + record.source_revision, + record.source_fingerprint, + record.observed_status.as_str(), + record.observed_at, record.created_at, record.updated_at, ], @@ -2090,8 +2109,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at + r#"SELECT workspace_id, repository_id, name, kind, provider, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, params![workspace_id, repository_id], @@ -2105,8 +2125,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn list_repositories(&self, workspace_id: &str) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( - r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at + r#"SELECT workspace_id, repository_id, name, kind, provider, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 ORDER BY repository_id ASC"#, @@ -5069,18 +5090,38 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + let source_kind_value = row.get::<_, String>(5)?; + let source_kind = workspace_api::RepositorySourceKind::parse(&source_kind_value) + .unwrap_or(workspace_api::RepositorySourceKind::Invalid); + let source_revision = row.get::<_, u64>(8)?; + let source_fingerprint = row.get::<_, String>(9)?; + let mut source = RepositorySource { + kind: source_kind, + uri: row.get(6)?, + }; + let observed_status_value = row.get::<_, String>(10)?; + let mut observed_status = RepositoryObservedStatus::parse(&observed_status_value) + .unwrap_or(RepositoryObservedStatus::Invalid); + if source_revision == 0 + || crate::repository_source::repository_source_fingerprint(&source) != source_fingerprint + { + source.kind = workspace_api::RepositorySourceKind::Invalid; + observed_status = RepositoryObservedStatus::Invalid; + } Ok(RepositoryRecord { workspace_id: row.get(0)?, repository_id: row.get(1)?, name: row.get(2)?, kind: row.get(3)?, provider: row.get(4)?, - uri: row.get(5)?, - default_ref: row.get(6)?, - auth_ref_kind: row.get(7)?, - auth_ref_key: row.get(8)?, - created_at: row.get(9)?, - updated_at: row.get(10)?, + source, + default_ref: row.get(7)?, + source_revision, + source_fingerprint, + observed_status, + observed_at: row.get(11)?, + created_at: row.get(12)?, + updated_at: row.get(13)?, }) } @@ -6399,6 +6440,61 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id Ok(()) } +fn create_repository_source_authority(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" + ALTER TABLE repositories ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'invalid'; + ALTER TABLE repositories ADD COLUMN source_uri TEXT NOT NULL DEFAULT ''; + ALTER TABLE repositories ADD COLUMN source_revision INTEGER NOT NULL DEFAULT 1; + ALTER TABLE repositories ADD COLUMN source_fingerprint TEXT NOT NULL DEFAULT ''; + ALTER TABLE repositories ADD COLUMN observed_status TEXT NOT NULL DEFAULT 'unverified'; + ALTER TABLE repositories ADD COLUMN observed_at TEXT; + "#, + )?; + + let legacy = { + let mut stmt = conn.prepare( + "SELECT workspace_id, repository_id, uri FROM repositories ORDER BY workspace_id, repository_id", + )?; + stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::, _>>()? + }; + for (workspace_id, repository_id, uri) in legacy { + let source = crate::repository_source::classify_legacy_repository_source(&uri); + let fingerprint = crate::repository_source::repository_source_fingerprint(&source); + let observed_status = if source.kind == workspace_api::RepositorySourceKind::Invalid { + RepositoryObservedStatus::Invalid + } else { + RepositoryObservedStatus::Unverified + }; + conn.execute( + r#"UPDATE repositories + SET source_kind = ?3, + source_uri = ?4, + source_revision = 1, + source_fingerprint = ?5, + observed_status = ?6, + observed_at = NULL + WHERE workspace_id = ?1 AND repository_id = ?2"#, + params![ + workspace_id, + repository_id, + source.kind.as_str(), + source.uri, + fingerprint, + observed_status.as_str(), + ], + )?; + } + Ok(()) +} + fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -9234,6 +9330,72 @@ mod tests { use super::*; use std::collections::BTreeSet; + #[test] + fn schema_v44_migrates_repository_sources_without_promoting_legacy_auth_refs() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 43).unwrap(); + conn.execute( + "INSERT INTO workspaces(workspace_id, display_name, state, created_at, updated_at) \ + VALUES ('workspace-a', 'Workspace A', 'active', '1', '1')", + [], + ) + .unwrap(); + for (repository_id, uri, auth_kind, auth_key) in [ + ( + "remote", + "https://example.test/org/repository.git", + Some("secret_store"), + Some("legacy/key"), + ), + ("invalid", "relative/repository", Some("file"), Some("/key")), + ] { + conn.execute( + r#"INSERT INTO repositories( + workspace_id, repository_id, name, kind, provider, uri, default_ref, + auth_ref_kind, auth_ref_key, created_at, updated_at + ) VALUES ('workspace-a', ?1, ?1, 'git', 'git', ?2, 'main', ?3, ?4, '1', '1')"#, + params![repository_id, uri, auth_kind, auth_key], + ) + .unwrap(); + } + + apply_migrations(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 44); + let remote = conn + .query_row( + "SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \ + FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'remote'", + [], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }, + ) + .unwrap(); + assert_eq!(remote.0, "https"); + assert_eq!(remote.1, "https://example.test/org/repository.git"); + assert_eq!(remote.2, 1); + assert!(remote.3.starts_with("sha256:")); + assert_eq!(remote.4, "unverified"); + + let invalid = conn + .query_row( + "SELECT source_kind, observed_status FROM repositories \ + WHERE workspace_id = 'workspace-a' AND repository_id = 'invalid'", + [], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .unwrap(); + assert_eq!(invalid, ("invalid".to_string(), "invalid".to_string())); + } + #[test] fn startup_composes_ticket_migrations_when_control_plane_is_current() { let conn = Connection::open_in_memory().unwrap(); @@ -9278,7 +9440,7 @@ mod tests { let before = std::fs::read(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 43); + assert_eq!(plan.target_schema_version, 44); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -9292,7 +9454,7 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 43); + assert_eq!(current_schema_version(conn)?, 44); Ok(()) }) .unwrap(); @@ -9371,7 +9533,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 43); + assert_eq!(current_schema_version(&conn).unwrap(), 44); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -9500,7 +9662,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 43); + assert_eq!(current_schema_version(&conn).unwrap(), 44); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -9618,7 +9780,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 43); + assert_eq!(current_schema_version(&conn).unwrap(), 44); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -9636,7 +9798,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 43); + assert_eq!(current_schema_version(&conn).unwrap(), 44); let settings = conn .query_row( "SELECT settings_revision, language FROM workspace_memory_settings \ @@ -9677,7 +9839,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 43); + assert_eq!(current_schema_version(&conn).unwrap(), 44); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -9744,7 +9906,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 43); + assert_eq!(current_schema_version(&conn).unwrap(), 44); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -9889,10 +10051,15 @@ INSERT INTO workdir_registry ( name: "Main".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - uri: "/repo-a".to_string(), + source: RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: "/repo-a".to_string(), + }, default_ref: Some("HEAD".to_string()), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), }) @@ -9922,7 +10089,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 43); + assert_eq!(store.schema_version().await.unwrap(), 44); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -9939,7 +10106,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 43); + assert_eq!(reopened.schema_version().await.unwrap(), 44); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -10693,7 +10860,7 @@ INSERT INTO worker_registry ( let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); migrated .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 43); + assert_eq!(current_schema_version(conn)?, 44); assert_eq!( conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, 1, @@ -10783,10 +10950,15 @@ INSERT INTO worker_registry ( name: "Main".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - uri: "file:///tmp/main".to_string(), + source: RepositorySource { + kind: workspace_api::RepositorySourceKind::File, + uri: "file:///tmp/main".to_string(), + }, default_ref: Some("develop".to_string()), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, created_at: "2026-09-01T00:00:00Z".to_string(), updated_at: "2026-09-01T00:00:00Z".to_string(), }) @@ -10997,13 +11169,13 @@ INSERT INTO worker_registry ( configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (44, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (45, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 44 is newer"), "{error}"); + assert!(error.contains("schema version 45 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -11224,7 +11396,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- apply_migrations(&mut conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 43); + assert_eq!(current_schema_version(&conn).unwrap(), 44); let workspace_id: Option = conn .query_row( "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", @@ -11727,6 +11899,12 @@ WHERE workspace_id = 'workspace-a' "auth_ref_key", "created_at", "updated_at", + "source_kind", + "source_uri", + "source_revision", + "source_fingerprint", + "observed_status", + "observed_at", ], ); assert_columns( @@ -11841,7 +12019,7 @@ WHERE workspace_id = 'workspace-a' .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 43); + assert_eq!(store.schema_version().await.unwrap(), 44); store .with_conn(|conn| { @@ -12030,7 +12208,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 43); + assert_eq!(store.schema_version().await.unwrap(), 44); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -12047,10 +12225,20 @@ CREATE TABLE ticket_assignment_operations ( name: "Yoi".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - uri: ".".to_string(), + source: RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: "/repo".to_string(), + }, default_ref: Some("HEAD".to_string()), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: 1, + source_fingerprint: crate::repository_source::repository_source_fingerprint( + &RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: "/repo".to_string(), + }, + ), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, created_at: "2".to_string(), updated_at: "2".to_string(), }; @@ -12080,7 +12268,9 @@ CREATE TABLE ticket_assignment_operations ( let mut other_repository = repository.clone(); other_repository.workspace_id = other_workspace.workspace_id.clone(); other_repository.name = "Other Yoi".to_string(); - other_repository.uri = "/other/yoi".to_string(); + other_repository.source.uri = "/other/yoi".to_string(); + other_repository.source_fingerprint = + crate::repository_source::repository_source_fingerprint(&other_repository.source); store.upsert_repository(&other_repository).unwrap(); assert_eq!( @@ -12096,7 +12286,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 43); + assert_eq!(store.schema_version().await.unwrap(), 44); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -12189,10 +12379,15 @@ CREATE TABLE ticket_assignment_operations ( name: "Repository".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - uri: ".".to_string(), + source: RepositorySource { + kind: workspace_api::RepositorySourceKind::LocalPath, + uri: "/repo".to_string(), + }, default_ref: Some("HEAD".to_string()), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: 1, + source_fingerprint: "sha256:test".to_string(), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), }) @@ -12498,7 +12693,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 43); + assert_eq!(store.schema_version().await.unwrap(), 44); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index adf67b6f..1e6ff1cc 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -6,6 +6,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; +use workspace_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind}; + +use crate::repository_source::{parse_repository_source, repository_source_fingerprint}; use crate::store::{ ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, }; @@ -109,8 +112,8 @@ impl WorkspaceCatalogService { )?; let display_name = normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?; - let repository_path = validate_repository_uri(&request.repository.uri)?; - let repository_uri = repository_path.to_string_lossy().into_owned(); + let repository_source = validate_repository_source(&request.repository.uri)?; + let repository_uri = repository_source.uri.clone(); let repository_name = request .repository .display_name @@ -166,10 +169,12 @@ impl WorkspaceCatalogService { name: repository_name, kind: "git".to_string(), provider: Some("git".to_string()), - uri: repository_uri, + source: repository_source.clone(), default_ref: Some(default_ref), - auth_ref_kind: None, - auth_ref_key: None, + source_revision: 1, + source_fingerprint: repository_source_fingerprint(&repository_source), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, created_at: now.clone(), updated_at: now, }, @@ -194,19 +199,38 @@ fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result Result { - let uri = uri.trim(); - if uri.is_empty() || uri.contains("://") { - return Err(Error::InvalidInput( - "initial repository uri must be an absolute server-local path".to_string(), - )); - } - let path = Path::new(uri); - if !path.is_absolute() { - return Err(Error::InvalidInput( - "initial repository uri must be an absolute server-local path".to_string(), - )); +fn validate_repository_source(uri: &str) -> Result { + let mut source = parse_repository_source(uri)?; + match source.kind { + RepositorySourceKind::LocalPath => { + let canonical = validate_local_git_path(Path::new(&source.uri))?; + source.uri = canonical.to_string_lossy().into_owned(); + } + RepositorySourceKind::File => { + let url = url::Url::parse(&source.uri).map_err(|error| { + Error::InvalidInput(format!("invalid file repository URI: {error}")) + })?; + let path = url.to_file_path().map_err(|_| { + Error::InvalidInput("file repository URI must contain an absolute path".to_string()) + })?; + let canonical = validate_local_git_path(&path)?; + source.uri = url::Url::from_file_path(canonical) + .map_err(|_| { + Error::InvalidInput("invalid canonical file repository path".to_string()) + })? + .to_string(); + } + RepositorySourceKind::Ssh | RepositorySourceKind::Http | RepositorySourceKind::Https => {} + RepositorySourceKind::Invalid => { + return Err(Error::InvalidInput( + "invalid initial repository source".to_string(), + )); + } } + Ok(source) +} + +fn validate_local_git_path(path: &Path) -> Result { let path = path.canonicalize().map_err(|error| { Error::InvalidInput(format!("initial repository path is unavailable: {error}")) })?; @@ -383,12 +407,48 @@ mod tests { } #[test] - fn repository_intent_rejects_remote_and_non_git_paths() { - let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err(); - assert!(remote.to_string().contains("server-local path")); + fn repository_intent_accepts_remote_and_rejects_non_git_local_paths() { + let remote = validate_repository_source("https://example.test/repo.git").unwrap(); + assert_eq!(remote.kind, RepositorySourceKind::Https); let dir = tempfile::tempdir().unwrap(); - let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err(); + let non_git = validate_repository_source(&dir.path().display().to_string()).unwrap_err(); assert!(non_git.to_string().contains("not a Git repository")); } + + #[test] + fn remote_repository_creation_persists_typed_source_without_auth_metadata() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let service = WorkspaceCatalogService::new(store.clone()); + let result = service + .create_first_ownerless(WorkspaceCreateRequest { + operation_key: "remote-create".to_string(), + display_name: "Remote Workspace".to_string(), + repository: InitialRepositoryIntent { + uri: "ssh://git@example.test/org/repository.git".to_string(), + display_name: Some("Remote Repository".to_string()), + default_ref: Some("main".to_string()), + }, + }) + .unwrap(); + + let persisted = store + .get_repository( + &result.workspace.workspace_id, + &result.repository.repository_id, + ) + .unwrap() + .unwrap(); + assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh); + assert_eq!(persisted.source_revision, 1); + assert!(persisted.source_fingerprint.starts_with("sha256:")); + assert_eq!( + persisted.observed_status, + RepositoryObservedStatus::Unverified + ); + let json = serde_json::to_value(&persisted).unwrap(); + assert!(json.get("source").is_some()); + assert!(json.get("auth_ref_kind").is_none()); + assert!(json.get("auth_ref_key").is_none()); + } } diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 44f70d1e..6878faf4 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -7,13 +7,29 @@ export type WorkspaceCatalogRecord = { updated_at: string; }; +export type RepositorySourceKind = + | "local_path" + | "file" + | "ssh" + | "http" + | "https" + | "invalid"; + export type WorkspaceRepositoryRecord = { workspace_id: string; repository_id: string; name: string; kind: string; - uri: string; + provider: string | null; + source: { + kind: RepositorySourceKind; + uri: string; + }; default_ref: string | null; + source_revision: number; + source_fingerprint: string; + observed_status: "unverified" | "ready" | "invalid"; + observed_at: string | null; }; export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index d0cab69e..bccc0c9c 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -155,8 +155,7 @@

New team space

Create Workspace

- Repository paths and URIs are interpreted by the Backend. Browser-local paths are - not authority. + Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority.