Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions src/TableCloth.App/Components/Implementations/SandboxBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// 샌드박스 게스트의 명령 인터프리터 절대 경로. 게스트는 항상 표준 Windows 설치이므로
/// <c>C:\Windows\System32\cmd.exe</c> 로 고정된다(호스트 경로가 아니다).
/// </summary>
private const string GuestCommandInterpreterPath = @"C:\Windows\System32\cmd.exe";

/// <summary>
/// 호스트가 가진 인증서 쌍을 staging의 <c>App\certs</c>로 떨어뜨리고, NPKI 경로 조립에 필요한
/// 식별자(O / SubjectNameForNpkiApp / IsPersonalCert)를 <see cref="SporkAnswers"/>에 채워 넣는다.
Expand Down Expand Up @@ -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 경로를 시도하며, 이 경로가
// 일부 환경에서 흰 화면이나 렌더링 지연을 유발할 수 있기 때문에 정책 키로 명시적으로
Expand Down Expand Up @@ -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 진단 중 실측).
Comment on lines +490 to +492
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 >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
";
}
Expand Down
148 changes: 148 additions & 0 deletions src/TableCloth.Test/SandboxStartupScriptTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System.Reflection;
using TableCloth.Components.Implementations;
using TableCloth.Models.Configuration;
using TableCloth.Models.WindowsSandbox;

namespace TableCloth.Test
{
/// <summary>
/// 샌드박스 LogonCommand 로 실행되는 StartupScript.cmd 생성 결과에 대한 회귀 방지 테스트.
/// 이 스크립트가 조용히 깨지면 Spork 가 아예 뜨지 않고 사용자에게 아무 메시지도 남지 않으므로
/// (이슈 #304), 실측으로 확인된 함정들을 여기서 고정한다.
/// </summary>
[TestClass]
public sealed class SandboxStartupScriptTests
{
/// <summary>
/// <c>GenerateSandboxStartupScript</c> 는 private 인스턴스 메서드지만 주입된 의존성을
/// 전혀 사용하지 않으므로, 생성자 인수는 null 로 두고 리플렉션으로 호출한다.
/// </summary>
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();

/// <summary>
/// 스크립트는 UTF-8(Encoding.Default)로 기록되지만 cmd 는 이를 OEM 코드페이지로 읽는다.
/// 비 ASCII 문자가 섞이면 바이트 정렬이 깨져 스크립트가 통째로 무동작이 될 수 있다
/// (이슈 #304 진단 중 진단 하니스에서 실측한 현상).
/// </summary>
Comment on lines +33 to +37
[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}"))}");
}

/// <summary>
/// citool.exe 는 작업을 마친 뒤 표준 입력을 기다리는 환경이 있다. stdin 을 nul 로 묶지 않으면
/// batch 가 그 자리에서 멈추고 바로 다음 줄의 Spork 실행에 도달하지 못한다(이슈 #304).
/// </summary>
[TestMethod]
public void StartupScript_CitoolRefresh_ShouldRedirectStdinFromNul()
{
var script = GenerateScript(DefaultConfiguration());

StringAssert.Contains(script, "--refresh <nul",
"citool --refresh 가 stdin 리다이렉트 없이 호출되면 프롬프트에서 무한 대기할 수 있습니다.");
}

/// <summary>
/// <c>echo rc=%errorlevel%&gt;&gt;file</c> 처럼 숫자 바로 뒤에 리다이렉션이 붙으면 cmd 가
/// <c>0&gt;&gt;</c> 를 fd 0 리다이렉션으로 파싱해 로그가 조용히 깨진다. 그래서 브레드크럼은
/// 리다이렉션을 명령 앞에 둔다.
/// </summary>
[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 리다이렉션으로 오인합니다. 리다이렉션을 명령 앞으로 옮기세요.");
}
}

/// <summary>
/// 부팅 브레드크럼이 없으면 Spork 가 뜨기 전에 실패했을 때 아무 단서도 남지 않는다.
/// 기록 위치는 세션 종료 후에도 남는 Data 마운트가 우선이어야 한다.
/// </summary>
[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} 가 없습니다.");
}
}

/// <summary>
/// LogonCommand 는 <c>.cmd</c> 경로를 직접 지정하지 않는다. <c>.cmd</c> 는 PE 이미지가 아니라
/// 실행에 셸/파일 연결이 개입하는데, 그 경로가 깨지면 아무 오류 없이 그냥 실행되지 않는다
/// (이슈 #304). System32 의 cmd.exe 를 절대 경로로 명시해 의존을 없앤다.
/// </summary>
[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""",
"스크립트 경로는 따옴표로 감싸 전달해야 합니다.");
}

/// <summary>
/// SAC 해제와 정책 적용은 반드시 Spork 실행 *전에* 끝나야 하고, Spork 실행은 스크립트의
/// 마지막 동작이어야 한다. 순서가 뒤집히면 이슈 #256 회귀가 된다.
/// </summary>
[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 실행보다 뒤에 있습니다.");
Comment on lines +142 to +145
}
}
}
11 changes: 11 additions & 0 deletions src/TableCloth/Help/manual.ko.html
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ <h2>7. 자주 묻는 질문</h2>
<dd>옵션 → 인증서 탭에서 ‘공동인증서 폴더 공유’가 켜져 있는지, 내 PC에 인증서 폴더가 실제로 있는지 확인하세요.</dd>
<dt>화면이 하얗게 나오거나 느립니다.</dt>
<dd>옵션 → 호환성 탭의 GPU 가속을 끈 상태(기본값)로 사용해 보세요. 일부 그래픽 환경에서 발생할 수 있습니다.</dd>
<dt>샌드박스는 열리는데 식탁보 창이 뜨지 않고 아무 메시지도 없습니다.</dt>
<dd>
샌드박스 바탕 화면에 <code>App</code>·<code>Data</code> 폴더는 보이는데 그 뒤로 아무 일도 일어나지 않는 경우입니다.
샌드박스 안에서 <code>바탕 화면 → App → StartupScript.cmd</code>를 직접 실행했을 때 정상적으로 진행된다면,
Windows Sandbox가 시작 시 실행하기로 한 명령을 무시하고 있는 상태입니다. 이때는 식탁보가 만든 파일이 아니라
Windows Sandbox 쪽 문제이므로, <strong>Windows 기능에서 ‘Windows Sandbox’를 끄고 재부팅한 뒤 다시 켜서</strong>
샌드박스 이미지를 초기 상태로 되돌려 보세요. 서드파티 백신을 쓰신다면 잠시 중지하고 시도해 보시는 것도 도움이 됩니다.
<br>
제보해 주실 때는 데이터 폴더에 남는 <code>tablecloth-boot.log</code> 파일을 함께 첨부해 주시면 큰 도움이 됩니다.
샌드박스 시작 과정이 어디까지 진행됐는지 기록되어 있습니다.
</dd>
</dl>
</section>

Expand Down
Loading