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
+5 -26
View File
@@ -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 {
+7 -14
View File
@@ -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;
+1 -1
View File
@@ -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>