fix: defer local repository validation to runtimes

This commit is contained in:
2026-08-24 13:46:02 +09:00
parent 163a403636
commit 6203316aa1
7 changed files with 58 additions and 98 deletions
+6 -27
View File
@@ -711,36 +711,15 @@ fn infer_workspace_root_from_repositories(
)));
};
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!(
if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
return Err(CliError(format!(
"repository `{}` has an invalid migrated source and cannot be used by serve",
repository.repository_id
))),
)));
}
Ok(ServerConfig::default_workspace_backend_data_root(
&workspace.workspace_id,
))
}
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
@@ -325,6 +325,15 @@ impl RepositoryRegistryReader {
.clone()
.unwrap_or_else(|| repository.id.clone());
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() {
"git" if repository.path.is_none() => {
diagnostics.push(RepositoryDiagnostic {
+9 -16
View File
@@ -271,22 +271,15 @@ impl ServerConfig {
workspace: &WorkspaceRecord,
repositories: Vec<RepositoryRecord>,
) -> Result<Self> {
let primary = repositories
.iter()
.find(|repository| repository.repository_id == "main")
.or_else(|| repositories.first())
.ok_or_else(|| {
Error::Config(format!(
"Workspace {} has no registered repository",
workspace.workspace_id
))
})?;
if repositories.is_empty() {
return Err(Error::Config(format!(
"Workspace {} has no registered repository",
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 workspace_root = workspace_data_root.clone();
let repositories = repositories
.into_iter()
.map(|repository| ConfiguredRepository {
@@ -15077,9 +15070,9 @@ mod tests {
scoped.repositories[0].source.kind,
workspace_api::RepositorySourceKind::Https
);
assert_ne!(
assert_eq!(
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 chrono::{SecondsFormat, Utc};
@@ -6,7 +5,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
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::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> {
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<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)
parse_repository_source(uri)
}
fn workspace_create_fingerprint(
@@ -283,6 +236,7 @@ fn workspace_create_fingerprint(
mod tests {
use super::*;
use crate::store::SqliteWorkspaceStore;
use workspace_api::RepositorySourceKind;
fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
@@ -407,13 +361,18 @@ mod tests {
}
#[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();
assert_eq!(remote.kind, RepositorySourceKind::Https);
let dir = tempfile::tempdir().unwrap();
let non_git = validate_repository_source(&dir.path().display().to_string()).unwrap_err();
assert!(non_git.to_string().contains("not a Git repository"));
let local = validate_repository_source("/runtime-only/missing/repository").unwrap();
assert_eq!(local.kind, RepositorySourceKind::LocalPath);
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]
@@ -269,6 +269,14 @@ export type RepositorySummary = {
display_name: string;
kind: 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;
record_authority: string;
git?: GitRepositorySummary | null;
+1 -1
View File
@@ -155,7 +155,7 @@
<p class="workspace-catalog-eyebrow">New team space</p>
<h2 id="workspace-create-title">Create Workspace</h2>
<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>
</div>
<form onsubmit={submitCreation}>
@@ -16,7 +16,7 @@
<div>
<h3>{data.repository.item.display_name}</h3>
</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>
<dl>
<div>
@@ -27,6 +27,18 @@
<dt>Provider</dt>
<dd>{data.repository.item.provider}</dd>
</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>
<dt>Record authority</dt>
<dd>{data.repository.item.record_authority}</dd>