fix: reject plain HTTP repository sources
This commit is contained in:
@@ -52,8 +52,6 @@ pub struct WorkingDirectoryEvidence {
|
|||||||
pub credential_revision: Option<u64>,
|
pub credential_revision: Option<u64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub host_trust_revision: Option<u64>,
|
pub host_trust_revision: Option<u64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub transport_warning: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -503,9 +501,7 @@ impl RuntimeGitMaterializer {
|
|||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
request.repository.source.kind,
|
request.repository.source.kind,
|
||||||
workspace_api::RepositorySourceKind::Https
|
workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Ssh
|
||||||
| workspace_api::RepositorySourceKind::Http
|
|
||||||
| workspace_api::RepositorySourceKind::Ssh
|
|
||||||
) {
|
) {
|
||||||
validate_remote_source_uri(request)?;
|
validate_remote_source_uri(request)?;
|
||||||
let materialization = request.materialization.as_ref().ok_or_else(|| {
|
let materialization = request.materialization.as_ref().ok_or_else(|| {
|
||||||
@@ -529,8 +525,7 @@ impl RuntimeGitMaterializer {
|
|||||||
match request.repository.source.kind {
|
match request.repository.source.kind {
|
||||||
workspace_api::RepositorySourceKind::LocalPath
|
workspace_api::RepositorySourceKind::LocalPath
|
||||||
| workspace_api::RepositorySourceKind::File
|
| workspace_api::RepositorySourceKind::File
|
||||||
| workspace_api::RepositorySourceKind::Https
|
| workspace_api::RepositorySourceKind::Https => {}
|
||||||
| workspace_api::RepositorySourceKind::Http => {}
|
|
||||||
workspace_api::RepositorySourceKind::Ssh => {
|
workspace_api::RepositorySourceKind::Ssh => {
|
||||||
let ssh = request
|
let ssh = request
|
||||||
.materialization
|
.materialization
|
||||||
@@ -545,10 +540,20 @@ impl RuntimeGitMaterializer {
|
|||||||
validate_ssh_materialization_access(ssh)?;
|
validate_ssh_materialization_access(ssh)?;
|
||||||
}
|
}
|
||||||
workspace_api::RepositorySourceKind::Invalid => {
|
workspace_api::RepositorySourceKind::Invalid => {
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
let is_plain_http = url::Url::parse(&request.repository.source.uri)
|
||||||
"working_directory_repository_source_invalid",
|
.is_ok_and(|url| url.scheme() == "http");
|
||||||
"configured Repository source is invalid and cannot be materialized",
|
let (code, message) = if is_plain_http {
|
||||||
));
|
(
|
||||||
|
"working_directory_repository_plain_http_unsupported",
|
||||||
|
"plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"working_directory_repository_source_invalid",
|
||||||
|
"configured Repository source is invalid and cannot be materialized",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(code, message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
validate_selector(request.repository.selector.as_deref().unwrap_or("HEAD"))
|
validate_selector(request.repository.selector.as_deref().unwrap_or("HEAD"))
|
||||||
@@ -723,8 +728,6 @@ impl RuntimeGitMaterializer {
|
|||||||
host_trust_revision: context
|
host_trust_revision: context
|
||||||
.and_then(|value| value.ssh.as_ref())
|
.and_then(|value| value.ssh.as_ref())
|
||||||
.map(|value| value.host_trust_revision),
|
.map(|value| value.host_trust_revision),
|
||||||
transport_warning: repository_transport_warning(request.repository.source.kind)
|
|
||||||
.map(str::to_string),
|
|
||||||
},
|
},
|
||||||
cleanup_target: WorkingDirectoryCleanupTarget {
|
cleanup_target: WorkingDirectoryCleanupTarget {
|
||||||
kind: "runtime_git_clone".to_string(),
|
kind: "runtime_git_clone".to_string(),
|
||||||
@@ -2029,10 +2032,6 @@ fn validate_ssh_materialization_access(
|
|||||||
Ok(())
|
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(
|
fn validate_remote_source_uri(
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
@@ -2074,12 +2073,10 @@ fn validate_remote_source_uri(
|
|||||||
"remote Repository source URI is invalid",
|
"remote Repository source URI is invalid",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let expected_scheme = match request.repository.source.kind {
|
if request.repository.source.kind != workspace_api::RepositorySourceKind::Https {
|
||||||
workspace_api::RepositorySourceKind::Https => "https",
|
return Ok(());
|
||||||
workspace_api::RepositorySourceKind::Http => "http",
|
}
|
||||||
_ => return Ok(()),
|
if url.scheme() != "https"
|
||||||
};
|
|
||||||
if url.scheme() != expected_scheme
|
|
||||||
|| url.host_str().is_none()
|
|| url.host_str().is_none()
|
||||||
|| url.password().is_some()
|
|| url.password().is_some()
|
||||||
|| url.query().is_some()
|
|| url.query().is_some()
|
||||||
@@ -2212,10 +2209,23 @@ fn read_bounded_command_output(mut reader: impl Read) -> Vec<u8> {
|
|||||||
captured
|
captured
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
thread_local! {
|
||||||
|
static TEST_REPOSITORY_GIT_INVOCATIONS: std::cell::Cell<usize> = 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(
|
fn run_repository_git_stdout(
|
||||||
mut command: Command,
|
mut command: Command,
|
||||||
source_kind: workspace_api::RepositorySourceKind,
|
source_kind: workspace_api::RepositorySourceKind,
|
||||||
) -> Result<String, WorkingDirectoryDiagnostic> {
|
) -> Result<String, WorkingDirectoryDiagnostic> {
|
||||||
|
#[cfg(test)]
|
||||||
|
TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1)));
|
||||||
|
|
||||||
command
|
command
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
@@ -2306,6 +2316,9 @@ fn run_repository_git(
|
|||||||
mut command: Command,
|
mut command: Command,
|
||||||
code: &'static str,
|
code: &'static str,
|
||||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
#[cfg(test)]
|
||||||
|
TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1)));
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "yoi::repository_access",
|
target: "yoi::repository_access",
|
||||||
event = "repository_git_operation_started",
|
event = "repository_git_operation_started",
|
||||||
@@ -3778,15 +3791,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut http = request(repo.path());
|
let mut http = request(repo.path());
|
||||||
http.repository.source = workspace_api::RepositorySource {
|
http.repository.source = serde_json::from_value(serde_json::json!({
|
||||||
kind: workspace_api::RepositorySourceKind::Http,
|
"kind": "http",
|
||||||
uri: "http://example.test/repo.git".to_string(),
|
"uri": "http://example.test/repo.git",
|
||||||
};
|
}))
|
||||||
|
.unwrap();
|
||||||
http.materialization = Some(context(None));
|
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!(
|
assert_eq!(
|
||||||
repository_transport_warning(http.repository.source.kind),
|
error.code,
|
||||||
Some("plain_http_transport")
|
"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"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ssh = request(repo.path());
|
let mut ssh = request(repo.path());
|
||||||
|
|||||||
@@ -365,23 +365,23 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
|
|||||||
///
|
///
|
||||||
/// Local paths remain distinct from network Git transports so callers cannot
|
/// Local paths remain distinct from network Git transports so callers cannot
|
||||||
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
/// 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))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RepositorySourceKind {
|
pub enum RepositorySourceKind {
|
||||||
LocalPath,
|
LocalPath,
|
||||||
File,
|
File,
|
||||||
Ssh,
|
Ssh,
|
||||||
Http,
|
|
||||||
Https,
|
Https,
|
||||||
/// A legacy value that could not be classified during migration. It remains
|
/// 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,
|
Invalid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepositorySourceKind {
|
impl RepositorySourceKind {
|
||||||
pub const fn is_remote(self) -> bool {
|
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 {
|
pub const fn as_str(self) -> &'static str {
|
||||||
@@ -389,7 +389,6 @@ impl RepositorySourceKind {
|
|||||||
Self::LocalPath => "local_path",
|
Self::LocalPath => "local_path",
|
||||||
Self::File => "file",
|
Self::File => "file",
|
||||||
Self::Ssh => "ssh",
|
Self::Ssh => "ssh",
|
||||||
Self::Http => "http",
|
|
||||||
Self::Https => "https",
|
Self::Https => "https",
|
||||||
Self::Invalid => "invalid",
|
Self::Invalid => "invalid",
|
||||||
}
|
}
|
||||||
@@ -400,14 +399,28 @@ impl RepositorySourceKind {
|
|||||||
"local_path" => Self::LocalPath,
|
"local_path" => Self::LocalPath,
|
||||||
"file" => Self::File,
|
"file" => Self::File,
|
||||||
"ssh" => Self::Ssh,
|
"ssh" => Self::Ssh,
|
||||||
"http" => Self::Http,
|
|
||||||
"https" => Self::Https,
|
"https" => Self::Https,
|
||||||
"invalid" => Self::Invalid,
|
"http" | "invalid" => Self::Invalid,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for RepositorySourceKind {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
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.
|
/// Stable Repository source identity stored by Workspace authority.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
@@ -3400,6 +3413,27 @@ mod workdir_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn skill_projection() -> SkillProjectionIdentity {
|
||||||
SkillProjectionIdentity {
|
SkillProjectionIdentity {
|
||||||
config_revision: 42,
|
config_revision: 42,
|
||||||
|
|||||||
@@ -288,13 +288,12 @@ impl RepositoryRegistryReader {
|
|||||||
|
|
||||||
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
||||||
let mut diagnostics = Vec::new();
|
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 {
|
diagnostics.push(RepositoryDiagnostic {
|
||||||
severity: "warning".to_string(),
|
severity: "error".to_string(),
|
||||||
code: "repository_source_insecure_http".to_string(),
|
code: "repository_source_plain_http_unsupported".to_string(),
|
||||||
message:
|
message: "Plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead."
|
||||||
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
|
.to_string(),
|
||||||
.to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let git = match repository.provider.as_str() {
|
let git = match repository.provider.as_str() {
|
||||||
@@ -607,6 +606,39 @@ mod tests {
|
|||||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
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]
|
#[test]
|
||||||
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
|
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
|
||||||
let source = RepositorySource {
|
let source = RepositorySource {
|
||||||
|
|||||||
@@ -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(
|
pub(crate) fn repository_ssh_endpoint(
|
||||||
repository_key: &str,
|
repository_key: &str,
|
||||||
repository_uri: &str,
|
repository_uri: &str,
|
||||||
@@ -200,6 +212,7 @@ fn project_repository_access_evaluation(
|
|||||||
let repository = store
|
let repository = store
|
||||||
.get_repository_by_key(workspace_id, &repository_key)?
|
.get_repository_by_key(workspace_id, &repository_key)?
|
||||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{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 {
|
if repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
"Repository `{repository_key}` is not an ssh:// Repository"
|
"Repository `{repository_key}` is not an ssh:// Repository"
|
||||||
@@ -1956,6 +1969,19 @@ mod tests {
|
|||||||
assert!(!contribution.source.contains("secret_ref"));
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn master_key_is_external_and_stable() {
|
fn master_key_is_external_and_stable() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -76,18 +76,19 @@ pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
|
|||||||
require_remote_host_and_path(&parsed)?;
|
require_remote_host_and_path(&parsed)?;
|
||||||
RepositorySourceKind::Ssh
|
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() {
|
if !parsed.username().is_empty() {
|
||||||
return Err(Error::InvalidInput(
|
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)?;
|
require_remote_host_and_path(&parsed)?;
|
||||||
if parsed.scheme() == "http" {
|
RepositorySourceKind::Https
|
||||||
RepositorySourceKind::Http
|
|
||||||
} else {
|
|
||||||
RepositorySourceKind::Https
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
scheme => {
|
scheme => {
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
@@ -111,6 +112,11 @@ pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_plain_http_repository_source(source: &RepositorySource) -> bool {
|
||||||
|
source.kind == RepositorySourceKind::Invalid
|
||||||
|
&& Url::parse(&source.uri).is_ok_and(|url| url.scheme() == "http")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
|
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
|
||||||
let payload = serde_json::to_vec(source).expect("Repository source serializes");
|
let payload = serde_json::to_vec(source).expect("Repository source serializes");
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
@@ -172,7 +178,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_local_file_ssh_http_and_https_sources_without_io() {
|
fn parses_local_file_ssh_and_https_sources_without_io() {
|
||||||
let cases = [
|
let cases = [
|
||||||
("/runtime/repos/project", RepositorySourceKind::LocalPath),
|
("/runtime/repos/project", RepositorySourceKind::LocalPath),
|
||||||
("file:///runtime/repos/project", RepositorySourceKind::File),
|
("file:///runtime/repos/project", RepositorySourceKind::File),
|
||||||
@@ -184,10 +190,6 @@ mod tests {
|
|||||||
"git@example.test:org/project.git",
|
"git@example.test:org/project.git",
|
||||||
RepositorySourceKind::Ssh,
|
RepositorySourceKind::Ssh,
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"http://git.test/org/project.git",
|
|
||||||
RepositorySourceKind::Http,
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"https://git.test/org/project.git",
|
"https://git.test/org/project.git",
|
||||||
RepositorySourceKind::Https,
|
RepositorySourceKind::Https,
|
||||||
@@ -198,6 +200,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]
|
#[test]
|
||||||
fn rejects_relative_unsupported_and_credential_bearing_sources() {
|
fn rejects_relative_unsupported_and_credential_bearing_sources() {
|
||||||
for source in [
|
for source in [
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ fn repository_local_path(source: &workspace_api::RepositorySource) -> Option<Pat
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|uri| uri.to_file_path().ok()),
|
.and_then(|uri| uri.to_file_path().ok()),
|
||||||
workspace_api::RepositorySourceKind::Ssh
|
workspace_api::RepositorySourceKind::Ssh
|
||||||
| workspace_api::RepositorySourceKind::Http
|
|
||||||
| workspace_api::RepositorySourceKind::Https
|
| workspace_api::RepositorySourceKind::Https
|
||||||
| workspace_api::RepositorySourceKind::Invalid => None,
|
| workspace_api::RepositorySourceKind::Invalid => None,
|
||||||
}
|
}
|
||||||
@@ -10809,9 +10808,8 @@ async fn create_workspace_working_directory(
|
|||||||
"local_path" => workspace_api::RepositorySourceKind::LocalPath,
|
"local_path" => workspace_api::RepositorySourceKind::LocalPath,
|
||||||
"file" => workspace_api::RepositorySourceKind::File,
|
"file" => workspace_api::RepositorySourceKind::File,
|
||||||
"https" => workspace_api::RepositorySourceKind::Https,
|
"https" => workspace_api::RepositorySourceKind::Https,
|
||||||
"http" => workspace_api::RepositorySourceKind::Http,
|
"http" | "invalid" => workspace_api::RepositorySourceKind::Invalid,
|
||||||
"ssh" => workspace_api::RepositorySourceKind::Ssh,
|
"ssh" => workspace_api::RepositorySourceKind::Ssh,
|
||||||
"invalid" => workspace_api::RepositorySourceKind::Invalid,
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(settings_bad_request(
|
return Err(settings_bad_request(
|
||||||
"working_directory_repository_source_invalid",
|
"working_directory_repository_source_invalid",
|
||||||
@@ -22094,6 +22092,22 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(replayed.status(), StatusCode::OK);
|
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
|
let conflict = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(create_repository(serde_json::json!({
|
.oneshot(create_repository(serde_json::json!({
|
||||||
|
|||||||
@@ -237,7 +237,6 @@ export type RepositorySourceKind =
|
|||||||
| "local_path"
|
| "local_path"
|
||||||
| "file"
|
| "file"
|
||||||
| "ssh"
|
| "ssh"
|
||||||
| "http"
|
|
||||||
| "https"
|
| "https"
|
||||||
| "invalid";
|
| "invalid";
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const SOURCE_KINDS = new Set<RepositorySourceKind>([
|
|||||||
"local_path",
|
"local_path",
|
||||||
"file",
|
"file",
|
||||||
"ssh",
|
"ssh",
|
||||||
"http",
|
|
||||||
"https",
|
"https",
|
||||||
"invalid",
|
"invalid",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
const items = stale.items as Array<Record<string, unknown>>;
|
||||||
|
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", () => {
|
Deno.test("stale repository aliases fail closed at the JSON boundary", () => {
|
||||||
const stale = structuredClone(repositoryList) as Record<string, unknown>;
|
const stale = structuredClone(repositoryList) as Record<string, unknown>;
|
||||||
const items = stale.items as Array<Record<string, unknown>>;
|
const items = stale.items as Array<Record<string, unknown>>;
|
||||||
|
|||||||
Reference in New Issue
Block a user