Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Core/CredentialsExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
extern alias Devlooped;
using GitCredentialManager;

namespace Core;
namespace Devlooped;

public static class CredentialsExtensions
{
Expand Down
2 changes: 1 addition & 1 deletion src/Core/DownloadManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public static class DownloadManager
{
extension(RemoteRef location)
{
public string TempPath => Path.Join(GetTempRoot(), location.Owner, location.Repo, location.Ref ?? "main");
public string TempPath => Path.Join(GetTempRoot(), location.Host ?? "github.com", location.Owner, location.Project ?? "", location.Repo, location.Ref ?? "main");
}

/// <summary>
Expand Down
60 changes: 58 additions & 2 deletions src/Core/DownloadProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ namespace Devlooped;

public abstract class DownloadProvider
{
public static DownloadProvider Create(RemoteRef location) => location.Host?.ToLowerInvariant() switch
public static DownloadProvider Create(RemoteRef location) => (location.Host?.ToLowerInvariant() ?? "github.com") switch
{
"gitlab.com" => new GitLabDownloadProvider(),
"dev.azure.com" => new AzureDevOpsDownloadProvider(),
//"bitbucket.org" => new BitbucketDownloadProvider(),
_ => new GitHubDownloadProvider(),
};
Expand Down Expand Up @@ -42,7 +43,7 @@ public override async Task<HttpResponseMessage> GetAsync(RemoteRef location)
var subdomain = gist ? "gist." : "";
var request = new HttpRequestMessage(HttpMethod.Get,
// Direct archive link works for branch, tag, sha
new Uri($"https://{subdomain}github.com/{location.Owner}/{location.Repo}/archive/{(location.Ref ?? "main")}.zip"))
new Uri($"https://{subdomain}github.com/{location.Owner}/{location.Repo}/archive/{location.Ref ?? "main"}.zip"))
.WithTag(location.ETag);

return await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
Expand Down Expand Up @@ -79,6 +80,61 @@ public override async Task<HttpResponseMessage> GetAsync(RemoteRef location)
}
}

public class AzureDevOpsDownloadProvider : DownloadProvider
{
static readonly HttpClient http = new(new AzureRepoAuthHandler(
new RedirectingHttpHandler(
new HttpClientHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip,
}, "visualstudio.com")))
{
Timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(15)
};

static AzureDevOpsDownloadProvider()
{
http.DefaultRequestHeaders.TryAddWithoutValidation("X-TFS-FedAuthRedirect", "Suppress");
}

public override async Task<HttpResponseMessage> GetAsync(RemoteRef location)
{
if (location.ResolvedUri != null)
{
return await http.SendAsync(
new HttpRequestMessage(HttpMethod.Get, location.ResolvedUri).WithTag(location.ETag),
HttpCompletionOption.ResponseHeadersRead);
}

// For Azure DevOps we support dev.azure.com/org/project/repo, defaulting project=repo if not specified
var project = location.Project ?? location.Repo;

// Branch/ref support
var version = location.Ref ?? "main";
var url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&$format=zip&api-version=7.1";
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag);
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);

if (response.StatusCode == HttpStatusCode.NotFound)
{
// try tag & commit
url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&versionType=tag&$format=zip&api-version=7.1";
request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag);
response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);

if (response.StatusCode == HttpStatusCode.NotFound)
{
url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&versionType=commit&$format=zip&api-version=7.1";
request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag);
response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
}
}

return response;
}
}

