diff --git a/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs b/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs index 72885919..629d77a9 100644 --- a/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs +++ b/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs @@ -257,10 +257,27 @@ private static SandboxConfiguration BootstrapSandboxConfiguration( } } - sandboxConfig.LogonCommand.Add(Path.Combine(SandboxMountPaths.AppDirectory, "StartupScript.cmd")); + // 이슈 #304: LogonCommand 로 .cmd 경로를 **직접** 지정하지 않고 cmd.exe 를 거친다. + // + // .cmd 는 PE 이미지가 아니라서 CreateProcess 로는 띄울 수 없고, 셸(ShellExecute)이나 + // 명령 인터프리터를 경유해야 한다. 즉 .cmd 를 그대로 넘기면 실행 성공 여부가 게스트의 + // .cmd 파일 연결과 샌드박스 클라이언트의 실행 방식에 의존하게 되는데, 이 경로가 깨지면 + // **아무 오류도 없이 그냥 실행되지 않는다**(제보 환경에서 관측된 증상). + // + // 인터프리터를 명시하면 샌드박스가 띄워야 할 대상이 System32 의 PE 바이너리로 고정되어 + // 연결·셸 의존이 사라진다. 경로도 %SystemRoot% 같은 확장에 기대지 않고 절대 경로로 박는다 + // (LogonCommand 문자열이 어느 시점에 환경 변수 확장을 거치는지 보장되지 않기 때문). + var startupScriptInSandbox = Path.Combine(SandboxMountPaths.AppDirectory, "StartupScript.cmd"); + sandboxConfig.LogonCommand.Add($@"{GuestCommandInterpreterPath} /c ""{startupScriptInSandbox}"""); return sandboxConfig; } + /// + /// 샌드박스 게스트의 명령 인터프리터 절대 경로. 게스트는 항상 표준 Windows 설치이므로 + /// C:\Windows\System32\cmd.exe 로 고정된다(호스트 경로가 아니다). + /// + private const string GuestCommandInterpreterPath = @"C:\Windows\System32\cmd.exe"; + /// /// 호스트가 가진 인증서 쌍을 staging의 App\certs로 떨어뜨리고, NPKI 경로 조립에 필요한 /// 식별자(O / SubjectNameForNpkiApp / IsPersonalCert)를 에 채워 넣는다. @@ -397,6 +414,13 @@ setx DOTNET_ROOT ""{SandboxMountPaths.SandboxDesktop}\{HostDotnetLeafName}"" >nu // reg.exe + citool.exe는 System32의 EV 서명 바이너리라 SAC가 신뢰하므로 batch 단계에서 // 호출하는 것이 안전. // + // 이슈 #304: 단, citool.exe는 작업을 마친 뒤 "계속하려면 Enter 키를 누르세요."로 표준 입력을 + // 기다리는 빌드가 있다(제보 환경의 게스트 이미지 10.0.26100.8875에서 확인). LogonCommand 로 + // 실행되는 이 batch 는 stdout/stderr 가 nul 로 묶여 있어 그 프롬프트가 화면에 보이지도 않은 채 + // 영원히 멈추고, 바로 다음 줄의 Spork 실행에 도달하지 못한다 = "아무 메시지 없이 아무 일도 + // 안 일어남". 그래서 stdin 을 nul 로 리다이렉트해 프롬프트가 즉시 EOF 를 받도록 한다. + // (batch 에서 블로킹될 수 있는 유일한 줄이므로 이 한 줄이 곧 hang 방어다.) + // // GPU 가속 OFF(기본)일 때 vGPU Disable 만으로는 부족할 수 있어 Edge 정책도 함께 적용해 // 다층 차단한다. Edge는 vGPU가 없어도 WARP/소프트웨어 GPU 경로를 시도하며, 이 경로가 // 일부 환경에서 흰 화면이나 렌더링 지연을 유발할 수 있기 때문에 정책 키로 명시적으로 @@ -444,12 +468,51 @@ reg add ""HKLM\SOFTWARE\Policies\Google\Chrome\LocalNetworkAccessAllowedForUrls" " : string.Empty; + // 이슈 #304: 부팅 브레드크럼. + // + // 이 batch 가 어디까지 갔는지 남기지 않으면, Spork 가 뜨지 않았을 때 사용자에게도 우리에게도 + // 단서가 0 이다(콘솔은 즉시 닫히고, Spork 가 뜨기 전이라 Serilog/Sentry 도 아무것도 못 남긴다). + // 그래서 각 단계 직후 한 줄씩 기록한다. + // + // 기록 위치: Data 마운트(항상 RW)가 최우선. staging 의 App 폴더는 메인 창을 닫을 때 + // SandboxCleanupManager 가 통째로 지우므로 세션이 끝나면 회수할 수 없다. Data 는 호스트의 + // 실제 폴더(기본 문서\TableCloth\Data)라 샌드박스 종료 후에도 그대로 남는다. + // 마운트가 없는 예외 상황에서만 App 폴더로 폴백한다. + // + // 리다이렉션을 **명령 앞**에 두는 것은 의도적이다. `echo rc=%errorlevel%>>파일` 처럼 숫자로 + // 끝나면 cmd 가 `0>>` 를 fd 0 리다이렉션으로 파싱해 로그가 조용히 깨진다. + const string BootLogVar = "%TCBOOTLOG%"; + var bootLogHeader = $@"set TCBOOTLOG=%~dp0tablecloth-boot.log +if exist ""{SandboxMountPaths.DataDirectory}\"" set TCBOOTLOG={SandboxMountPaths.DataDirectory}\tablecloth-boot.log +>""{BootLogVar}"" echo [00] startup script begin %DATE% %TIME% +"; + + // 본 batch 는 UTF-8(Encoding.Default)로 기록되는데 cmd 는 이를 OEM 코드페이지로 읽는다. + // 따라서 **반드시 ASCII 만** 사용한다. 한글을 넣으면 바이트 정렬이 깨져 스크립트가 통째로 + // 무동작이 될 수 있다(이슈 #304 진단 중 실측). + var launchFailureNotice = $@"if not ""%TCSPORKRC%""==""0"" ( + echo. + echo [TableCloth] Spork could not start ^(exit code %TCSPORKRC%^). + echo [TableCloth] Diagnostic log: {BootLogVar} + echo [TableCloth] Please attach that file to https://github.com/yourtablecloth/TableCloth/issues + echo. + pause +) +"; + return $@"@echo off pushd ""%~dp0"" -{dotnetRootScript}reg add ""HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy"" /v VerifiedAndReputablePolicyState /t REG_DWORD /d 0 /f >nul 2>&1 -{disableEdgeGpuScript}{darkWallpaperScript}{localNetworkAccessScript}""%SystemRoot%\System32\citool.exe"" --refresh >nul 2>&1 -{idleGuardScript}""{tableClothExeInSandbox}"" spork {idList} {string.Join(" ", switches)} -popd +{bootLogHeader}{dotnetRootScript}reg add ""HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy"" /v VerifiedAndReputablePolicyState /t REG_DWORD /d 0 /f >nul 2>&1 +>>""{BootLogVar}"" echo [01] sac policy rc=%errorlevel% +{disableEdgeGpuScript}{darkWallpaperScript}{localNetworkAccessScript}>>""{BootLogVar}"" echo [02] browser policies applied rc=%errorlevel% +>>""{BootLogVar}"" echo [03] citool refresh begin %TIME% +""%SystemRoot%\System32\citool.exe"" --refresh nul 2>&1 +>>""{BootLogVar}"" echo [04] citool refresh end rc=%errorlevel% %TIME% +{idleGuardScript}>>""{BootLogVar}"" echo [05] launching spork %TIME% +""{tableClothExeInSandbox}"" spork {idList} {string.Join(" ", switches)} +set TCSPORKRC=%errorlevel% +>>""{BootLogVar}"" echo [06] spork exited rc=%TCSPORKRC% %TIME% +{launchFailureNotice}popd @echo on "; } diff --git a/src/TableCloth.Test/SandboxStartupScriptTests.cs b/src/TableCloth.Test/SandboxStartupScriptTests.cs new file mode 100644 index 00000000..3afba84a --- /dev/null +++ b/src/TableCloth.Test/SandboxStartupScriptTests.cs @@ -0,0 +1,148 @@ +using System.Reflection; +using TableCloth.Components.Implementations; +using TableCloth.Models.Configuration; +using TableCloth.Models.WindowsSandbox; + +namespace TableCloth.Test +{ + /// + /// 샌드박스 LogonCommand 로 실행되는 StartupScript.cmd 생성 결과에 대한 회귀 방지 테스트. + /// 이 스크립트가 조용히 깨지면 Spork 가 아예 뜨지 않고 사용자에게 아무 메시지도 남지 않으므로 + /// (이슈 #304), 실측으로 확인된 함정들을 여기서 고정한다. + /// + [TestClass] + public sealed class SandboxStartupScriptTests + { + /// + /// GenerateSandboxStartupScript 는 private 인스턴스 메서드지만 주입된 의존성을 + /// 전혀 사용하지 않으므로, 생성자 인수는 null 로 두고 리플렉션으로 호출한다. + /// + private static string GenerateScript(TableClothConfiguration configuration) + { + var builder = new SandboxBuilder(null!, null!, null!, null!); + var method = typeof(SandboxBuilder).GetMethod( + "GenerateSandboxStartupScript", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.IsNotNull(method, "GenerateSandboxStartupScript 를 찾지 못했습니다. 이름이 바뀌었는지 확인하세요."); + return (string)method.Invoke(builder, new object[] { configuration })!; + } + + private static TableClothConfiguration DefaultConfiguration() => new(); + + /// + /// 스크립트는 UTF-8(Encoding.Default)로 기록되지만 cmd 는 이를 OEM 코드페이지로 읽는다. + /// 비 ASCII 문자가 섞이면 바이트 정렬이 깨져 스크립트가 통째로 무동작이 될 수 있다 + /// (이슈 #304 진단 중 진단 하니스에서 실측한 현상). + /// + [TestMethod] + public void StartupScript_ShouldBePureAscii() + { + var script = GenerateScript(DefaultConfiguration()); + + var offending = script.Where(c => c > 0x7F).Distinct().ToArray(); + + Assert.IsEmpty(offending, + $"StartupScript 에 비 ASCII 문자가 있습니다: {string.Join(", ", offending.Select(c => $"U+{(int)c:X4}"))}"); + } + + /// + /// citool.exe 는 작업을 마친 뒤 표준 입력을 기다리는 환경이 있다. stdin 을 nul 로 묶지 않으면 + /// batch 가 그 자리에서 멈추고 바로 다음 줄의 Spork 실행에 도달하지 못한다(이슈 #304). + /// + [TestMethod] + public void StartupScript_CitoolRefresh_ShouldRedirectStdinFromNul() + { + var script = GenerateScript(DefaultConfiguration()); + + StringAssert.Contains(script, "--refresh + /// echo rc=%errorlevel%>>file 처럼 숫자 바로 뒤에 리다이렉션이 붙으면 cmd 가 + /// 0>> 를 fd 0 리다이렉션으로 파싱해 로그가 조용히 깨진다. 그래서 브레드크럼은 + /// 리다이렉션을 명령 앞에 둔다. + /// + [TestMethod] + public void StartupScript_ShouldNotHaveDigitAdjacentRedirection() + { + var script = GenerateScript(DefaultConfiguration()); + + foreach (var token in new[] { "%errorlevel%>>", "%errorlevel%>", "%TCSPORKRC%>>" }) + { + Assert.IsFalse(script.Contains(token, StringComparison.Ordinal), + $"'{token}' 형태는 cmd 가 fd 리다이렉션으로 오인합니다. 리다이렉션을 명령 앞으로 옮기세요."); + } + } + + /// + /// 부팅 브레드크럼이 없으면 Spork 가 뜨기 전에 실패했을 때 아무 단서도 남지 않는다. + /// 기록 위치는 세션 종료 후에도 남는 Data 마운트가 우선이어야 한다. + /// + [TestMethod] + public void StartupScript_ShouldWriteBootBreadcrumbsToPersistentMount() + { + var script = GenerateScript(DefaultConfiguration()); + + StringAssert.Contains(script, "set TCBOOTLOG="); + StringAssert.Contains(script, @"Desktop\Data\tablecloth-boot.log", + "브레드크럼은 세션 종료 후 회수 가능한 Data 마운트에 남아야 합니다."); + + foreach (var stage in new[] { "[00]", "[01]", "[02]", "[03]", "[04]", "[05]", "[06]" }) + { + StringAssert.Contains(script, stage, $"브레드크럼 단계 {stage} 가 없습니다."); + } + } + + /// + /// LogonCommand 는 .cmd 경로를 직접 지정하지 않는다. .cmd 는 PE 이미지가 아니라 + /// 실행에 셸/파일 연결이 개입하는데, 그 경로가 깨지면 아무 오류 없이 그냥 실행되지 않는다 + /// (이슈 #304). System32 의 cmd.exe 를 절대 경로로 명시해 의존을 없앤다. + /// + [TestMethod] + public void LogonCommand_ShouldInvokeScriptThroughAbsoluteCmdExePath() + { + // AssetsDirectoryPath 가 실재해야 BootstrapSandboxConfiguration 이 LogonCommand 를 채운다. + var configuration = new TableClothConfiguration + { + AssetsDirectoryPath = Path.GetTempPath(), + }; + + var method = typeof(SandboxBuilder).GetMethod( + "BootstrapSandboxConfiguration", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.IsNotNull(method, "BootstrapSandboxConfiguration 을 찾지 못했습니다."); + + var sandboxConfiguration = (SandboxConfiguration)method.Invoke(null, new object[] { configuration })!; + + Assert.HasCount(1, sandboxConfiguration.LogonCommand); + + var command = sandboxConfiguration.LogonCommand[0]; + StringAssert.StartsWith(command, @"C:\Windows\System32\cmd.exe /c ", + "LogonCommand 는 게스트 System32 의 cmd.exe 를 절대 경로로 먼저 지정해야 합니다."); + StringAssert.Contains(command, @"""C:\Users\WDAGUtilityAccount\Desktop\App\StartupScript.cmd""", + "스크립트 경로는 따옴표로 감싸 전달해야 합니다."); + } + + /// + /// SAC 해제와 정책 적용은 반드시 Spork 실행 *전에* 끝나야 하고, Spork 실행은 스크립트의 + /// 마지막 동작이어야 한다. 순서가 뒤집히면 이슈 #256 회귀가 된다. + /// + [TestMethod] + public void StartupScript_ShouldLaunchSporkAfterPolicyStages() + { + var script = GenerateScript(DefaultConfiguration()); + + var sacIndex = script.IndexOf("VerifiedAndReputablePolicyState", StringComparison.Ordinal); + var citoolIndex = script.IndexOf("citool.exe", StringComparison.Ordinal); + var sporkIndex = script.IndexOf(" spork ", StringComparison.Ordinal); + + Assert.IsTrue(sacIndex >= 0 && citoolIndex >= 0 && sporkIndex >= 0, + "SAC 정책 / citool / spork 실행 중 하나가 스크립트에 없습니다."); + Assert.IsLessThan(citoolIndex, sacIndex, "SAC 레지스트리 설정이 citool refresh 보다 뒤에 있습니다."); + Assert.IsLessThan(sporkIndex, citoolIndex, "citool refresh 가 Spork 실행보다 뒤에 있습니다."); + } + } +} diff --git a/src/TableCloth/Help/manual.ko.html b/src/TableCloth/Help/manual.ko.html index c0fc615d..cb41cd58 100644 --- a/src/TableCloth/Help/manual.ko.html +++ b/src/TableCloth/Help/manual.ko.html @@ -177,6 +177,17 @@

