2026-09-01 16:37:53 +00:00
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Text.Json.Serialization;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Services.Execution;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Shared helpers for the subprocess-backed executors: environment construction,
|
|
|
|
|
/// working-directory resolution and the stdout → JSON output interpretation.
|
|
|
|
|
///
|
|
|
|
|
/// Output contract: a program signals its result by printing one JSON value on
|
|
|
|
|
/// the last non-empty line of stdout. If that line is not valid JSON the whole
|
|
|
|
|
/// (trimmed) stdout is wrapped as a JSON string instead, so every output is
|
|
|
|
|
/// storable JSON.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal static class ExecutionHelpers
|
|
|
|
|
{
|
|
|
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
|
|
|
|
{
|
|
|
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
public static string ResolveWorkingDir(TaskInvocation invocation)
|
|
|
|
|
=> string.IsNullOrWhiteSpace(invocation.WorkingDir)
|
|
|
|
|
? Directory.GetCurrentDirectory()
|
|
|
|
|
: Path.GetFullPath(invocation.WorkingDir);
|
|
|
|
|
|
2026-09-13 08:35:17 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// Resolves a task's entry file inside its working directory. A rooted path,
|
|
|
|
|
/// or one that escapes via <c>..</c>, is rejected — otherwise a workflow could
|
|
|
|
|
/// read or execute files outside its own run directory (<c>Path.Combine</c>
|
|
|
|
|
/// silently drops the root when the second argument is absolute).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static bool TryResolveEntryPath(TaskInvocation invocation, out string entryPath, out string? error)
|
|
|
|
|
{
|
|
|
|
|
var root = ResolveWorkingDir(invocation);
|
|
|
|
|
var candidate = invocation.EntryFile;
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(candidate))
|
|
|
|
|
{
|
|
|
|
|
entryPath = string.Empty;
|
|
|
|
|
error = "entry.file is empty";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Path.IsPathRooted(candidate))
|
|
|
|
|
{
|
|
|
|
|
entryPath = string.Empty;
|
|
|
|
|
error = $"entry file '{candidate}' must be relative to the task working directory";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resolved = Path.GetFullPath(Path.Combine(root, candidate));
|
|
|
|
|
var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
|
|
|
|
|
? root
|
|
|
|
|
: root + Path.DirectorySeparatorChar;
|
|
|
|
|
var comparison = OperatingSystem.IsWindows()
|
|
|
|
|
? StringComparison.OrdinalIgnoreCase
|
|
|
|
|
: StringComparison.Ordinal;
|
|
|
|
|
|
|
|
|
|
if (!resolved.StartsWith(rootWithSeparator, comparison))
|
|
|
|
|
{
|
|
|
|
|
entryPath = string.Empty;
|
|
|
|
|
error = $"entry file '{candidate}' escapes the task working directory";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entryPath = resolved;
|
|
|
|
|
error = null;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
/// <summary>WF_* runtime context + the workflow/task-declared env, merged.</summary>
|
|
|
|
|
public static IReadOnlyDictionary<string, string> BuildEnvironment(TaskInvocation invocation)
|
|
|
|
|
{
|
|
|
|
|
var env = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
|
|
|
{
|
|
|
|
|
["WF_TENANT_ID"] = invocation.TenantId,
|
|
|
|
|
["WF_RUN_ID"] = invocation.RunId,
|
|
|
|
|
["WF_TASK_ID"] = invocation.TaskId,
|
|
|
|
|
["WF_TASK_KEY"] = invocation.TaskKey,
|
|
|
|
|
["WF_INPUT"] = invocation.Input ?? "{}",
|
|
|
|
|
};
|
|
|
|
|
foreach (var (key, value) in invocation.Env)
|
|
|
|
|
env[key] = value;
|
|
|
|
|
return env;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static ExecutionResult ToResult(ProcessOutput output, TimeSpan duration)
|
|
|
|
|
{
|
|
|
|
|
var success = output.ExitCode == 0;
|
|
|
|
|
var error = success
|
|
|
|
|
? null
|
|
|
|
|
: LastNonEmptyLine(output.Stderr) ?? $"exit code {output.ExitCode}";
|
|
|
|
|
return new ExecutionResult(
|
|
|
|
|
success,
|
|
|
|
|
output.ExitCode,
|
|
|
|
|
output.Stdout,
|
|
|
|
|
output.Stderr,
|
|
|
|
|
success ? ParseOutput(output.Stdout) : null,
|
|
|
|
|
error,
|
|
|
|
|
duration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string? LastNonEmptyLine(string? text)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(text))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
foreach (var line in text.Split('\n'))
|
|
|
|
|
{
|
|
|
|
|
var trimmed = line.Trim('\r', ' ', '\t');
|
|
|
|
|
if (trimmed.Length > 0)
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? ParseOutput(string stdout)
|
|
|
|
|
{
|
|
|
|
|
var line = LastNonEmptyLine(stdout);
|
|
|
|
|
if (line == null)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var _ = JsonDocument.Parse(line);
|
|
|
|
|
return line; // already valid JSON — store it verbatim
|
|
|
|
|
}
|
|
|
|
|
catch (JsonException)
|
|
|
|
|
{
|
|
|
|
|
return JsonSerializer.Serialize(stdout.Trim(), Json);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|