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
96 changes: 96 additions & 0 deletions .github/workflows/test-blacksmith.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,99 @@ jobs:

git fsck --no-dangling
echo "Reused deep checkout verified"

# The shape that bit real container jobs: a privileged job container with the
# runner's devices passed through, checking out as root into a directory
# owned by the runner user (the workspace is bind-mounted from the host),
# then dissociating from the mirror. Without the checkout's git environment
# the dissociate repack fails with "dubious ownership". The target is a
# subdirectory given the workspace's owner rather than the workspace root,
# so the checkout does not wipe the action code out from under the job.
#
# The mirror on the sticky disk was committed by VM jobs, so it arrives owned
# by the runner user and the root checkout has to take it over. (Every
# checkout step mounts its own copy of the last commit, so ownership cannot be
# flipped between two steps of one job; the takeover is only observable
# across jobs, and in the VM direction only after a container job committed.)
test-git-mirror-container:
runs-on: blacksmith
container:
image: ubuntu:24.04
options: --privileged -v /dev:/dev
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
steps:
- name: Install git, sudo and the sticky disk tooling
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
bash git sudo util-linux e2fsprogs mount ca-certificates

- name: Checkout action repo
uses: actions/checkout@v4
with:
path: action

- name: Create a checkout directory owned by the runner user
shell: bash
run: |
set -euo pipefail
test "$(id -u)" = 0
mkdir host-owned && chown --reference=. host-owned
test "$(stat -c %u host-owned)" != 0
echo "workspace owned by uid $(stat -c %u .), git runs as uid $(id -u)"

- name: Test checkout with git mirror inside the container (dissociate)
uses: ./action
with:
path: host-owned
allow-inside-container: true
dissociate: true

- name: Verify the mirror was mounted and the workspace dissociated
shell: bash
run: |
set -euo pipefail
test "$(id -u)" = 0
mirror="/blacksmith-git-mirror/${GITHUB_REPOSITORY}/v1/${GITHUB_REPOSITORY_OWNER}-${GITHUB_REPOSITORY#*/}.git"
if ! mountpoint -q "$(dirname "$(dirname "$mirror")")"; then
echo "Sticky disk is not mounted inside the container; the mirror path was not exercised"
exit 1
fi
test -d "$mirror"
if [ "$(stat -c %u "$mirror")" != 0 ]; then
echo "mirror still owned by uid $(stat -c %u "$mirror"); the root checkout did not take it over"
exit 1
fi
cd host-owned
if [ -e .git/objects/info/alternates ]; then
echo "alternates file survived dissociate"
exit 1
fi
git config --global --add safe.directory "$PWD"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
git fsck --no-dangling
echo "Container checkout with dissociate verified"

# The other direction: a VM job finding the mirror as a root container job
# left it. Real once a container job on a trusted trigger (push to main) has
# committed a root-owned mirror; the action logs the owner it took over from.
test-git-mirror-after-container:
runs-on: blacksmith
needs: test-git-mirror-container
steps:
- name: Checkout action repo
uses: actions/checkout@v4

- name: Test checkout against the mirror a container job left behind
uses: ./
with:
path: after-container

- name: Verify the mirror is owned by the runner user and was used
run: |
set -euo pipefail
mirror="/blacksmith-git-mirror/${GITHUB_REPOSITORY}/v1/${GITHUB_REPOSITORY_OWNER}-${GITHUB_REPOSITORY#*/}.git"
test -d "$mirror"
test "$(stat -c %u "$mirror")" = "$(id -u)"
grep -qF "$mirror/objects" after-container/.git/objects/info/alternates
git -C after-container fsck --no-dangling
echo "Checkout after a container job verified (mirror owned by uid $(id -u))"
1 change: 1 addition & 0 deletions __test__/git-auth-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,7 @@ async function setup(testName: string): Promise<void> {
env: {},
fetch: jest.fn(),
getDefaultBranch: jest.fn(),
getEnvironment: jest.fn(() => ({...git.env})),
getSubmoduleConfigPaths: jest.fn(async () => []),
getWorkingDirectory: jest.fn(() => workspace),
init: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions __test__/git-directory-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ async function setup(testName: string): Promise<void> {
configExists: jest.fn(),
fetch: jest.fn(),
getDefaultBranch: jest.fn(),
getEnvironment: jest.fn(() => ({})),
getSubmoduleConfigPaths: jest.fn(async () => []),
getWorkingDirectory: jest.fn(() => repositoryPath),
init: jest.fn(),
Expand Down
169 changes: 169 additions & 0 deletions __test__/mirror-workspace-env-git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Real-git coverage for the workspace-side mirror operations (ref copy,
* dissociate) on a workspace git considers owned by another user, as in a
* job container where the runner creates the workspace and git runs as
* root. Git's own GIT_TEST_ASSUME_DIFFERENT_OWNER knob stands in for the
* uid mismatch; the checkout's safe.directory entry lives in a temporary
* HOME, so the operations only succeed when run with that environment.
*/
jest.mock('@connectrpc/connect', () => ({
createClient: jest.fn(),
ConnectError: class ConnectError extends Error {},
Code: {Aborted: 'ABORTED'}
}))

jest.mock('@connectrpc/connect-node', () => ({
createGrpcTransport: jest.fn()
}))

jest.mock(
'@buf/blacksmith_vm-agent.connectrpc_es/stickydisk/v1/stickydisk_connect',
() => ({
StickyDiskService: {}
})
)

jest.mock('../src/container-detector', () => ({
isRunningInContainer: jest.fn(() => false)
}))

import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import {execFileSync} from 'child_process'
import * as blacksmithCache from '../src/blacksmith-cache'

function git(cwd: string, ...args: string[]): string {
return execFileSync('git', ['-C', cwd, ...args], {encoding: 'utf8'}).trim()
}

function commit(repo: string, msg: string): void {
fs.writeFileSync(path.join(repo, 'file.txt'), msg)
git(repo, 'add', 'file.txt')
git(
repo,
'-c',
'user.email=test@example.com',
'-c',
'user.name=Test',
'commit',
'-m',
msg
)
}

function baseEnv(): {[key: string]: string} {
const env: {[key: string]: string} = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value
}
}
env['GIT_TEST_ASSUME_DIFFERENT_OWNER'] = '1'
return env
}

