Skip to content

Commit

Permalink
replace string with ProjectCode in HgService to force validation and …
Browse files Browse the repository at this point in the history
…to avoid requiring validation anywhere in HgService. Also add some tests for ProjectCode validation. closes #823
  • Loading branch information
hahn-kev committed May 30, 2024
1 parent a99490e commit cf3b206
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 60 deletions.
73 changes: 28 additions & 45 deletions backend/LexBoxApi/Services/HgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -36,17 +36,12 @@ public HgService(IOptions<HgConfig> 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<HttpResponseMessage> GetResponseMessage(string code, string requestPath)
private async Task<HttpResponseMessage> 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);
Expand All @@ -60,9 +55,8 @@ private async Task<HttpResponseMessage> 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
/// </summary>
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(() =>
Expand All @@ -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))
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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:
Expand All @@ -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(() =>
Expand Down Expand Up @@ -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"));
}
Expand All @@ -232,7 +226,7 @@ public bool RepoIsLocked(string projectCode)
return json?["entries"]?.AsArray().FirstOrDefault()?["node"].Deserialize<string>();
}

public async Task<DateTimeOffset?> GetLastCommitTimeFromHg(string projectCode)
public async Task<DateTimeOffset?> GetLastCommitTimeFromHg(ProjectCode projectCode)
{
var json = await GetCommit(projectCode, "tip");
//format is this: [1678687688, offset] offset is
Expand All @@ -247,33 +241,33 @@ public bool RepoIsLocked(string projectCode)
return date.ToUniversalTime();
}

private async Task<JsonObject?> GetCommit(string projectCode, string rev)
private async Task<JsonObject?> GetCommit(ProjectCode projectCode, string rev)
{
var response = await GetResponseMessage(projectCode, $"log?style=json-lex&rev={rev}");
return await response.Content.ReadFromJsonAsync<JsonObject>();
}

public async Task<Changeset[]> GetChangesets(string projectCode)
public async Task<Changeset[]> GetChangesets(ProjectCode projectCode)
{
var response = await GetResponseMessage(projectCode, "log?style=json-lex");
var logResponse = await response.Content.ReadFromJsonAsync<LogResponse>();
return logResponse?.Changesets ?? Array.Empty<Changeset>();
}


public Task<HttpContent> VerifyRepo(string code, CancellationToken token)
public Task<HttpContent> VerifyRepo(ProjectCode code, CancellationToken token)
{
return ExecuteHgCommandServerCommand(code, "verify", token);
}
public async Task<HttpContent> ExecuteHgRecover(string code, CancellationToken token)
public async Task<HttpContent> 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.
// if (string.IsNullOrWhiteSpace(response)) return "Nothing to recover";
return response;
}

public Task<HttpContent> InvalidateDirCache(string code, CancellationToken token = default)
public Task<HttpContent> InvalidateDirCache(ProjectCode code, CancellationToken token = default)
{
var repoPath = Path.Join(PrefixRepoFilePath(code));
if (Directory.Exists(repoPath))
Expand All @@ -293,13 +287,13 @@ public Task<HttpContent> InvalidateDirCache(string code, CancellationToken token
return result;
}

public async Task<string> GetTipHash(string code, CancellationToken token = default)
public async Task<string> 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);
Expand All @@ -325,7 +319,7 @@ private async Task WaitForRepoEmptyState(string code, RepoEmptyState expectedSta
catch (OperationCanceledException) { }
}

public async Task<int?> GetLexEntryCount(string code, ProjectType projectType)
public async Task<int?> GetLexEntryCount(ProjectCode code, ProjectType projectType)
{
var command = projectType switch
{
Expand All @@ -346,7 +340,7 @@ public async Task<string> HgCommandHealth()
return version.Trim();
}

private async Task<HttpContent> ExecuteHgCommandServerCommand(string code, string command, CancellationToken token)
private async Task<HttpContent> ExecuteHgCommandServerCommand(ProjectCode code, string command, CancellationToken token)
{
var httpClient = _hgClient.Value;
var baseUri = _options.Value.HgCommandServer;
Expand All @@ -355,18 +349,7 @@ private async Task<HttpContent> ExecuteHgCommandServerCommand(string code, strin
return response.Content;
}

private static readonly string[] SpecialDirectoryNames = [DELETED_REPO_FOLDER, TEMP_REPO_FOLDER];
private static readonly HashSet<string> 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<ProjectType> DetermineProjectType(string projectCode)
public async Task<ProjectType> DetermineProjectType(ProjectCode projectCode)
{
var response = await GetResponseMessage(projectCode, "file/tip?style=json-lex");
var parsed = await response.Content.ReadFromJsonAsync<BrowseResponse>();
Expand Down Expand Up @@ -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()));

Expand Down
43 changes: 43 additions & 0 deletions backend/LexCore/Entities/ProjectCode.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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();
}
30 changes: 15 additions & 15 deletions backend/LexCore/ServiceInterfaces/IHgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@ namespace LexCore.ServiceInterfaces;
public record BackupExecutor(Func<Stream, CancellationToken, Task> ExecuteBackup);
public interface IHgService
{
Task InitRepo(string code);
Task<DateTimeOffset?> GetLastCommitTimeFromHg(string projectCode);
Task<Changeset[]> GetChangesets(string projectCode);
Task<ProjectType> 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<HttpContent> VerifyRepo(string code, CancellationToken token);
Task<string> GetTipHash(string code, CancellationToken token = default);
Task<int?> GetLexEntryCount(string code, ProjectType projectType);
Task InitRepo(ProjectCode code);
Task<DateTimeOffset?> GetLastCommitTimeFromHg(ProjectCode projectCode);
Task<Changeset[]> GetChangesets(ProjectCode projectCode);
Task<ProjectType> 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<HttpContent> VerifyRepo(ProjectCode code, CancellationToken token);
Task<string> GetTipHash(ProjectCode code, CancellationToken token = default);
Task<int?> GetLexEntryCount(ProjectCode code, ProjectType projectType);
Task<string?> GetRepositoryIdentifier(Project project);
Task<HttpContent> ExecuteHgRecover(string code, CancellationToken token);
Task<HttpContent> InvalidateDirCache(string code, CancellationToken token = default);
bool HasAbandonedTransactions(string projectCode);
Task<HttpContent> ExecuteHgRecover(ProjectCode code, CancellationToken token);
Task<HttpContent> InvalidateDirCache(ProjectCode code, CancellationToken token = default);
bool HasAbandonedTransactions(ProjectCode projectCode);
Task<string> HgCommandHealth();
}
35 changes: 35 additions & 0 deletions backend/Testing/LexCore/ProjectCodeTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => 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);
}
}

0 comments on commit cf3b206

Please sign in to comment.