7. 자주 묻는 질문

옵션 → 인증서 탭에서 ‘공동인증서 폴더 공유’가 켜져 있는지, 내 PC에 인증서 폴더가 실제로 있는지 확인하세요.
화면이 하얗게 나오거나 느립니다.
옵션 → 호환성 탭의 GPU 가속을 끈 상태(기본값)로 사용해 보세요. 일부 그래픽 환경에서 발생할 수 있습니다.
+
샌드박스는 열리는데 식탁보 창이 뜨지 않고 아무 메시지도 없습니다.
+
+ 샌드박스 바탕 화면에 App·Data 폴더는 보이는데 그 뒤로 아무 일도 일어나지 않는 경우입니다. + 샌드박스 안에서 바탕 화면 → App → StartupScript.cmd를 직접 실행했을 때 정상적으로 진행된다면, + Windows Sandbox가 시작 시 실행하기로 한 명령을 무시하고 있는 상태입니다. 이때는 식탁보가 만든 파일이 아니라 + Windows Sandbox 쪽 문제이므로, Windows 기능에서 ‘Windows Sandbox’를 끄고 재부팅한 뒤 다시 켜서 + 샌드박스 이미지를 초기 상태로 되돌려 보세요. 서드파티 백신을 쓰신다면 잠시 중지하고 시도해 보시는 것도 도움이 됩니다. +
+ 제보해 주실 때는 데이터 폴더에 남는 tablecloth-boot.log 파일을 함께 첨부해 주시면 큰 도움이 됩니다. + 샌드박스 시작 과정이 어디까지 진행됐는지 기록되어 있습니다. +