fix: defer local repository validation to runtimes
This commit is contained in:
@@ -711,36 +711,15 @@ fn infer_workspace_root_from_repositories(
|
|||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
match repository.source.kind {
|
if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
|
||||||
workspace_api::RepositorySourceKind::LocalPath => {
|
|
||||||
let repository_path = PathBuf::from(&repository.source.uri);
|
|
||||||
if !repository_path.is_absolute() {
|
|
||||||
return Err(CliError(format!(
|
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 `{}` has an invalid migrated source and cannot be used by serve",
|
||||||
repository.repository_id
|
repository.repository_id
|
||||||
))),
|
)));
|
||||||
}
|
}
|
||||||
|
Ok(ServerConfig::default_workspace_backend_data_root(
|
||||||
|
&workspace.workspace_id,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
|
|||||||
@@ -325,6 +325,15 @@ impl RepositoryRegistryReader {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| repository.id.clone());
|
.unwrap_or_else(|| repository.id.clone());
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
|
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
let git = match repository.provider.as_str() {
|
let git = match repository.provider.as_str() {
|
||||||
"git" if repository.path.is_none() => {
|
"git" if repository.path.is_none() => {
|
||||||
diagnostics.push(RepositoryDiagnostic {
|
diagnostics.push(RepositoryDiagnostic {
|
||||||
|
|||||||
@@ -271,22 +271,15 @@ impl ServerConfig {
|
|||||||
workspace: &WorkspaceRecord,
|
workspace: &WorkspaceRecord,
|
||||||
repositories: Vec<RepositoryRecord>,
|
repositories: Vec<RepositoryRecord>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let primary = repositories
|
if repositories.is_empty() {
|
||||||
.iter()
|
return Err(Error::Config(format!(
|
||||||
.find(|repository| repository.repository_id == "main")
|
|
||||||
.or_else(|| repositories.first())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"Workspace {} has no registered repository",
|
"Workspace {} has no registered repository",
|
||||||
workspace.workspace_id
|
workspace.workspace_id
|
||||||
))
|
)));
|
||||||
})?;
|
}
|
||||||
let workspace_data_root =
|
let workspace_data_root =
|
||||||
Self::default_workspace_backend_data_root(&workspace.workspace_id);
|
Self::default_workspace_backend_data_root(&workspace.workspace_id);
|
||||||
let primary_path = repository_local_path(&primary.source);
|
let workspace_root = workspace_data_root.clone();
|
||||||
let workspace_root = primary_path
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| workspace_data_root.clone());
|
|
||||||
let repositories = repositories
|
let repositories = repositories
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|repository| ConfiguredRepository {
|
.map(|repository| ConfiguredRepository {
|
||||||
@@ -15077,9 +15070,9 @@ mod tests {
|
|||||||
scoped.repositories[0].source.kind,
|
scoped.repositories[0].source.kind,
|
||||||
workspace_api::RepositorySourceKind::Https
|
workspace_api::RepositorySourceKind::Https
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_eq!(
|
||||||
scoped.workspace_root,
|
scoped.workspace_root,
|
||||||
PathBuf::from("https://example.test/org/repository.git")
|
ServerConfig::default_workspace_backend_data_root("remote-workspace")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
@@ -6,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use workspace_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind};
|
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||||
|
|
||||||
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
|
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
@@ -200,53 +199,7 @@ fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result<St
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_repository_source(uri: &str) -> Result<RepositorySource> {
|
fn validate_repository_source(uri: &str) -> Result<RepositorySource> {
|
||||||
let mut source = parse_repository_source(uri)?;
|
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<PathBuf> {
|
|
||||||
let path = path.canonicalize().map_err(|error| {
|
|
||||||
Error::InvalidInput(format!("initial repository path is unavailable: {error}"))
|
|
||||||
})?;
|
|
||||||
if !path.is_dir() {
|
|
||||||
return Err(Error::InvalidInput(
|
|
||||||
"initial repository path must be a directory".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let normal_git = path.join(".git").exists();
|
|
||||||
let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir();
|
|
||||||
if !normal_git && !bare_git {
|
|
||||||
return Err(Error::InvalidInput(
|
|
||||||
"initial repository path is not a Git repository".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_create_fingerprint(
|
fn workspace_create_fingerprint(
|
||||||
@@ -283,6 +236,7 @@ fn workspace_create_fingerprint(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::SqliteWorkspaceStore;
|
use crate::store::SqliteWorkspaceStore;
|
||||||
|
use workspace_api::RepositorySourceKind;
|
||||||
|
|
||||||
fn git_repository() -> tempfile::TempDir {
|
fn git_repository() -> tempfile::TempDir {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -407,13 +361,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repository_intent_accepts_remote_and_rejects_non_git_local_paths() {
|
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
|
||||||
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
|
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
|
||||||
assert_eq!(remote.kind, RepositorySourceKind::Https);
|
assert_eq!(remote.kind, RepositorySourceKind::Https);
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let local = validate_repository_source("/runtime-only/missing/repository").unwrap();
|
||||||
let non_git = validate_repository_source(&dir.path().display().to_string()).unwrap_err();
|
assert_eq!(local.kind, RepositorySourceKind::LocalPath);
|
||||||
assert!(non_git.to_string().contains("not a Git repository"));
|
assert_eq!(local.uri, "/runtime-only/missing/repository");
|
||||||
|
|
||||||
|
let file = validate_repository_source("file:///runtime-only/missing/repository").unwrap();
|
||||||
|
assert_eq!(file.kind, RepositorySourceKind::File);
|
||||||
|
|
||||||
|
assert!(validate_repository_source("relative/repository").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -269,6 +269,14 @@ export type RepositorySummary = {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
source: {
|
||||||
|
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid";
|
||||||
|
uri: string;
|
||||||
|
};
|
||||||
|
source_revision: number;
|
||||||
|
source_fingerprint: string;
|
||||||
|
observed_status: "unverified" | "ready" | "invalid";
|
||||||
|
observed_at?: string | null;
|
||||||
default_selector?: string | null;
|
default_selector?: string | null;
|
||||||
record_authority: string;
|
record_authority: string;
|
||||||
git?: GitRepositorySummary | null;
|
git?: GitRepositorySummary | null;
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
<p class="workspace-catalog-eyebrow">New team space</p>
|
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||||
<h2 id="workspace-create-title">Create Workspace</h2>
|
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||||
<p>
|
<p>
|
||||||
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.
|
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. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onsubmit={submitCreation}>
|
<form onsubmit={submitCreation}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3>{data.repository.item.display_name}</h3>
|
<h3>{data.repository.item.display_name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
<span class="status-pill" class:warn={data.repository.item.observed_status !== 'ready'}>{data.repository.item.observed_status}</span>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
@@ -27,6 +27,18 @@
|
|||||||
<dt>Provider</dt>
|
<dt>Provider</dt>
|
||||||
<dd>{data.repository.item.provider}</dd>
|
<dd>{data.repository.item.provider}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Source</dt>
|
||||||
|
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Source revision</dt>
|
||||||
|
<dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Observed</dt>
|
||||||
|
<dd>{data.repository.item.observed_at ?? 'not observed'}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Record authority</dt>
|
<dt>Record authority</dt>
|
||||||
<dd>{data.repository.item.record_authority}</dd>
|
<dd>{data.repository.item.record_authority}</dd>
|
||||||
|
|||||||
Reference in New Issue
Block a user