using System.Text.Json; using System.Text.Json.Serialization; namespace w4c_workflows.Services.Execution; /// /// 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. /// 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); /// /// Resolves a task's entry file inside its working directory. A rooted path, /// or one that escapes via .., is rejected — otherwise a workflow could /// read or execute files outside its own run directory (Path.Combine /// silently drops the root when the second argument is absolute). /// 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; } /// WF_* runtime context + the workflow/task-declared env, merged. public static IReadOnlyDictionary BuildEnvironment(TaskInvocation invocation) { var env = new Dictionary(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); } } }