function gitRefusesForeignOwner(): boolean {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-probe-'))
try {
execFileSync('git', ['init', '-q', tmp])
execFileSync('git', ['-C', tmp, 'rev-parse', '--git-dir'], {
env: baseEnv(),
stdio: 'ignore'
})
return false
} catch {
return true
} finally {
fs.rmSync(tmp, {recursive: true, force: true})
}
}

const describeIfSupported = gitRefusesForeignOwner() ? describe : describe.skip

describeIfSupported(
'workspace mirror operations on a foreign-owned workspace',
() => {
let tmpDir: string
let mirrorPath: string
let workspace: string
let checkoutEnv: {[key: string]: string}

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mirror-env-test-'))
const sourceRepo = path.join(tmpDir, 'source')
mirrorPath = path.join(tmpDir, 'mirror')
workspace = path.join(tmpDir, 'workspace')

fs.mkdirSync(sourceRepo)
git(sourceRepo, 'init', '-q', '-b', 'main', '.')
commit(sourceRepo, 'one')
commit(sourceRepo, 'two')
git(sourceRepo, 'tag', 'v1')
execFileSync('git', ['clone', '-q', '--mirror', sourceRepo, mirrorPath])

fs.mkdirSync(workspace)
git(workspace, 'init', '-q', '.')
git(workspace, 'remote', 'add', 'origin', sourceRepo)
const infoDir = path.join(workspace, '.git', 'objects', 'info')
fs.mkdirSync(infoDir, {recursive: true})
fs.writeFileSync(
path.join(infoDir, 'alternates'),
`${mirrorPath}/objects\n`
)

// What the action's temporary global config holds after
// `git config --global --add safe.directory <workspace>`
const tempHome = path.join(tmpDir, 'home')
fs.mkdirSync(tempHome)
fs.writeFileSync(
path.join(tempHome, '.gitconfig'),
`[safe]\n\tdirectory = ${workspace}\n`
)
checkoutEnv = {...baseEnv(), HOME: tempHome}
})

afterEach(() => {
fs.rmSync(tmpDir, {recursive: true, force: true})
})

it('dissociate fails without the checkout environment', async () => {
await expect(
blacksmithCache.dissociate(workspace, baseEnv())
).rejects.toThrow(/exit code 128/)
expect(
fs.existsSync(
path.join(workspace, '.git', 'objects', 'info', 'alternates')
)
).toBe(true)
})

it('ref copy and dissociate succeed with the checkout environment and leave a self-contained workspace', async () => {
expect(
await blacksmithCache.fetchRefsFromMirror(
workspace,
mirrorPath,
checkoutEnv
)
).toBe(true)
// The fast path wrote packed-refs; the ref listing is what verifies it
expect(git(workspace, 'rev-parse', 'refs/remotes/origin/main')).toBe(
git(mirrorPath, 'rev-parse', 'refs/heads/main')
)

await blacksmithCache.dissociate(workspace, checkoutEnv)

expect(
fs.existsSync(
path.join(workspace, '.git', 'objects', 'info', 'alternates')
)
).toBe(false)
fs.rmSync(mirrorPath, {recursive: true, force: true})
git(workspace, 'fsck', '--no-dangling')
git(workspace, 'rev-list', '--objects', '--all', '--quiet')
expect(git(workspace, 'rev-parse', 'refs/tags/v1^{commit}')).toBe(
git(workspace, 'rev-parse', 'refs/remotes/origin/main')
)
})
}
)
Loading
Loading