[F] Name validation

This commit is contained in:
2026-05-10 14:25:28 +00:00
parent acde9f4f67
commit fe1aa19ce3
4 changed files with 116 additions and 18 deletions
+8 -5
View File
@@ -12,13 +12,15 @@ Created becasue github is so unusable and [unreliable](https://red-squares.cian.
- **read-write mirrors**: Make changes from any provider, and the changes will sync to the others - **read-write mirrors**: Make changes from any provider, and the changes will sync to the others
- **webhook support**: Sync right after push, reduce potential divergence window - **webhook support**: Sync right after push, reduce potential divergence window
- **conflict handling**: Rebase or open pull requests when two platforms diverge - **conflict handling**: Rebase or open pull requests when two platforms diverge
- **tracks deletions**: Delete branches/repos across platforms when they are deleted from one platform - **tracks deletions**: Branches/repo deletions sync across platforms (with backup)
- **selective sync**: Sync subset of repos by regex white/black list, or by private/public visibility - **selective sync**: Sync subset of repos by regex white/black list, or by private/public visibility
- **multithreaded**: Process multiple repos simultaneously!
Supported platforms: GitHub, GitLab, Gitea, Forgejo Supported platforms: GitHub, GitLab, Gitea, Forgejo
> [!NOTE] > [!NOTE]
> Meow > My cat made this codebase, meow
## Install ## Install
@@ -177,6 +179,10 @@ To move installed hooks to a new public URL, use `webhook update`. It removes ho
refray webhook update https://new.example.com/webhook refray webhook update https://new.example.com/webhook
``` ```
## Issues and Pull Requests
Issues and pull requests are not mirrored.
## Sync Semantics ## Sync Semantics
Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints. Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints.
@@ -245,6 +251,3 @@ REFRAY_E2E_ALLOW_DESTRUCTIVE=1 \
By default cleanup only deletes repositories named `refray-e2e-*`. To start by deleting every owned repository visible to the configured accounts, set `REFRAY_E2E_CLEAR_ALL_REPOS=DELETE_ALL_OWNED_REPOS`. Provider skips (`REFRAY_E2E_SKIP_GITHUB`, `REFRAY_E2E_SKIP_GITLAB`, `REFRAY_E2E_SKIP_GITEA`, `REFRAY_E2E_SKIP_FORGEJO`) and `REFRAY_E2E_ALLOW_PARTIAL=1` are available for local debugging, but the full support check should run with all four providers. By default cleanup only deletes repositories named `refray-e2e-*`. To start by deleting every owned repository visible to the configured accounts, set `REFRAY_E2E_CLEAR_ALL_REPOS=DELETE_ALL_OWNED_REPOS`. Provider skips (`REFRAY_E2E_SKIP_GITHUB`, `REFRAY_E2E_SKIP_GITLAB`, `REFRAY_E2E_SKIP_GITEA`, `REFRAY_E2E_SKIP_FORGEJO`) and `REFRAY_E2E_ALLOW_PARTIAL=1` are available for local debugging, but the full support check should run with all four providers.
## Issues and Pull Requests
Issues and pull requests are not mirrored.
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

+75 -13
View File
@@ -11,7 +11,7 @@ use regex::Regex;
use crate::config::{ use crate::config::{
Config, ConflictResolutionStrategy, DEFAULT_JOBS, EndpointConfig, MirrorConfig, NamespaceKind, Config, ConflictResolutionStrategy, DEFAULT_JOBS, EndpointConfig, MirrorConfig, NamespaceKind,
RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config, ProviderKind, RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config,
}; };
use crate::git::{ use crate::git::{
BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RefBackup, RemoteSpec, BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RefBackup, RemoteSpec,
@@ -530,7 +530,13 @@ fn ensure_missing_repos(
let description = template.and_then(|repo| repo.description); let description = template.and_then(|repo| repo.description);
let expected_private = matches!(create_visibility, Visibility::Private); let expected_private = matches!(create_visibility, Visibility::Private);
let create_jobs = missing.into_iter().enumerate().collect::<Vec<_>>(); let create_jobs = missing.into_iter().enumerate().collect::<Vec<_>>();
let mut created = crate::parallel::map(create_jobs, context.jobs, |(index, endpoint)| { let created = crate::parallel::map(create_jobs, context.jobs, |(index, endpoint)| {
let site = context.config.site(&endpoint.site).unwrap();
if site.provider == ProviderKind::Gitlab && !is_supported_gitlab_project_path(repo_name) {
log_invalid_gitlab_project_name_skip(repo_name, &endpoint);
return Ok(None);
}
crate::logln!( crate::logln!(
" {} {} {}", " {} {} {}",
style("create").green().bold(), style("create").green().bold(),
@@ -538,16 +544,27 @@ fn ensure_missing_repos(
style(format!("on {}", endpoint.label())).dim() style(format!("on {}", endpoint.label())).dim()
); );
let site = context.config.site(&endpoint.site).unwrap();
let client = ProviderClient::new(site)?; let client = ProviderClient::new(site)?;
let created = client let created = match client.create_repo(
.create_repo( &endpoint,
&endpoint, repo_name,
repo_name, &create_visibility,
&create_visibility, description.as_deref(),
description.as_deref(), ) {
) Ok(created) => created,
.with_context(|| format!("failed to create {} on {}", repo_name, endpoint.label()))?; Err(error)
if site.provider == ProviderKind::Gitlab
&& is_gitlab_invalid_project_name_error(&error) =>
{
log_invalid_gitlab_project_name_skip(repo_name, &endpoint);
return Ok(None);
}
Err(error) => {
return Err(error).with_context(|| {
format!("failed to create {} on {}", repo_name, endpoint.label())
});
}
};
if created.private != expected_private { if created.private != expected_private {
crate::logln!( crate::logln!(
" {} created {} on {}, but provider reported a different visibility than requested", " {} created {} on {}, but provider reported a different visibility than requested",
@@ -556,14 +573,15 @@ fn ensure_missing_repos(
style(endpoint.label()).dim() style(endpoint.label()).dim()
); );
} }
Ok(( Ok(Some((
index, index,
EndpointRepo { EndpointRepo {
endpoint, endpoint,
repo: created, repo: created,
}, },
)) )))
})?; })?;
let mut created = created.into_iter().flatten().collect::<Vec<_>>();
created.sort_by_key(|(index, _)| *index); created.sort_by_key(|(index, _)| *index);
let created = created let created = created
.into_iter() .into_iter()
@@ -586,6 +604,50 @@ fn visibility_for_created_repo(mirror: &MirrorConfig, template: Option<&RemoteRe
.unwrap_or_else(|| mirror.visibility.clone()) .unwrap_or_else(|| mirror.visibility.clone())
} }
fn is_supported_gitlab_project_path(name: &str) -> bool {
if name.is_empty()
|| matches!(name.chars().next(), Some('-' | '_' | '.'))
|| matches!(name.chars().last(), Some('-' | '_' | '.'))
{
return false;
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".git") || lower.ends_with(".atom") {
return false;
}
name.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}
fn log_invalid_gitlab_project_name_skip(repo_name: &str, endpoint: &EndpointConfig) {
crate::logln!(
" {} {} {}",
style("skip").yellow().bold(),
style(repo_name).cyan(),
style(format!(
"on {}: invalid GitLab project name/path",
endpoint.label()
))
.dim()
);
}
fn is_gitlab_invalid_project_name_error(error: &anyhow::Error) -> bool {
let text = error
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
.to_ascii_lowercase();
text.contains("400 bad request")
&& (text.contains("project_namespace.path")
|| text.contains("can only include non-accented letters")
|| text.contains("must not start with")
|| text.contains("must start with a letter"))
}
struct RepoSyncContext<'a> { struct RepoSyncContext<'a> {
config: &'a Config, config: &'a Config,
mirror: &'a MirrorConfig, mirror: &'a MirrorConfig,
+33
View File
@@ -532,6 +532,39 @@ fn created_repo_visibility_falls_back_to_config_without_template() {
); );
} }
#[test]
fn gitlab_invalid_project_name_errors_are_skippable() {
let error = anyhow::Error::msg(
r#"POST https://gitlab.com/api/v4/projects returned 400 Bad Request: {"message":{"project_namespace.path":["can only include non-accented letters, digits, '_', '-' and '.'. It must not start with '-', '_', or '.'."],"name":["can contain only letters, digits, emoji, '_', '.', '+', dashes, or spaces. It must start with a letter, digit, emoji, or '_'."]}}"#,
);
assert!(is_gitlab_invalid_project_name_error(&error));
}
#[test]
fn gitlab_project_path_validation_matches_create_constraints() {
for name in ["Kairos", "needLe", "amaoke.app", "repo_1", "repo-1"] {
assert!(is_supported_gitlab_project_path(name), "{name}");
}
for name in [
"",
".github",
"_private",
"-draft",
"repo.",
"repo_",
"repo-",
"repo.git",
"repo.atom",
"has space",
"has+plus",
"荞麦main",
] {
assert!(!is_supported_gitlab_project_path(name), "{name}");
}
}
fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState { fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState {
RemoteRefState { RemoteRefState {
hash: hash.to_string(), hash: hash.to_string(),