1
0
Fork 0

feat: 외부도구 활성화 인프라 — ISettingsStore(DPAPI) + ExternalProcessRunner

AI 키·외부 도구 경로를 안전 저장하고 외부 CLI를 일원화 실행하는 토대. 둘 다 순수 .NET, 실동작 테스트 검증.

- DpapiSettingsStore: DPAPI(CurrentUser) 암호화로 %LOCALAPPDATA%에 저장, 평문 노출 없음 검증
- ExternalProcessRunner: stdout/stderr 수집 + 타임아웃 + 프로세스 트리 Kill (LibreOffice/FFmpeg/Ghostscript 공용)
- 테스트 6개 추가 (암호화 round-trip, echo/비정상종료/타임아웃) — 총 29개 통과
This commit is contained in:
Yun Chan 2026-06-01 13:16:56 +09:00
parent d49ee6365f
commit 937d9e7878
4 changed files with 268 additions and 0 deletions

View file

@ -0,0 +1,79 @@
using System.Diagnostics;
using System.Text;
namespace Everything2Everything.Core.Converters;
public sealed record ProcessRunResult(int ExitCode, string StdOut, string StdErr, bool TimedOut)
{
public bool Success => !TimedOut && ExitCode == 0;
}
/// <summary>
/// 외부 CLI 프로세스 실행을 일원화한다 — stdout/stderr 수집, 타임아웃, 취소 시 프로세스 트리 종료.
/// LibreOffice·FFmpeg·Ghostscript·qpdf 등 모든 외부 도구 어댑터가 공유하는 실행기.
/// </summary>
public static class ExternalProcessRunner
{
public static async Task<ProcessRunResult> RunAsync(
string fileName,
IEnumerable<string> arguments,
TimeSpan? timeout = null,
string? workingDirectory = null,
CancellationToken cancellationToken = default)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (var a in arguments) psi.ArgumentList.Add(a);
if (!string.IsNullOrEmpty(workingDirectory)) psi.WorkingDirectory = workingDirectory;
using var proc = new Process { StartInfo = psi };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
if (!proc.Start())
throw new InvalidOperationException($"프로세스를 시작하지 못했습니다: {fileName}");
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (timeout is { } t) linked.CancelAfter(t);
var timedOut = false;
try
{
await proc.WaitForExitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// 사용자 취소면 전파, 타임아웃이면 TimedOut으로 보고
timedOut = !cancellationToken.IsCancellationRequested;
try { proc.Kill(entireProcessTree: true); } catch { /* 이미 종료 */ }
if (cancellationToken.IsCancellationRequested)
{
try { proc.WaitForExit(2000); } catch { }
throw;
}
}
// Kill 또는 종료 후 비동기 출력 콜백이 마저 도착하도록 잠깐 대기
try { proc.WaitForExit(2000); } catch { }
var exitCode = timedOut ? -1 : SafeExitCode(proc);
return new ProcessRunResult(exitCode, stdout.ToString(), stderr.ToString(), timedOut);
}
private static int SafeExitCode(Process proc)
{
try { return proc.ExitCode; }
catch { return -1; }
}
}

View file

