diff --git a/ci/e2e-config-test.sh b/ci/e2e-config-test.sh index 82b6dd5..e8dd1ed 100755 --- a/ci/e2e-config-test.sh +++ b/ci/e2e-config-test.sh @@ -257,6 +257,74 @@ else exit 1 fi +echo "" +echo "=== Verify parent directory ownership ===" +DIR_OWNERSHIP_OK=true +DIR_CHECKED=0 + +# Collect unique parent directories (up to but not including HOME) +PARENT_DIRS_FILE="$(mktemp)" +for f in "${CREATED_FILES[@]}"; do + dir="$(dirname "$f")" + while [ "$dir" != "$HOME" ] && [ "$dir" != "/" ] && [ ${#dir} -gt ${#HOME} ]; do + echo "$dir" + dir="$(dirname "$dir")" + done +done | sort -u > "$PARENT_DIRS_FILE" + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + echo "Expected owner: $EXPECTED_OWNER" + while IFS= read -r d; do + WIN_D=$(cygpath -w "$d") + ACTUAL_OWNER=$(powershell.exe -NoProfile -NonInteractive -Command \ + "(Get-Acl -LiteralPath '${WIN_D}').Owner" | tr -d '\r') + DIR_CHECKED=$((DIR_CHECKED + 1)) + if [ "$ACTUAL_OWNER" = "$EXPECTED_OWNER" ]; then + echo " OK: $d" + else + echo " FAIL: $d (expected: $EXPECTED_OWNER, actual: $ACTUAL_OWNER)" + DIR_OWNERSHIP_OK=false + fi + done < "$PARENT_DIRS_FILE" + ;; + Darwin) + echo "Expected owner: $EXPECTED_OWNER" + while IFS= read -r d; do + ACTUAL_OWNER=$(stat -f '%Su' "$d") + DIR_CHECKED=$((DIR_CHECKED + 1)) + if [ "$ACTUAL_OWNER" = "$EXPECTED_OWNER" ]; then + echo " OK: $d" + else + echo " FAIL: $d (expected: $EXPECTED_OWNER, actual: $ACTUAL_OWNER)" + DIR_OWNERSHIP_OK=false + fi + done < "$PARENT_DIRS_FILE" + ;; + Linux) + echo "Expected owner: $EXPECTED_OWNER" + while IFS= read -r d; do + ACTUAL_OWNER=$(stat -c '%U' "$d") + DIR_CHECKED=$((DIR_CHECKED + 1)) + if [ "$ACTUAL_OWNER" = "$EXPECTED_OWNER" ]; then + echo " OK: $d" + else + echo " FAIL: $d (expected: $EXPECTED_OWNER, actual: $ACTUAL_OWNER)" + DIR_OWNERSHIP_OK=false + fi + done < "$PARENT_DIRS_FILE" + ;; +esac + +rm -f "$PARENT_DIRS_FILE" + +if [ "$DIR_OWNERSHIP_OK" = true ]; then + echo "PASS [dir-ownership]: All $DIR_CHECKED parent dir(s) owned by $EXPECTED_OWNER" +else + echo "FAIL [dir-ownership]: Some parent directories have incorrect ownership" + exit 1 +fi + echo "" echo "=== Compute Run 1 checksums ===" # Deduplicate paths (e.g. cursor and cursor-agent share the same file) diff --git a/src/hosts/index.ts b/src/hosts/index.ts index 9bdd8f9..cdb8b6f 100644 --- a/src/hosts/index.ts +++ b/src/hosts/index.ts @@ -64,23 +64,23 @@ function resolveProfileOwner(homeDir: string): string | null { } } -function setOwnerWindows(filePath: string, owner: string): void { +function setOwnerWindowsBatch(paths: string[], owner: string): void { + if (paths.length === 0) return + const escapedOwner = owner.replace(/'/g, "''") + const pathsList = paths.map((p) => `'${p.replace(/'/g, "''")}'`).join(',') try { - execFileSync('icacls', [filePath, '/setowner', owner], { - stdio: 'pipe', - timeout: 30_000, - }) + execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `[Console]::OutputEncoding = [Text.Encoding]::UTF8; $o = [System.Security.Principal.NTAccount]'${escapedOwner}'; @(${pathsList}) | ForEach-Object { $p = $_; try { $a = Get-Acl -LiteralPath $p; $a.SetOwner($o); Set-Acl -LiteralPath $p -AclObject $a } catch { Write-Warning "Failed to set owner on $p : $_" } }`, + ], + { encoding: 'utf-8', stdio: 'pipe', timeout: 60_000 }, + ) } catch (err) { - log.warn(`Failed to set owner of ${filePath} to ${owner}: ${err}`) - } -} - -function setOwnerWindowsAncestors(filePath: string, stopAt: string, owner: string): void { - const stopDir = resolve(stopAt) - let dir = dirname(resolve(filePath)) - while (dir.length > stopDir.length && dir.startsWith(stopDir)) { - setOwnerWindows(dir, owner) - dir = dirname(dir) + log.warn(`Failed to batch-set ownership to ${owner}: ${err}`) } } @@ -112,6 +112,7 @@ export function configureHosts(options: ConfigureHostsOptions): ConfigureResult[ // creates with the correct principal. const windowsOwner = currentPlatform === 'win32' ? resolveProfileOwner(userHomeDir) ?? username : undefined + const windowsOwnerPaths: string[] = [] for (const client of clients) { const configPath = client.configPath[currentPlatform] @@ -160,9 +161,15 @@ export function configureHosts(options: ConfigureHostsOptions): ConfigureResult[ if (currentPlatform === 'win32' && windowsOwner) { if (!lstatSync(resolvedPath).isSymbolicLink()) { - setOwnerWindows(resolvedPath, windowsOwner) + windowsOwnerPaths.push(resolvedPath) + } + // Collect ancestor directories up to (but not including) the home dir + const stopDir = resolve(userHomeDir) + let dir = dirname(resolve(resolvedPath)) + while (dir.length > stopDir.length && dir.startsWith(stopDir)) { + windowsOwnerPaths.push(dir) + dir = dirname(dir) } - setOwnerWindowsAncestors(resolvedPath, userHomeDir, windowsOwner) } else if (uid !== undefined && gid !== undefined) { if (!lstatSync(resolvedPath).isSymbolicLink()) { chownSync(resolvedPath, uid, gid) @@ -178,5 +185,9 @@ export function configureHosts(options: ConfigureHostsOptions): ConfigureResult[ } } + if (windowsOwner && windowsOwnerPaths.length > 0) { + setOwnerWindowsBatch([...new Set(windowsOwnerPaths)], windowsOwner) + } + return results }