| File: CargoMetadataReader.cs | Web Access |
| Project: src\src\Aspire.Hosting.Rust\Aspire.Hosting.Rust.csproj (Aspire.Hosting.Rust) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Text.RegularExpressions; namespace Aspire.Hosting.Rust; /// <summary> /// Queries cargo for a crate's package/target layout without compiling anything. /// </summary> /// <remarks> /// Registered in the app host's service container by <c>AddRustApp</c> so tests can substitute a /// deterministic implementation and exercise publishing and debugging on machines with no Rust toolchain. /// </remarks> internal interface ICargoMetadataReader { Task<CargoMetadata> ReadAsync(string workingDirectory, string? manifestPath, string resourceName, IReadOnlyDictionary<string, string> environment, CancellationToken cancellationToken); } /// <summary> /// Queries cargo for a crate's package/target layout without compiling anything. /// </summary> /// <remarks> /// Publishing a Rust app builds it inside the container, so the host must never compile. It still needs the /// name of the produced binary in order to emit a correct <c>COPY --from=build</c> and <c>ENTRYPOINT</c>, and /// <c>cargo metadata</c> is the only cargo subcommand that answers that from the manifest alone. /// <c>--no-deps</c> additionally stops cargo from resolving or downloading the dependency graph. /// See https://doc.rust-lang.org/cargo/commands/cargo-metadata.html /// </remarks> internal sealed partial class CargoMetadataReader : ICargoMetadataReader { internal const int MaximumStandardErrorLength = 4096; private const string TruncatedDiagnosticSuffix = "... (truncated)"; // A cold `cargo metadata --format-version 1 --no-deps` has been measured at close to 15 seconds on a // machine whose cargo caches are empty, so a short timeout would fail valid apps rather than protect // them. This is only a backstop against a cargo process that never exits; ordinary shutdown flows // through the caller's cancellation token instead. private static readonly TimeSpan s_timeout = TimeSpan.FromMinutes(2); /// <summary> /// Builds the argument vector passed to cargo. /// </summary> /// <remarks> /// Exposed separately so tests can assert that publishing never invokes a compiling subcommand. /// </remarks> internal static string[] BuildArguments(string? manifestPath) { string[] arguments = ["metadata", "--format-version", "1", "--no-deps"]; return manifestPath is null ? arguments : [.. arguments, "--manifest-path", manifestPath]; } /// <summary> /// Runs <c>cargo metadata</c> for the crate in <paramref name="workingDirectory"/>. /// </summary> /// <remarks> /// <paramref name="environment"/> carries the resource's own environment so the query sees the same /// cargo configuration the build will. <c>CARGO_TARGET_DIR</c> is the one that matters most: it moves the /// <c>target_directory</c> cargo reports here, and therefore the path the debugger is pointed at. /// </remarks> public async Task<CargoMetadata> ReadAsync(string workingDirectory, string? manifestPath, string resourceName, IReadOnlyDictionary<string, string> environment, CancellationToken cancellationToken) { var startInfo = new ProcessStartInfo("cargo") { WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; foreach (var argument in BuildArguments(manifestPath)) { startInfo.ArgumentList.Add(argument); } foreach (var (name, value) in environment) { startInfo.Environment[name] = value; } using var process = new Process { StartInfo = startInfo }; try { process.Start(); } catch (Exception ex) { throw new DistributedApplicationException( $"Unable to start 'cargo' to inspect the Rust app '{resourceName}'. Install Rust from https://www.rust-lang.org/tools/install " + $"or supply your own Dockerfile in '{workingDirectory}'. {ex.Message}", ex); } // Drain both redirected streams concurrently so a full pipe cannot block cargo before it exits. var stdoutTask = process.StandardOutput.ReadToEndAsync(CancellationToken.None); var stderrTask = process.StandardError.ReadToEndAsync(CancellationToken.None); using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(s_timeout); try { await process.WaitForExitAsync(timeoutSource.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { TryKillProcess(process); throw new DistributedApplicationException( $"'cargo metadata' for the Rust app '{resourceName}' did not complete within {s_timeout.TotalSeconds:0} seconds."); } catch (OperationCanceledException) { TryKillProcess(process); throw; } var stdout = await stdoutTask.ConfigureAwait(false); var stderr = await stderrTask.ConfigureAwait(false); if (process.ExitCode != 0) { var diagnostic = FormatStandardError(stderr, environment, startInfo.Environment); var diagnosticSuffix = diagnostic.Length > 0 ? $" {diagnostic}" : string.Empty; throw new DistributedApplicationException( $"'cargo metadata' failed for the Rust app '{resourceName}' with exit code {process.ExitCode}.{diagnosticSuffix}"); } try { return CargoMetadata.Parse(stdout); } catch (Exception ex) when (ex is not DistributedApplicationException) { throw new DistributedApplicationException( $"Unable to read the output of 'cargo metadata' for the Rust app '{resourceName}'. Cargo returned invalid {ex.GetType().Name} output."); } } internal static string FormatStandardError( string standardError, IReadOnlyDictionary<string, string> environment, IEnumerable<KeyValuePair<string, string?>>? inheritedEnvironment = null) { // Cargo wrappers and configuration errors can echo values from the resolved resource environment or // inherited variables such as CARGO_REGISTRY_TOKEN. Resource values are all user-controlled, while // inherited values are limited to conventional secret-bearing names to preserve useful diagnostics. // Redact before truncating so a value that crosses the retained-output boundary cannot leak partially. if (string.IsNullOrWhiteSpace(standardError)) { return string.Empty; } var redacted = standardError; var resourceValues = environment .Where(static pair => pair.Value.Length >= 4 || IsSensitiveEnvironmentVariableName(pair.Key)) .Select(static pair => pair.Value); var sensitiveInheritedValues = inheritedEnvironment? .Where(static pair => IsSensitiveEnvironmentVariableName(pair.Key) || ContainsCredentialUserInfo(pair.Value)) .Select(static pair => pair.Value); var sensitiveValues = resourceValues .Concat(sensitiveInheritedValues ?? []) .OfType<string>() .Where(static value => value.Length > 0) .Distinct(StringComparer.Ordinal) .Where(value => redacted.Contains(value, StringComparison.Ordinal)) .OrderByDescending(static value => value.Length) .ToArray(); if (sensitiveValues.Any(static value => value.Length < 4)) { return "Cargo stderr omitted because a sensitive environment value was too short to redact safely."; } foreach (var value in sensitiveValues) { redacted = redacted.Replace(value, "***", StringComparison.Ordinal); } redacted = redacted.Trim(); if (redacted.Length <= MaximumStandardErrorLength) { return redacted; } var retainedLength = MaximumStandardErrorLength - TruncatedDiagnosticSuffix.Length; if (char.IsHighSurrogate(redacted[retainedLength - 1]) && char.IsLowSurrogate(redacted[retainedLength])) { retainedLength--; } return string.Concat( redacted.AsSpan(0, retainedLength), TruncatedDiagnosticSuffix); } private static bool IsSensitiveEnvironmentVariableName(string name) => SensitiveEnvironmentVariableNamePattern().IsMatch(name); private static bool ContainsCredentialUserInfo(string? value) { return Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.UserInfo); } // A URL or URI variable is treated as sensitive by name rather than only when it carries user info: // `DATABASE_URL` and `REDIS_URL` routinely hold a password, and an inherited one would otherwise be // echoed verbatim by a cargo wrapper into an exception message and any log that records it. This // mirrors the extension-side policy in extension/src/debugger/languages/rust.ts // (`isSensitiveArgumentName`), which matches `(?:^|[._-])(?:url|uri)(?:$|[._-])`. URL and URI are // deliberately absent from the trailing no-delimiter alternation below, because names such as `CURL` // end in those letters without naming a URL. [GeneratedRegex( @"(?:^|[._-])(?:PGPASSWORD|MYSQL_PWD|tokens?|passwords?|passwd|secrets?|credentials?|api[_-]?keys?|access[_-]?keys?|private[_-]?keys?|client[_-]?secrets?|connection[_-]?strings?|urls?|uris?)(?:$|[._-])|(?:TOKENS?|PASSWORDS?|PASSWD|SECRETS?|CREDENTIALS?|APIKEYS?|ACCESSKEYS?|PRIVATEKEYS?|CLIENTSECRETS?|CONNECTIONSTRINGS?)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex SensitiveEnvironmentVariableNamePattern(); private static void TryKillProcess(Process process) { try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { // The process exited while cancellation was being observed. } } }