diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 46599070..8d6aac3b 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -52,8 +52,6 @@ pub struct WorkingDirectoryEvidence { pub credential_revision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub host_trust_revision: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transport_warning: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -856,6 +854,18 @@ impl RuntimeGitMaterializer { Ok(binding) } + fn validate_plain_http_source( + request: &WorkingDirectoryRequest, + ) -> Result<(), WorkingDirectoryDiagnostic> { + if url::Url::parse(&request.repository.source.uri).is_ok_and(|url| url.scheme() == "http") { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_plain_http_unsupported", + "plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead", + )); + } + Ok(()) + } + fn validate_request( request: &WorkingDirectoryRequest, ) -> Result<(), WorkingDirectoryDiagnostic> { @@ -871,11 +881,10 @@ impl RuntimeGitMaterializer { "the configured Repository provider is unsupported", )); } + Self::validate_plain_http_source(request)?; if matches!( request.repository.source.kind, - workspace_api::RepositorySourceKind::Https - | workspace_api::RepositorySourceKind::Http - | workspace_api::RepositorySourceKind::Ssh + workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Ssh ) { validate_remote_source_uri(request)?; let materialization = request.materialization.as_ref().ok_or_else(|| { @@ -899,8 +908,7 @@ impl RuntimeGitMaterializer { match request.repository.source.kind { workspace_api::RepositorySourceKind::LocalPath | workspace_api::RepositorySourceKind::File - | workspace_api::RepositorySourceKind::Https - | workspace_api::RepositorySourceKind::Http => {} + | workspace_api::RepositorySourceKind::Https => {} workspace_api::RepositorySourceKind::Ssh => { let ssh = request .materialization @@ -984,6 +992,7 @@ impl RuntimeGitMaterializer { request: &WorkingDirectoryRequest, ) -> Result { validate_working_directory_id(&working_directory_id)?; + Self::validate_plain_http_source(request)?; let request = self.request_with_authorized_repository_access(&working_directory_id, request)?; Self::validate_request(&request)?; @@ -1085,8 +1094,6 @@ impl RuntimeGitMaterializer { host_trust_revision: context .and_then(|value| value.ssh.as_ref()) .map(|value| value.host_trust_revision), - transport_warning: repository_transport_warning(request.repository.source.kind) - .map(str::to_string), }, cleanup_target: WorkingDirectoryCleanupTarget { kind: "runtime_git_clone".to_string(), @@ -2393,10 +2400,6 @@ fn validate_ssh_materialization_access( Ok(()) } -fn repository_transport_warning(kind: workspace_api::RepositorySourceKind) -> Option<&'static str> { - (kind == workspace_api::RepositorySourceKind::Http).then_some("plain_http_transport") -} - fn validate_remote_source_uri( request: &WorkingDirectoryRequest, ) -> Result<(), WorkingDirectoryDiagnostic> { @@ -2438,12 +2441,10 @@ fn validate_remote_source_uri( "remote Repository source URI is invalid", ) })?; - let expected_scheme = match request.repository.source.kind { - workspace_api::RepositorySourceKind::Https => "https", - workspace_api::RepositorySourceKind::Http => "http", - _ => return Ok(()), - }; - if url.scheme() != expected_scheme + if request.repository.source.kind != workspace_api::RepositorySourceKind::Https { + return Ok(()); + } + if url.scheme() != "https" || url.host_str().is_none() || url.password().is_some() || url.query().is_some() @@ -2576,10 +2577,23 @@ fn read_bounded_command_output(mut reader: impl Read) -> Vec { captured } +#[cfg(test)] +thread_local! { + static TEST_REPOSITORY_GIT_INVOCATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn test_repository_git_invocation_count() -> usize { + TEST_REPOSITORY_GIT_INVOCATIONS.with(std::cell::Cell::get) +} + fn run_repository_git_stdout( mut command: Command, source_kind: workspace_api::RepositorySourceKind, ) -> Result { + #[cfg(test)] + TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1))); + command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -2670,6 +2684,9 @@ fn run_repository_git( mut command: Command, code: &'static str, ) -> Result<(), WorkingDirectoryDiagnostic> { + #[cfg(test)] + TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1))); + tracing::info!( target: "yoi::repository_access", event = "repository_git_operation_started", @@ -4291,16 +4308,54 @@ mod tests { ); let mut http = request(repo.path()); - http.repository.source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Http, - uri: "http://example.test/repo.git".to_string(), - }; + http.repository.source = serde_json::from_value(serde_json::json!({ + "kind": "http", + "uri": "http://example.test/repo.git", + })) + .unwrap(); http.materialization = Some(context(None)); - RuntimeGitMaterializer::validate_request(&http).unwrap(); + let git_invocations_before = test_repository_git_invocation_count(); + let error = materializer + .materialize(&worker_ref(3), &http) + .expect_err("historical plain HTTP source must fail before Git execution"); assert_eq!( - repository_transport_warning(http.repository.source.kind), - Some("plain_http_transport") + error.code, + "working_directory_repository_plain_http_unsupported" ); + assert!(error.message.contains("HTTPS or SSH")); + assert_eq!( + test_repository_git_invocation_count(), + git_invocations_before, + "plain HTTP rejection must occur before invoking Git" + ); + + for (offset, kind) in [ + workspace_api::RepositorySourceKind::LocalPath, + workspace_api::RepositorySourceKind::File, + workspace_api::RepositorySourceKind::Https, + ] + .into_iter() + .enumerate() + { + let mut mismatched_http = request(repo.path()); + mismatched_http.repository.source = workspace_api::RepositorySource { + kind, + uri: "http://example.test/repo.git".to_string(), + }; + let git_invocations_before = test_repository_git_invocation_count(); + let error = materializer + .materialize(&worker_ref(4 + offset as u64), &mismatched_http) + .expect_err("plain HTTP URI must fail regardless of its declared source kind"); + assert_eq!( + error.code, + "working_directory_repository_plain_http_unsupported" + ); + assert_eq!( + test_repository_git_invocation_count(), + git_invocations_before, + "mismatched plain HTTP source must fail before invoking Git" + ); + } let mut ssh = request(repo.path()); ssh.repository.source = workspace_api::RepositorySource { diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 470fd5a0..a053cca3 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -365,23 +365,23 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> { /// /// 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)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[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. + /// inspectable but every provider operation must fail closed. Historical + /// `http` wire values decode into this non-executable classification. Invalid, } impl RepositorySourceKind { pub const fn is_remote(self) -> bool { - matches!(self, Self::Ssh | Self::Http | Self::Https) + matches!(self, Self::Ssh | Self::Https) } pub const fn as_str(self) -> &'static str { @@ -389,7 +389,6 @@ impl RepositorySourceKind { Self::LocalPath => "local_path", Self::File => "file", Self::Ssh => "ssh", - Self::Http => "http", Self::Https => "https", Self::Invalid => "invalid", } @@ -400,14 +399,28 @@ impl RepositorySourceKind { "local_path" => Self::LocalPath, "file" => Self::File, "ssh" => Self::Ssh, - "http" => Self::Http, "https" => Self::Https, - "invalid" => Self::Invalid, + "http" | "invalid" => Self::Invalid, _ => return None, }) } } +impl<'de> Deserialize<'de> for RepositorySourceKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).ok_or_else(|| { + serde::de::Error::unknown_variant( + &value, + &["local_path", "file", "ssh", "https", "invalid"], + ) + }) + } +} + /// Stable Repository source identity stored by Workspace authority. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -3410,6 +3423,27 @@ mod workdir_typescript_tests { mod tests { use super::*; + #[test] + fn historical_http_repository_source_kind_decodes_as_invalid_evidence() { + let source: RepositorySource = serde_json::from_value(serde_json::json!({ + "kind": "http", + "uri": "http://git.example.test/team/project.git", + "revision": 1, + })) + .unwrap(); + + assert_eq!(source.kind, RepositorySourceKind::Invalid); + assert_eq!( + serde_json::to_value(source).unwrap()["kind"], + serde_json::json!("invalid") + ); + assert_eq!( + RepositorySourceKind::parse("http"), + Some(RepositorySourceKind::Invalid) + ); + assert!(!RepositorySourceKind::Invalid.is_remote()); + } + fn skill_projection() -> SkillProjectionIdentity { SkillProjectionIdentity { config_revision: 42, diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 3fc43c66..4a409604 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -288,13 +288,12 @@ impl RepositoryRegistryReader { fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary { let mut diagnostics = Vec::new(); - if repository.source.kind == workspace_api::RepositorySourceKind::Http { + if crate::repository_source::is_plain_http_repository_source(&repository.source) { diagnostics.push(RepositoryDiagnostic { - severity: "warning".to_string(), - code: "repository_source_insecure_http".to_string(), - message: - "HTTP Repository source is unencrypted; prefer HTTPS or SSH when available." - .to_string(), + severity: "error".to_string(), + code: "repository_source_plain_http_unsupported".to_string(), + message: "Plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead." + .to_string(), }); } let git = match repository.provider.as_str() { @@ -607,6 +606,39 @@ mod tests { assert_eq!(projection.diagnostics[0].code, "repository_config_empty"); } + #[test] + fn legacy_plain_http_source_is_projected_as_non_executable_error() { + let source: RepositorySource = serde_json::from_value(serde_json::json!({ + "kind": "http", + "uri": "http://git.example.test/team/project.git", + })) + .unwrap(); + let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { + id: "legacy-http".into(), + repository_key: "legacy-http".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(); + assert_eq!( + projection.items[0].source.kind, + workspace_api::RepositorySourceKind::Invalid + ); + let diagnostics = projection.items[0].diagnostics.as_ref().unwrap(); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.severity == "error" + && diagnostic.code == "repository_source_plain_http_unsupported" + && diagnostic.message.contains("HTTPS or SSH") + })); + } + #[test] fn remote_source_is_visible_but_local_provider_operations_fail_closed() { let source = RepositorySource { diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs index 3433a8f5..7bea3352 100644 --- a/crates/workspace-server/src/repository_access.rs +++ b/crates/workspace-server/src/repository_access.rs @@ -136,6 +136,18 @@ pub fn project_repository_access_state( ) } +fn validate_repository_access_source( + repository_key: &str, + source: &workspace_api::RepositorySource, +) -> Result<()> { + if crate::repository_source::is_plain_http_repository_source(source) { + return Err(Error::InvalidInput(format!( + "repository_source_plain_http_unsupported: Repository `{repository_key}` uses unsupported plain HTTP; register an HTTPS or SSH source instead" + ))); + } + Ok(()) +} + pub(crate) fn repository_ssh_endpoint( repository_key: &str, repository_uri: &str, @@ -200,6 +212,7 @@ fn project_repository_access_evaluation( let repository = store .get_repository_by_key(workspace_id, &repository_key)? .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?; + validate_repository_access_source(&repository_key, &repository.source)?; if repository.source.kind != workspace_api::RepositorySourceKind::Ssh { return Err(Error::InvalidInput(format!( "Repository `{repository_key}` is not an ssh:// Repository" @@ -1956,6 +1969,30 @@ mod tests { assert!(!contribution.source.contains("secret_ref")); } + #[test] + fn workspace_config_projection_rejects_legacy_plain_http_repository_source() { + let source: RepositorySource = serde_json::from_value(serde_json::json!({ + "kind": "http", + "uri": "http://git.example.test/team/project.git", + })) + .unwrap(); + + let error = validate_repository_access_source("remote", &source).unwrap_err(); + assert!(error.to_string().contains("unsupported plain HTTP")); + assert!(error.to_string().contains("HTTPS or SSH")); + + let mismatched = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Https, + uri: "http://git.example.test/team/project.git".to_string(), + }; + let error = validate_repository_access_source("remote", &mismatched).unwrap_err(); + assert!( + error + .to_string() + .contains("repository_source_plain_http_unsupported") + ); + } + #[test] fn master_key_is_external_and_stable() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/repository_source.rs b/crates/workspace-server/src/repository_source.rs index 204d408e..7d3bf407 100644 --- a/crates/workspace-server/src/repository_source.rs +++ b/crates/workspace-server/src/repository_source.rs @@ -76,18 +76,19 @@ pub fn parse_repository_source(value: &str) -> Result { require_remote_host_and_path(&parsed)?; RepositorySourceKind::Ssh } - "http" | "https" => { + "http" => { + return Err(Error::InvalidInput( + "repository_source_plain_http_unsupported: plain HTTP Repository sources are not supported; use HTTPS or SSH".to_string(), + )); + } + "https" => { if !parsed.username().is_empty() { return Err(Error::InvalidInput( - "HTTP repository URI must not contain user information".to_string(), + "HTTPS repository URI must not contain user information".to_string(), )); } require_remote_host_and_path(&parsed)?; - if parsed.scheme() == "http" { - RepositorySourceKind::Http - } else { - RepositorySourceKind::Https - } + RepositorySourceKind::Https } scheme => { return Err(Error::InvalidInput(format!( @@ -111,6 +112,10 @@ pub fn classify_legacy_repository_source(value: &str) -> RepositorySource { }) } +pub(crate) fn is_plain_http_repository_source(source: &RepositorySource) -> bool { + Url::parse(&source.uri).is_ok_and(|url| url.scheme() == "http") +} + pub fn repository_source_fingerprint(source: &RepositorySource) -> String { let payload = serde_json::to_vec(source).expect("Repository source serializes"); let mut hasher = Sha256::new(); @@ -172,7 +177,7 @@ mod tests { use super::*; #[test] - fn parses_local_file_ssh_http_and_https_sources_without_io() { + fn parses_local_file_ssh_and_https_sources_without_io() { let cases = [ ("/runtime/repos/project", RepositorySourceKind::LocalPath), ("file:///runtime/repos/project", RepositorySourceKind::File), @@ -184,10 +189,6 @@ mod tests { "git@example.test:org/project.git", RepositorySourceKind::Ssh, ), - ( - "http://git.test/org/project.git", - RepositorySourceKind::Http, - ), ( "https://git.test/org/project.git", RepositorySourceKind::Https, @@ -198,6 +199,26 @@ mod tests { } } + #[test] + fn rejects_plain_http_with_secure_transport_guidance() { + for source in [ + "http://git.test/org/project.git", + "http://localhost/org/project.git", + "http://127.0.0.1/org/project.git", + ] { + let error = parse_repository_source(source).expect_err("plain HTTP must fail closed"); + assert!(error.to_string().contains("plain HTTP Repository sources")); + assert!(error.to_string().contains("HTTPS or SSH")); + } + } + + #[test] + fn legacy_plain_http_is_preserved_only_as_invalid_evidence() { + let source = classify_legacy_repository_source("http://git.test/org/project.git"); + assert_eq!(source.kind, RepositorySourceKind::Invalid); + assert_eq!(source.uri, "http://git.test/org/project.git"); + } + #[test] fn rejects_relative_unsupported_and_credential_bearing_sources() { for source in [ diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 820ef071..09635831 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -364,7 +364,6 @@ fn repository_local_path(source: &workspace_api::RepositorySource) -> Option None, } @@ -10818,9 +10817,8 @@ async fn create_workspace_working_directory( "local_path" => workspace_api::RepositorySourceKind::LocalPath, "file" => workspace_api::RepositorySourceKind::File, "https" => workspace_api::RepositorySourceKind::Https, - "http" => workspace_api::RepositorySourceKind::Http, + "http" | "invalid" => workspace_api::RepositorySourceKind::Invalid, "ssh" => workspace_api::RepositorySourceKind::Ssh, - "invalid" => workspace_api::RepositorySourceKind::Invalid, _ => { return Err(settings_bad_request( "working_directory_repository_source_invalid", @@ -22275,6 +22273,22 @@ mod tests { .unwrap(); assert_eq!(replayed.status(), StatusCode::OK); + let plain_http = app + .clone() + .oneshot(create_repository(serde_json::json!({ + "repository_key": "insecure-http", + "source": "http://git.example.test/team/project.git", + "default_ref": "main" + }))) + .await + .unwrap(); + assert_eq!(plain_http.status(), StatusCode::BAD_REQUEST); + let plain_http_body = to_bytes(plain_http.into_body(), usize::MAX).await.unwrap(); + let plain_http_body = String::from_utf8_lossy(&plain_http_body); + assert!(plain_http_body.contains("repository_source_plain_http_unsupported")); + assert!(plain_http_body.contains("plain HTTP Repository sources")); + assert!(plain_http_body.contains("HTTPS or SSH")); + let conflict = app .clone() .oneshot(create_repository(serde_json::json!({ diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 4ab513c3..12d78f3f 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -237,7 +237,6 @@ export type RepositorySourceKind = | "local_path" | "file" | "ssh" - | "http" | "https" | "invalid"; diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index b14b056f..38d5b028 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -56,7 +56,6 @@ const SOURCE_KINDS = new Set([ "local_path", "file", "ssh", - "http", "https", "invalid", ]); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte index 8e9e7c8d..961a7d79 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte @@ -13,7 +13,7 @@ } function supportsRepositoryAccess(kind: RepositorySourceKind): boolean { - return kind === 'ssh' || kind === 'http' || kind === 'https'; + return kind === 'ssh' || kind === 'https'; } let showAddRepository = $state(false); let repositoryKey = $state(''); diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index 4daa10ec..cd0a3040 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -47,6 +47,19 @@ Deno.test("generated repository wrapper validates current Backend JSON", () => { } }); +Deno.test("plain HTTP repository source kind fails closed at the JSON boundary", () => { + const stale = structuredClone(repositoryList) as Record; + const items = stale.items as Array>; + items[0].source = { + kind: "http", + uri: "http://git.example.test/team/project.git", + }; + assertThrows( + () => parseRepositoryListResponse(stale), + ".source.kind is invalid", + ); +}); + Deno.test("stale repository aliases fail closed at the JSON boundary", () => { const stale = structuredClone(repositoryList) as Record; const items = stale.items as Array>; @@ -242,6 +255,15 @@ Deno.test("Repository settings consume the validated shared wire shape", async ( throw new Error(`Repository settings should include ${token}`); } } + for (const kind of ["ssh", "https"]) { + if (!pageSource.includes(`kind === '${kind}'`)) { + throw new Error(`Repository Access should support ${kind}`); + } + } + if (pageSource.includes("kind === 'http'")) { + throw new Error("Repository Access must not support plain HTTP sources"); + } + for ( const staleToken of [ "repository.id",