diff --git a/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs b/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs new file mode 100644 index 0000000..361dd22 --- /dev/null +++ b/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs @@ -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; +} + +/// +/// 외부 CLI 프로세스 실행을 일원화한다 — stdout/stderr 수집, 타임아웃, 취소 시 프로세스 트리 종료. +/// LibreOffice·FFmpeg·Ghostscript·qpdf 등 모든 외부 도구 어댑터가 공유하는 실행기. +/// +public static class ExternalProcessRunner +{ + public static async Task RunAsync( + string fileName, + IEnumerable 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; } + } +} diff --git a/src/Everything2Everything.Core/Everything2Everything.Core.csproj b/src/Everything2Everything.Core/Everything2Everything.Core.csproj index dc1e9ad..12a9a38 100644 --- a/src/Everything2Everything.Core/Everything2Everything.Core.csproj +++ b/src/Everything2Everything.Core/Everything2Everything.Core.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Everything2Everything.Core/SettingsStore.cs b/src/Everything2Everything.Core/SettingsStore.cs new file mode 100644 index 0000000..e7fb48b --- /dev/null +++ b/src/Everything2Everything.Core/SettingsStore.cs @@ -0,0 +1,92 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace Everything2Everything.Core; + +/// 키-값 설정 저장소. API 키·외부 도구 경로 등 민감 정보를 영속화한다. +public interface ISettingsStore +{ + string? Get(string key); + void Set(string key, string value); + void Remove(string key); + bool Contains(string key); +} + +/// +/// DPAPI(CurrentUser)로 암호화해 %LOCALAPPDATA%\Everything2Everything\settings.dat 에 저장한다. +/// 현재 사용자 계정에서만 복호화 가능하므로 API 키 저장에 적합하다. Windows 전용. +/// +public sealed class DpapiSettingsStore : ISettingsStore +{ + private readonly string _path; + private readonly Dictionary _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 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>(json) ?? new(StringComparer.Ordinal); + } + catch + { + // 손상/복호화 실패 시 빈 저장소로 시작(설정은 비치명적) + return new(StringComparer.Ordinal); + } + } +} diff --git a/src/Everything2Everything.Tests/InfraTests.cs b/src/Everything2Everything.Tests/InfraTests.cs new file mode 100644 index 0000000..442119e --- /dev/null +++ b/src/Everything2Everything.Tests/InfraTests.cs @@ -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); + } +}