@ -24,6 +24,7 @@
<PackageReference Include="ReverseMarkdown" Version="4.6.0" /> <PackageReference Include="ReverseMarkdown" Version="4.6.0" />
<PackageReference Include="PDFsharp" Version="6.2.0" /> <PackageReference Include="PDFsharp" Version="6.2.0" />
<PackageReference Include="Svg.Skia" Version="5.0.0" /> <PackageReference Include="Svg.Skia" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,92 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace Everything2Everything.Core;
/// <summary>키-값 설정 저장소. API 키·외부 도구 경로 등 민감 정보를 영속화한다.</summary>
public interface ISettingsStore
{
string? Get(string key);
void Set(string key, string value);
void Remove(string key);
bool Contains(string key);
}
/// <summary>
/// DPAPI(CurrentUser)로 암호화해 %LOCALAPPDATA%\Everything2Everything\settings.dat 에 저장한다.
/// 현재 사용자 계정에서만 복호화 가능하므로 API 키 저장에 적합하다. Windows 전용.
/// </summary>
public sealed class DpapiSettingsStore : ISettingsStore
{
private readonly string _path;
private readonly Dictionary<string, string> _cache;
private readonly object _lock = new();
public DpapiSettingsStore(string? filePath = null)
{
_path = filePath ?? DefaultPath();
_cache = Load(_path);
}
public static string DefaultPath()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Everything2Everything");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "settings.dat");
}
public string? Get(string key)
{
lock (_lock) return _cache.TryGetValue(key, out var v) ? v : null;
}
public bool Contains(string key)
{
lock (_lock) return _cache.ContainsKey(key);
}
public void Set(string key, string value)
{
lock (_lock)
{
_cache[key] = value;
Save();
}
}
public void Remove(string key)
{
lock (_lock)
{
if (_cache.Remove(key)) Save();
}
}
private void Save()
{
var json = JsonSerializer.SerializeToUtf8Bytes(_cache);
var encrypted = ProtectedData.Protect(json, optionalEntropy: null, DataProtectionScope.CurrentUser);
var tmp = _path + ".tmp";
File.WriteAllBytes(tmp, encrypted);
if (File.Exists(_path)) File.Delete(_path);
File.Move(tmp, _path);
}
private static Dictionary<string, string> Load(string path)
{
try
{
if (!File.Exists(path)) return new(StringComparer.Ordinal);
var encrypted = File.ReadAllBytes(path);
var json = ProtectedData.Unprotect(encrypted, optionalEntropy: null, DataProtectionScope.CurrentUser);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new(StringComparer.Ordinal);
}
catch
{
// 손상/복호화 실패 시 빈 저장소로 시작(설정은 비치명적)
return new(StringComparer.Ordinal);
}
}
}

View file

@ -0,0 +1,96 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Everything2Everything.Core;
using Everything2Everything.Core.Converters;
using Xunit;
namespace Everything2Everything.Tests;
public class SettingsStoreTests
{
private static string TempFile()
=> Path.Combine(Path.GetTempPath(), "e2e_settings_" + Guid.NewGuid().ToString("N") + ".dat");
[Fact]
public void Set_Get_Roundtrip_AndPersists()
{
var path = TempFile();
try
{
var store = new DpapiSettingsStore(path);
store.Set("openai.key", "sk-secret-123");
Assert.Equal("sk-secret-123", store.Get("openai.key"));
Assert.True(store.Contains("openai.key"));
// 새 인스턴스로 다시 로드 — 영속 + 복호화 확인
var reloaded = new DpapiSettingsStore(path);
Assert.Equal("sk-secret-123", reloaded.Get("openai.key"));
}
finally { try { File.Delete(path); } catch { } }
}
[Fact]
public void File_IsEncrypted_NotPlaintext()
{
var path = TempFile();
try
{
new DpapiSettingsStore(path).Set("k", "PLAINTEXT_MARKER");
var bytes = File.ReadAllBytes(path);
var asText = System.Text.Encoding.UTF8.GetString(bytes);
Assert.DoesNotContain("PLAINTEXT_MARKER", asText);
}
finally { try { File.Delete(path); } catch { } }
}
[Fact]
public void Remove_DeletesKey()
{
var path = TempFile();
try
{
var store = new DpapiSettingsStore(path);
store.Set("k", "v");
store.Remove("k");
Assert.Null(store.Get("k"));
Assert.False(store.Contains("k"));
}
finally { try { File.Delete(path); } catch { } }
}
}
public class ExternalProcessRunnerTests
{
[Fact]
public async Task Echo_CapturesStdout_AndExitZero()
{
var r = await ExternalProcessRunner.RunAsync(
"cmd.exe", new[] { "/c", "echo", "hello_e2e" },
timeout: TimeSpan.FromSeconds(10), cancellationToken: CancellationToken.None);
Assert.True(r.Success);
Assert.Equal(0, r.ExitCode);
Assert.Contains("hello_e2e", r.StdOut);
}
[Fact]
public async Task NonZeroExit_IsReported()
{
var r = await ExternalProcessRunner.RunAsync(
"cmd.exe", new[] { "/c", "exit", "3" },
timeout: TimeSpan.FromSeconds(10), cancellationToken: CancellationToken.None);
Assert.False(r.Success);
Assert.Equal(3, r.ExitCode);
}
[Fact]
public async Task Timeout_IsDetected_AndProcessKilled()
{
var r = await ExternalProcessRunner.RunAsync(
"cmd.exe", new[] { "/c", "ping", "127.0.0.1", "-n", "10" },
timeout: TimeSpan.FromMilliseconds(400), cancellationToken: CancellationToken.None);
Assert.True(r.TimedOut);
Assert.False(r.Success);
}
}