/*
public class BitbucketDownloadProvider : DownloadProvider
{
Expand Down
32 changes: 27 additions & 5 deletions src/Core/Http/AzureRepoAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
extern alias Devlooped;
using System.Text;
using GitCredentialManager;
using Microsoft.AzureRepos;

Expand All @@ -18,7 +19,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
return await base.SendAsync(request, cancellationToken);

var retry = new HttpRequestMessage(HttpMethod.Get, request.RequestUri);
retry.Headers.Add("Authorization", "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(creds.Password)));
retry.Headers.Add("Authorization", "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes($":{creds.Password}")));
foreach (var etag in request.Headers.IfNoneMatch)
{
retry.Headers.IfNoneMatch.Add(etag);
Expand All @@ -27,11 +28,23 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
return await base.SendAsync(retry, cancellationToken);
}

async Task<ICredential?> GetCredentialAsync(Uri uri)
async Task<ICredential?> GetCredentialAsync(Uri? uri)
{
if (credential != null)
if (credential != null || uri == null ||
// We need at least the org
uri.PathAndQuery.Split('/', StringSplitOptions.RemoveEmptyEntries) is not { Length: >= 1 } parts)
return credential;

// We can use the namespace-less version since we don't need any specific permissions besides
// the built-in GCM GH auth.
var store = Devlooped::GitCredentialManager.CredentialManager.Create();

// Try using GCM via API first to retrieve creds
if (parts.Length >= 2 && store.GetCredential($"https://dev.azure.com/{parts[0]}/{parts[1]}") is { } project)
return project;
else if (store.GetCredential($"https://dev.azure.com/{parts[0]}") is { } owner)
return owner;

var input = new InputArguments(new Dictionary<string, string>
{
["protocol"] = "https",
Expand All @@ -40,7 +53,16 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
});

var provider = new AzureReposHostProvider(CommandContext.Create());
credential = await provider.GetCredentialAsync(input);

try
{
credential = await provider.GetCredentialAsync(input);
store.AddOrUpdate($"https://dev.azure.com/{parts[0]}", credential.Account, credential.Password);
}
catch (Exception)
{
credential = null;
}

return credential;
}
Expand Down
1 change: 0 additions & 1 deletion src/Core/Http/BitbucketAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
extern alias Devlooped;
using System.Net;
using Atlassian.Bitbucket;
using Core;
using GitCredentialManager;
using GitCredentialManager.Authentication.OAuth;

Expand Down
1 change: 0 additions & 1 deletion src/Core/Http/GitHubAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
extern alias Devlooped;
using System.Net.Http.Headers;
using Core;
using GitCredentialManager;
using GitHub;

Expand Down
5 changes: 3 additions & 2 deletions src/Core/Http/RedirectingHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Devlooped.Http;

/// <summary>A handler that redirects traffic preserving auth/etag headers to originating domain or subdomains.</summary>
public sealed class RedirectingHttpHandler(HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler)
public sealed class RedirectingHttpHandler(HttpMessageHandler innerHandler, params string[] followHosts) : DelegatingHandler(innerHandler)
{
/// <summary>Maximum number of redirects to follow. Default is 10.</summary>
public int MaxRedirects { get; set; } = 10;
Expand All @@ -29,7 +29,8 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage

var nextHost = NormalizeHost(nextUri.Host);
// Never redirect to a different domain (security)
if (!IsWithinSubdomain(originalHost, nextHost))
if (!IsWithinSubdomain(originalHost, nextHost) &&
!followHosts.Any(host => IsWithinSubdomain(host, nextHost)))
return response;

// Limit to prevent loops
Expand Down
29 changes: 26 additions & 3 deletions src/Core/RemoteRef.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ namespace Devlooped;

public partial record RemoteRef(string Owner, string Repo, string? Ref, string? Path, string? Host)
{
public override string ToString() => $"{(Host == null ? "" : Host + "/")}{Owner}/{Repo}{(Ref == null ? "" : "@" + Ref)}{(Path == null ? "" : ":" + Path)}";
// When an optional third segment ("project") is provided (owner/repo/project),
// we treat the originally parsed <repo> as the Azure DevOps project and the
// third segment as the actual repository name.
public string? Project { get; init; }

public override string ToString() =>
$"{(Host == null ? "" : Host + "/")}{Owner}/{(Project == null ? "" : Project + "/")}{Repo}{(Ref == null ? "" : "@" + Ref)}{(Path == null ? "" : ":" + Path)}";

public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? remote)
{
Expand All @@ -18,20 +24,37 @@ public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? rem
var host = match.Groups["host"].Value;
var owner = match.Groups["owner"].Value;
var repo = match.Groups["repo"].Value;
var project = match.Groups["project"].Value;
var reference = match.Groups["ref"].Value;
var filePath = match.Groups["path"].Value;

// If a third segment was provided, treat the originally parsed <repo> as the Azure DevOps project
if (!string.IsNullOrEmpty(project))
(project, repo) = (repo, project);
else
project = null;

// If project is provided, host is required, since GH does not support projects
if (project != null && string.IsNullOrEmpty(host))
{
remote = null;
return false;
}

remote = new RemoteRef(owner, repo,
string.IsNullOrEmpty(reference) ? null : reference,
string.IsNullOrEmpty(filePath) ? null : filePath,
string.IsNullOrEmpty(host) ? null : host);
string.IsNullOrEmpty(host) ? null : host)
{
Project = project
};

return true;
}

public string? ETag { get; init; }
public Uri? ResolvedUri { get; init; }

[GeneratedRegex(@"^(?:(?<host>[A-Za-z0-9.-]+\.[A-Za-z]{2,})/)?(?<owner>[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38})/(?<repo>[A-Za-z0-9._-]{1,100})(?:@(?<ref>[^:\s]+))?(?::(?<path>.+))?$")]
[GeneratedRegex(@"^(?:(?<host>[A-Za-z0-9.-]+\.[A-Za-z]{2,})/)?(?<owner>[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38})/(?<repo>[A-Za-z0-9._-]{1,100})(?:/(?<project>[A-Za-z0-9._-]{1,100}))?(?:@(?<ref>[^:\s]+))?(?::(?<path>.+))?$")]
private static partial Regex ParseExp();
}
Loading