fix(cso): support hardened Git on Windows

This commit is contained in:
Garry Tan committed 2026-09-11 19:05:10 +00:00
1 parent dba8c31558
commit ad6f0aa212
2 files changed
+10 -46

No files matched your search

+6 -2
View File
@@ -147,13 +147,17 @@ function hardenGit(file:string,args:string[]):{args:string[];configs?:GitConfigI
if(!isAbsolute(requested))throw new CsoError('INVALID_ARGUMENT','CSO Git operations require an absolute audited working directory');
const repo=realpathSync(requested),stat=statSync(repo);
if(!stat.isDirectory())throw new CsoError('MISSING_INPUT','Audited Git working directory is not a directory');
const configs=gitConfigIdentities(repo),nullPath=process.platform==='win32'?'NUL':'/dev/null';
const configs=gitConfigIdentities(repo),nullPath=process.platform==='win32'?'NUL':'/dev/null',
// Git for Windows accepts NUL for ordinary file-valued settings, but its
// config include machinery treats NUL as a failing include. Its MSYS path
// layer maps /dev/null correctly for this one directive.
includeNullPath=process.platform==='win32'?'/dev/null':nullPath;
const prefix=args.slice(0,position),command=args.slice(position+2);
return{configs,args:[...prefix,
'--no-replace-objects',
'-c','core.fsmonitor=false','-c',`core.hooksPath=${nullPath}`,'-c',`core.attributesFile=${nullPath}`,
'-c',`core.excludesFile=${nullPath}`,'-c','core.ignoreCase=false','-c','core.precomposeUnicode=false',
'-c','core.untrackedCache=false','-c',`include.path=${nullPath}`,'-c','core.pager=cat',
'-c','core.untrackedCache=false','-c',`include.path=${includeNullPath}`,'-c','core.pager=cat',
'-C',repo,`--work-tree=${repo}`,...command]};
}
export async function runProcess(file: string, args: string[], opts: {
+4 -44
View File
@@ -91,46 +91,6 @@ function expectSuccessfulProcess(result: ReturnType<typeof spawnSync>, label: st
})}`);
}
function gitProbeMatrix(git: string, repository: string, profile: string): Record<string, number | string> {
const asciiRoot=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),'gstack-cso-git-probe-')),
asciiHome=path.join(asciiRoot,'home'),unicodeHome=path.join(profile,'.gstack','security','cso','000000000000000000000000','0000000000000-0000000000000000','home');
fs.mkdirSync(asciiHome);fs.mkdirSync(unicodeHome,{recursive:true});
const repo=fs.realpathSync(repository),windowsRoot=process.env.SystemRoot||'C:\\Windows',system=path.join(windowsRoot,'System32'),trustedPath=[path.dirname(git),system].join(path.delimiter),nul='NUL';
const configs:Record<string,string>={
fsmonitor:'core.fsmonitor=false',hooks:`core.hooksPath=${nul}`,attributes:`core.attributesFile=${nul}`,excludes:`core.excludesFile=${nul}`,
ignoreCase:'core.ignoreCase=false',precompose:'core.precomposeUnicode=false',untracked:'core.untrackedCache=false',include:`include.path=${nul}`,pager:'core.pager=cat',
};
const environment=(home:string,extra:Record<string,string|undefined>={})=>{
const env:Record<string,string>={PATH:trustedPath,HOME:home,LANG:'C.UTF-8',LC_ALL:'C.UTF-8',TZ:'UTC',GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'NUL',GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',GIT_ATTR_NOSYSTEM:'1'};
for(const [key,value] of Object.entries(extra)){if(value===undefined)delete env[key];else env[key]=value;}return env;
};
const args=(omit=new Set<string>(),replace:Record<string,string>={})=>[
'--no-optional-locks',...(!omit.has('replaceObjects')?['--no-replace-objects']:[]),
...Object.entries(configs).flatMap(([key,value])=>omit.has(key)?[]:['-c',replace[key]??value]),
'-C',repo,...(!omit.has('workTree')?[`--work-tree=${repo}`]:[]),'rev-parse','--show-object-format',
];
const result:Record<string,number|string>={};
const probe=(label:string,command:string[],home=unicodeHome,cwd=unicodeHome,extra:Record<string,string|undefined>={})=>{
const child=spawnSync(git,command,{cwd,env:environment(home,extra),encoding:'utf8',timeout:10_000});
result[label]=child.status??(child.error?'spawn-error':child.signal??'no-status');
};
try{
probe('baseline',['--no-optional-locks','-C',repo,'rev-parse','--show-object-format']);
probe('exact',args());
probe('ascii-home-cwd',args(),asciiHome,asciiHome);
probe('ascii-home',args(),asciiHome,unicodeHome);
probe('ascii-cwd',args(),unicodeHome,asciiHome);
probe('with-systemroot',args(),unicodeHome,unicodeHome,{SystemRoot:windowsRoot});
probe('with-temp',args(),unicodeHome,unicodeHome,{TEMP:asciiRoot,TMP:asciiRoot});
probe('without-global-config-env',args(),unicodeHome,unicodeHome,{GIT_CONFIG_GLOBAL:undefined});
probe('global-dev-null',args(),unicodeHome,unicodeHome,{GIT_CONFIG_GLOBAL:'/dev/null'});
for(const key of ['replaceObjects','fsmonitor','hooks','attributes','excludes','ignoreCase','precompose','untracked','include','pager','workTree'])probe(`without-${key}`,args(new Set([key])));
for(const key of ['hooks','attributes','excludes','include'])probe(`${key}-dev-null`,args(new Set(),{[key]:configs[key].replace('NUL','/dev/null')}));
probe('without-attr-nosystem',args(),unicodeHome,unicodeHome,{GIT_ATTR_NOSYSTEM:undefined});
return result;
}finally{fs.rmSync(asciiRoot,{recursive:true,force:true});}
}
describe('CSO native Windows build contract', () => {
test('Windows builds use MSVC with a static CRT and no Bun-hosted public launcher', () => {
const build = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf8');
@@ -158,6 +118,8 @@ describe('CSO native Windows build contract', () => {
expect(msvc).toContain('GSTACK_CSO_GIT_PATH');
expect(msvc).toContain('/FI$binding');
expect(msvc).toContain('if ($LASTEXITCODE -ne 0)');
const processSource=fs.readFileSync(path.join(ROOT,'lib','cso','process.ts'),'utf8');
expect(processSource).toContain("includeNullPath=process.platform==='win32'?'/dev/null':nullPath");
const launcherSource = fs.readFileSync(path.join(ROOT, 'lib/cso/launcher-windows.c'), 'utf8');
expect(launcherSource).toContain('.gstack-cso-generation.lock');
expect(launcherSource).toContain('.gstack-cso-generation');
@@ -320,9 +282,7 @@ describe('CSO native Windows build contract', () => {
fs.writeFileSync(path.join(repository,'app.js'),'console.log("safe")\n');
for(const args of [['add','app.js'],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary,NODE_OPTIONS:'--require=hostile'};
const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);const diagnosis=JSON.parse(doctor.stdout);expect(diagnosis.downloads).toBe(false);
const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000});
if(started.status!==0)throw new Error(`gstack-cso start failed; fixed Git probe statuses: ${JSON.stringify(gitProbeMatrix(git,repository,profile))}`);
expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true);
const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);expect(JSON.parse(doctor.stdout).downloads).toBe(false);
const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000});expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true);
});
});