diff --git a/backend/LexBoxApi/Services/HgService.cs b/backend/LexBoxApi/Services/HgService.cs index 4e314fa48..0b4288650 100644 --- a/backend/LexBoxApi/Services/HgService.cs +++ b/backend/LexBoxApi/Services/HgService.cs @@ -18,10 +18,10 @@ namespace LexBoxApi.Services; -public partial class HgService : IHgService, IHostedService +public class HgService : IHgService, IHostedService { - private const string DELETED_REPO_FOLDER = "_____deleted_____"; - private const string TEMP_REPO_FOLDER = "_____temp_____"; + private const string DELETED_REPO_FOLDER = ProjectCode.DELETED_REPO_FOLDER; + private const string TEMP_REPO_FOLDER = ProjectCode.TEMP_REPO_FOLDER; private const string AllZeroHash = "0000000000000000000000000000000000000000"; @@ -36,17 +36,12 @@ public HgService(IOptions options, IHttpClientFactory clientFactory, I _hgClient = new(() => clientFactory.CreateClient("HgWeb")); } - [GeneratedRegex(Project.ProjectCodeRegex)] - private static partial Regex ProjectCodeRegex(); + public static string PrefixRepoRequestPath(ProjectCode code) => $"{code.Value[0]}/{code}"; + private string PrefixRepoFilePath(ProjectCode code) => Path.Combine(_options.Value.RepoPath, code.Value[0].ToString(), code.Value); + private string GetTempRepoPath(ProjectCode code, string reason) => Path.Combine(_options.Value.RepoPath, TEMP_REPO_FOLDER, $"{code}__{reason}__{FileUtils.ToTimestamp(DateTimeOffset.UtcNow)}"); - public static string PrefixRepoRequestPath(string code) => $"{code[0]}/{code}"; - private string PrefixRepoFilePath(string code) => Path.Combine(_options.Value.RepoPath, code[0].ToString(), code); - private string GetTempRepoPath(string code, string reason) => Path.Combine(_options.Value.RepoPath, TEMP_REPO_FOLDER, $"{code}__{reason}__{FileUtils.ToTimestamp(DateTimeOffset.UtcNow)}"); - - private async Task GetResponseMessage(string code, string requestPath) + private async Task GetResponseMessage(ProjectCode code, string requestPath) { - if (!ProjectCodeRegex().IsMatch(code)) - throw new ArgumentException($"Invalid project code: {code}."); var client = _hgClient.Value; var urlPrefix = DetermineProjectUrlPrefix(HgType.hgWeb, _options.Value); @@ -60,9 +55,8 @@ private async Task GetResponseMessage(string code, string r /// Note: The repo is unstable and potentially unavailable for a short while after creation, so don't read from it right away. /// See: https://github.com/sillsdev/languageforge-lexbox/issues/173#issuecomment-1665478630 /// - public async Task InitRepo(string code) + public async Task InitRepo(ProjectCode code) { - AssertIsSafeRepoName(code); if (Directory.Exists(PrefixRepoFilePath(code))) throw new AlreadyExistsException($"Repo already exists: {code}."); await Task.Run(() => @@ -83,12 +77,12 @@ private void InitRepoAt(DirectoryInfo repoDirectory) ); } - public async Task DeleteRepo(string code) + public async Task DeleteRepo(ProjectCode code) { await Task.Run(() => Directory.Delete(PrefixRepoFilePath(code), true)); } - public BackupExecutor? BackupRepo(string code) + public BackupExecutor? BackupRepo(ProjectCode code) { string repoPath = PrefixRepoFilePath(code); if (!Directory.Exists(repoPath)) @@ -101,7 +95,7 @@ public async Task DeleteRepo(string code) }, token)); } - public async Task ResetRepo(string code) + public async Task ResetRepo(ProjectCode code) { var tmpRepo = new DirectoryInfo(GetTempRepoPath(code, "reset")); InitRepoAt(tmpRepo); @@ -112,7 +106,7 @@ public async Task ResetRepo(string code) await WaitForRepoEmptyState(code, RepoEmptyState.Empty); } - public async Task FinishReset(string code, Stream zipFile) + public async Task FinishReset(ProjectCode code, Stream zipFile) { var tempRepoPath = GetTempRepoPath(code, "upload"); var tempRepo = Directory.CreateDirectory(tempRepoPath); @@ -167,7 +161,7 @@ await Task.Run(() => } - public Task RevertRepo(string code, string revHash) + public Task RevertRepo(ProjectCode code, string revHash) { throw new NotImplementedException(); // Steps: @@ -179,7 +173,7 @@ public Task RevertRepo(string code, string revHash) // Will need an SSH key as a k8s secret, put it into authorized_keys on the hgweb side so that lexbox can do "ssh hgweb hg clone ..." } - public async Task SoftDeleteRepo(string code, string deletedRepoSuffix) + public async Task SoftDeleteRepo(ProjectCode code, string deletedRepoSuffix) { var deletedRepoName = $"{code}__{deletedRepoSuffix}"; await Task.Run(() => @@ -216,12 +210,12 @@ private static void SetPermissionsRecursively(DirectoryInfo rootDir) } } - public bool HasAbandonedTransactions(string projectCode) + public bool HasAbandonedTransactions(ProjectCode projectCode) { return Path.Exists(Path.Combine(PrefixRepoFilePath(projectCode), ".hg", "store", "journal")); } - public bool RepoIsLocked(string projectCode) + public bool RepoIsLocked(ProjectCode projectCode) { return Path.Exists(Path.Combine(PrefixRepoFilePath(projectCode), ".hg", "store", "lock")); } @@ -232,7 +226,7 @@ public bool RepoIsLocked(string projectCode) return json?["entries"]?.AsArray().FirstOrDefault()?["node"].Deserialize(); } - public async Task GetLastCommitTimeFromHg(string projectCode) + public async Task GetLastCommitTimeFromHg(ProjectCode projectCode) { var json = await GetCommit(projectCode, "tip"); //format is this: [1678687688, offset] offset is @@ -247,13 +241,13 @@ public bool RepoIsLocked(string projectCode) return date.ToUniversalTime(); } - private async Task GetCommit(string projectCode, string rev) + private async Task GetCommit(ProjectCode projectCode, string rev) { var response = await GetResponseMessage(projectCode, $"log?style=json-lex&rev={rev}"); return await response.Content.ReadFromJsonAsync(); } - public async Task GetChangesets(string projectCode) + public async Task GetChangesets(ProjectCode projectCode) { var response = await GetResponseMessage(projectCode, "log?style=json-lex"); var logResponse = await response.Content.ReadFromJsonAsync(); @@ -261,11 +255,11 @@ public async Task GetChangesets(string projectCode) } - public Task VerifyRepo(string code, CancellationToken token) + public Task VerifyRepo(ProjectCode code, CancellationToken token) { return ExecuteHgCommandServerCommand(code, "verify", token); } - public async Task ExecuteHgRecover(string code, CancellationToken token) + public async Task ExecuteHgRecover(ProjectCode code, CancellationToken token) { var response = await ExecuteHgCommandServerCommand(code, "recover", token); // Can't do this with a streamed response, unfortunately. Will have to do it client-side. @@ -273,7 +267,7 @@ public async Task ExecuteHgRecover(string code, CancellationToken t return response; } - public Task InvalidateDirCache(string code, CancellationToken token = default) + public Task InvalidateDirCache(ProjectCode code, CancellationToken token = default) { var repoPath = Path.Join(PrefixRepoFilePath(code)); if (Directory.Exists(repoPath)) @@ -293,13 +287,13 @@ public Task InvalidateDirCache(string code, CancellationToken token return result; } - public async Task GetTipHash(string code, CancellationToken token = default) + public async Task GetTipHash(ProjectCode code, CancellationToken token = default) { var content = await ExecuteHgCommandServerCommand(code, "tip", token); return await content.ReadAsStringAsync(); } - private async Task WaitForRepoEmptyState(string code, RepoEmptyState expectedState, int timeoutMs = 30_000, CancellationToken token = default) + private async Task WaitForRepoEmptyState(ProjectCode code, RepoEmptyState expectedState, int timeoutMs = 30_000, CancellationToken token = default) { // Set timeout so unforeseen errors can't cause an infinite loop using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(token); @@ -325,7 +319,7 @@ private async Task WaitForRepoEmptyState(string code, RepoEmptyState expectedSta catch (OperationCanceledException) { } } - public async Task GetLexEntryCount(string code, ProjectType projectType) + public async Task GetLexEntryCount(ProjectCode code, ProjectType projectType) { var command = projectType switch { @@ -346,7 +340,7 @@ public async Task HgCommandHealth() return version.Trim(); } - private async Task ExecuteHgCommandServerCommand(string code, string command, CancellationToken token) + private async Task ExecuteHgCommandServerCommand(ProjectCode code, string command, CancellationToken token) { var httpClient = _hgClient.Value; var baseUri = _options.Value.HgCommandServer; @@ -355,18 +349,7 @@ private async Task ExecuteHgCommandServerCommand(string code, strin return response.Content; } - private static readonly string[] SpecialDirectoryNames = [DELETED_REPO_FOLDER, TEMP_REPO_FOLDER]; - private static readonly HashSet InvalidRepoNames = [.. SpecialDirectoryNames, "api"]; - - private void AssertIsSafeRepoName(string name) - { - if (InvalidRepoNames.Contains(name, StringComparer.OrdinalIgnoreCase)) - throw new ArgumentException($"Invalid repo name: {name}."); - if (!ProjectCodeRegex().IsMatch(name)) - throw new ArgumentException($"Invalid repo name: {name}."); - } - - public async Task DetermineProjectType(string projectCode) + public async Task DetermineProjectType(ProjectCode projectCode) { var response = await GetResponseMessage(projectCode, "file/tip?style=json-lex"); var parsed = await response.Content.ReadFromJsonAsync(); @@ -433,7 +416,7 @@ public static string DetermineProjectUrlPrefix(HgType type, HgConfig hgConfig) public Task StartAsync(CancellationToken cancellationToken) { - var repoContainerDirectories = SpecialDirectoryNames + var repoContainerDirectories = ProjectCode.SpecialDirectoryNames .Concat(Enumerable.Range('a', 'z' - 'a' + 1).Select(c => ((char)c).ToString())) .Concat(Enumerable.Range(0, 10).Select(c => c.ToString())); diff --git a/backend/LexCore/Entities/ProjectCode.cs b/backend/LexCore/Entities/ProjectCode.cs new file mode 100644 index 000000000..25153eeec --- /dev/null +++ b/backend/LexCore/Entities/ProjectCode.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; + +namespace LexCore.Entities; + +public readonly partial record struct ProjectCode +{ + public ProjectCode() + { + throw new NotSupportedException("Default constructor is not supported."); + } + + public ProjectCode(string value) + { + AssertIsSafeRepoName(value); + Value = value; + } + + public string Value { get; } + public static implicit operator ProjectCode(string code) => new(code); + + public override string ToString() + { + return Value; + } + + public const string DELETED_REPO_FOLDER = "_____deleted_____"; + public const string TEMP_REPO_FOLDER = "_____temp_____"; + public static readonly string[] SpecialDirectoryNames = [DELETED_REPO_FOLDER, TEMP_REPO_FOLDER]; + + private static readonly HashSet InvalidRepoNames = + new([.. SpecialDirectoryNames, "api"], StringComparer.OrdinalIgnoreCase); + + private void AssertIsSafeRepoName(string name) + { + if (InvalidRepoNames.Contains(name)) + throw new ArgumentException($"Invalid repo name: {name}."); + if (!ProjectCodeRegex().IsMatch(name)) + throw new ArgumentException($"Invalid repo name: {name}."); + } + + [GeneratedRegex(Project.ProjectCodeRegex)] + private static partial Regex ProjectCodeRegex(); +} diff --git a/backend/LexCore/ServiceInterfaces/IHgService.cs b/backend/LexCore/ServiceInterfaces/IHgService.cs index cc4703b7a..60876ea06 100644 --- a/backend/LexCore/ServiceInterfaces/IHgService.cs +++ b/backend/LexCore/ServiceInterfaces/IHgService.cs @@ -5,21 +5,21 @@ namespace LexCore.ServiceInterfaces; public record BackupExecutor(Func ExecuteBackup); public interface IHgService { - Task InitRepo(string code); - Task GetLastCommitTimeFromHg(string projectCode); - Task GetChangesets(string projectCode); - Task DetermineProjectType(string projectCode); - Task DeleteRepo(string code); - Task SoftDeleteRepo(string code, string deletedRepoSuffix); - BackupExecutor? BackupRepo(string code); - Task ResetRepo(string code); - Task FinishReset(string code, Stream zipFile); - Task VerifyRepo(string code, CancellationToken token); - Task GetTipHash(string code, CancellationToken token = default); - Task GetLexEntryCount(string code, ProjectType projectType); + Task InitRepo(ProjectCode code); + Task GetLastCommitTimeFromHg(ProjectCode projectCode); + Task GetChangesets(ProjectCode projectCode); + Task DetermineProjectType(ProjectCode projectCode); + Task DeleteRepo(ProjectCode code); + Task SoftDeleteRepo(ProjectCode code, string deletedRepoSuffix); + BackupExecutor? BackupRepo(ProjectCode code); + Task ResetRepo(ProjectCode code); + Task FinishReset(ProjectCode code, Stream zipFile); + Task VerifyRepo(ProjectCode code, CancellationToken token); + Task GetTipHash(ProjectCode code, CancellationToken token = default); + Task GetLexEntryCount(ProjectCode code, ProjectType projectType); Task GetRepositoryIdentifier(Project project); - Task ExecuteHgRecover(string code, CancellationToken token); - Task InvalidateDirCache(string code, CancellationToken token = default); - bool HasAbandonedTransactions(string projectCode); + Task ExecuteHgRecover(ProjectCode code, CancellationToken token); + Task InvalidateDirCache(ProjectCode code, CancellationToken token = default); + bool HasAbandonedTransactions(ProjectCode projectCode); Task HgCommandHealth(); } diff --git a/backend/Testing/LexCore/ProjectCodeTests.cs b/backend/Testing/LexCore/ProjectCodeTests.cs new file mode 100644 index 000000000..49b387bfa --- /dev/null +++ b/backend/Testing/LexCore/ProjectCodeTests.cs @@ -0,0 +1,35 @@ +using LexCore.Entities; +using Shouldly; + +namespace Testing.LexCore; + +public class ProjectCodeTests +{ + [Theory] + [InlineData("_____deleted_____")] + [InlineData("_____temp_____")] + [InlineData("api")] + [InlineData("../hacker")] + [InlineData("hacker/test")] + [InlineData("/hacker")] + [InlineData(@"hacker\test")] + [InlineData("❌")] + [InlineData("!")] + [InlineData("#")] + [InlineData("-not-start-with-dash")] + public void InvalidCodesThrows(string code) + { + Assert.Throws(() => new ProjectCode(code)); + } + + [Theory] + [InlineData("test-name123")] + [InlineData("123-name")] + [InlineData("test")] + public void ValidCodes(string code) + { + var projectCode = new ProjectCode(code); + projectCode.Value.ShouldBe(code); + projectCode.ToString().ShouldBe(code); + } +}