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
77 changes: 67 additions & 10 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ updates:
# found" on a log page nobody visits, and the symptom is simply that no PR
# ever arrives. The e2e test asserts this path holds a Cargo.toml.
directory: /languages/typescript/packages/protect-ffi
# Lockfile only, like the EQL entry below and for the same reason: this
# workspace depends by path on eql-bindings, a member of the EQL
# workspace. A requirement this entry raised could be written into
# packages/eql/crates/eql-bindings/Cargo.toml without the EQL Cargo.lock,
# the #1205 shape one edge over. An update that needs a manifest change,
# in this workspace or that one, is dropped and made by hand. This applies
# to Dependabot security updates too; osv-scanner still reports the
# advisory. Held by
# scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs.
versioning-strategy: lockfile-only
# Monthly, where npm and actions are weekly. The deviation is about
# validation cost, not risk appetite: a Cargo.lock bump is checked by
# tests-rust.yml — one Blacksmith job, 45-minute timeout, cargo test +
Expand Down Expand Up @@ -138,23 +148,39 @@ updates:
- version-update:semver-major

# ── Cargo (packages/eql — the second Rust workspace) ───────────
# The EQL subtree brought a second Cargo workspace: five members
# (crates/eql-{domains,codegen,tests-macros,bindings} plus tests/sqlx) and
# its own Cargo.lock. It needs its own entry because Dependabot's cargo
# `directory:` is a single workspace root, not a glob — the protect-ffi entry
# above cannot reach it. The lockfile-to-ecosystem check in
# e2e/tests/supply-chain.e2e.test.ts asserts coverage per ECOSYSTEM, so it
# was already satisfied by that entry and would NOT have caught this gap;
# the symptom would simply have been that no PR ever arrived.
# The EQL subtree brought a second Cargo workspace: six members
# (crates/eql-{domains,codegen,tests-macros,bindings}, tests/sqlx and
# tests/encryption) and its own Cargo.lock. It needs its own entry because
# Dependabot's cargo `directory:` is a single workspace root, not a glob —
# the protect-ffi entry above cannot reach it. The lockfile-to-ecosystem
# check in e2e/tests/supply-chain.e2e.test.ts asserts coverage per
# ECOSYSTEM, so it was already satisfied by that entry and would NOT have
# caught this gap; the symptom would simply have been that no PR ever
# arrived.
- package-ecosystem: cargo
# The workspace root, where Cargo.toml and Cargo.lock live. Not `/`, and
# not a crate directory — same trap as the protect-ffi entry documents.
directory: /packages/eql
# Lockfile only: this entry must never edit a manifest. The workspace
# depends by path on root-workspace crates (see the ignores below), so a
# requirement it would raise can live in the ROOT Cargo.toml, outside this
# workspace; editing it there without the root Cargo.lock broke every root
# `--locked` build (#1205, #1207). An update that needs a manifest change is
# dropped rather than proposed, and is made by hand.
#
# That includes Dependabot SECURITY updates: `versioning-strategy` governs
# them as well as version updates, so a security fix that needs a
# requirement raised gets no PR from this entry. osv-scanner still reports
# the advisory (it reaches this lockfile with `--recursive ./`), and the fix
# is made by hand. Held, with the other two cargo entries, by
# scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs.
versioning-strategy: lockfile-only
# Monthly, matching protect-ffi and for the same reason: validating a
# Cargo.lock bump here means the sharded SQLx matrix in test-eql.yml, which
# compiles the full dependency tree and runs against live Postgres with
# CipherStash credentials. Security fixes are unaffected — those are driven
# by alerts, not by `schedule`.
# CipherStash credentials. The schedule does not delay security fixes —
# those are driven by alerts, not by `schedule` — though `lockfile-only`
# above still drops one that needs a manifest edit.
#
# No `day:`, for the reason recorded above: it applies to `interval: weekly`
# and would be configuration that does nothing.
Expand Down Expand Up @@ -195,6 +221,26 @@ updates:
# a registry version for a crate we release here would fight the lockstep
# version hook (scripts/sync-lockstep-versions.mjs).
- dependency-name: "eql-bindings"
# Inherited from the ROOT workspace. tests/encryption depends by path on
# packages/stack-kms and packages/stack-encrypt, and eql-bindings on
# stack-encrypt behind its optional `stack-encrypt` feature. Those crates
# declare these `{ workspace = true }`, so their requirements come from
# the root Cargo.toml even when built here. The root entry below ignores
# them because they move by hand with cipherstash-suite: the root
# manifest pins recipher and cllw-ore with `=`, and so does the Go guest.
# Without these lines this entry bumped them in this lockfile and, to
# satisfy the exact pins, rewrote the root Cargo.toml without touching
# the root Cargo.lock or the guest's pin, breaking every root `--locked`
# build and the guest (#1205, #1207). vitaminc* also keeps eql-bindings
# on the line stack-encrypt and the guest build against.
# versioning-strategy above already stops the manifest edit; these keep
# the lockfile from moving these crates apart from the root's.
# scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs fails when
# the root entry ignores a crate this entry does not.
- dependency-name: "recipher"
- dependency-name: "cllw-ore"
- dependency-name: "zerokms-protocol"
- dependency-name: "vitaminc*"
# Major bumps are reviewed and applied manually, not by Dependabot.
- dependency-name: "*"
update-types:
Expand All @@ -213,6 +259,17 @@ updates:
- /packages/stack-encrypt/fuzz
- /languages/golang/encrypt/guest
- /languages/golang/auth/guest
# Lockfile only, like the other two cargo entries. Every one of these
# workspaces shares crates by path with another: the fuzz crates and both
# guests depend on root-workspace crates, the encrypt guest also on
# eql-bindings, and the EQL workspace on stack-encrypt and stack-kms. Those
# root crates take `{ workspace = true }` requirements from the root
# Cargo.toml, so a manifest edit made for any one of these lockfiles can
# leave the others stale under `--locked`. An update that needs a manifest
# change, security updates included, is dropped and made by hand; osv-scanner
# still reports the advisory. Held by
# scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs.
versioning-strategy: lockfile-only
# Monthly, matching the other two cargo entries.
schedule:
interval: monthly
Expand Down
2 changes: 1 addition & 1 deletion packages/eql/crates/eql-codegen/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ thiserror = "2"
quote = "1"
proc-macro2 = "1"
syn = { version = "2", features = ["full"] }
prettyplease = "=0.2.37"
prettyplease = "0.2"

[[bin]]
name = "eql-codegen"
Expand Down
159 changes: 159 additions & 0 deletions scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { existsSync, readFileSync, statSync } from 'node:fs'
import { join, relative, resolve, sep } from 'node:path'
import yaml from 'js-yaml'
import { beforeAll, describe, expect, it } from 'vitest'
import { REPO_ROOT } from './lib/repo-root.mjs'

/**
* Every cargo Dependabot entry whose workspaces share crates with another
* workspace by path must be `versioning-strategy: lockfile-only`.
*
* Each cargo `directory:` is one Cargo workspace with its own Cargo.lock, and
* a Dependabot PR updates exactly that one lockfile. A path dependency crosses
* that line in both directions:
* - OUTWARD: a workspace that depends by path on another workspace's crate
* resolves that crate's requirements, including any `{ workspace = true }`
* ones from the other workspace's root Cargo.toml. To satisfy a bump it may
* edit those manifests, outside its own workspace, and leave the other
* workspace's Cargo.lock behind. That is #1205: the EQL entry rewrote the
* root Cargo.toml, and every root `--locked` build failed (#1207).
* - INWARD: a workspace whose crate another workspace depends on by path can
* raise a requirement in its own manifest and update its own lockfile, and
* the depending workspace's Cargo.lock is then stale under `--locked`.
*
* `lockfile-only` stops both: the entry never edits a manifest. An update that
* needs one is dropped and made by hand, across every affected lockfile.
*
* The edges are read from the tracked manifests, so a new path dependency is
* covered the day it lands.
*/

const DEPENDABOT = join(REPO_ROOT, '.github/dependabot.yml')

/** Repo-relative, `/`-separated, no leading slash: the form Cargo uses. */
function repoRel(abs) {
return relative(REPO_ROOT, abs).split(sep).join('/')
}

function readManifest(dir) {
return readFileSync(join(REPO_ROOT, dir, 'Cargo.toml'), 'utf8')
}

/**
* The crate directories of the workspace rooted at `dir`: its `members`, plus
* the root itself when it is also a package (a detached single-crate
* workspace, such as a fuzz crate or a Go guest).
*/
function workspaceCrates(dir, manifest) {
const crates = []
if (/^\[package\]/m.test(manifest)) crates.push(dir)
const block = manifest.match(/^members\s*=\s*\[([\s\S]*?)\]/m)
if (block) {
for (const [, member] of block[1].matchAll(/"([^"]+)"/g)) {
expect(
member,
`${dir}/Cargo.toml: glob members are not expanded here`,
).not.toMatch(/[*?[]/)
crates.push(repoRel(resolve(REPO_ROOT, dir, member)))
}
}
return crates
}

/**
* The crates a manifest depends on by path, repo-relative. A `path =` that
* names a file (`[lib]`, `[[bin]]`) is not a dependency and is skipped.
*/
function pathDependencies(crateDir, manifest) {
const deps = []
for (const [, path] of manifest.matchAll(/\bpath\s*=\s*"([^"]+)"/g)) {
const abs = resolve(REPO_ROOT, crateDir, path)
if (!existsSync(abs) || !statSync(abs).isDirectory()) continue
if (!existsSync(join(abs, 'Cargo.toml'))) continue
deps.push(repoRel(abs))
}
return deps
}

/**
* Every cross-workspace path dependency, as
* `{ from, to, fromDir, toDir }`: crate `from` in the workspace at `fromDir`
* depends on crate `to` in the workspace at `toDir`. `toDir` is null when no
* Dependabot cargo directory covers `to`.
*/
function crossWorkspaceEdges(workspaces) {
const owner = new Map()
for (const [dir, crates] of workspaces) {
for (const crate of crates) owner.set(crate, dir)
}
const edges = []
for (const [dir, crates] of workspaces) {
for (const crate of crates) {
for (const to of pathDependencies(crate, readManifest(crate))) {
const toDir = owner.get(to) ?? null
const seen = edges.some((e) => e.from === crate && e.to === to)
if (toDir !== dir && !seen) {
edges.push({ from: crate, to, fromDir: dir, toDir })
}
}
}
}
return edges
}

/** The directories of a Dependabot entry, without the leading `/`. */
function entryDirs(entry) {
const dirs = entry.directories ?? [entry.directory]
return dirs.map((d) => d.replace(/^\/+/, '').replace(/\/+$/, ''))
}

describe('cargo Dependabot entries that share crates across workspaces never edit a manifest', () => {
let entries
let edges

beforeAll(() => {
const config = yaml.load(readFileSync(DEPENDABOT, 'utf8'))
entries = config.updates.filter((u) => u['package-ecosystem'] === 'cargo')
const workspaces = new Map()
for (const entry of entries) {
for (const dir of entryDirs(entry)) {
workspaces.set(dir, workspaceCrates(dir, readManifest(dir)))
}
}
edges = crossWorkspaceEdges(workspaces)
})

it('finds cross-workspace path dependencies', () => {
// If these all go away the rule below has nothing to hold, and this file
// can be retired rather than left vacuously green.
expect(edges).not.toHaveLength(0)
})

it('covers every crate a path dependency reaches', () => {
const uncovered = edges.filter((e) => e.toDir === null)
expect(
uncovered.map((e) => `${e.from} -> ${e.to}`),
'path dependencies on crates no cargo Dependabot directory covers',
).toEqual([])
})

it('makes every entry on either end of one lockfile-only', () => {
const involved = new Set(edges.flatMap((e) => [e.fromDir, e.toDir]))
const offenders = entries
.filter((entry) => entryDirs(entry).some((d) => involved.has(d)))
.filter((entry) => entry['versioning-strategy'] !== 'lockfile-only')
.map((entry) => {
const dirs = new Set(entryDirs(entry))
const why = edges
.filter((e) => dirs.has(e.fromDir) || dirs.has(e.toDir))
.map((e) => `${e.from} -> ${e.to}`)
return `${(entry.directories ?? [entry.directory]).join(', ')}: ${why.join('; ')}`
})
expect(
offenders,
'these cargo entries share crates by path with another workspace, so a manifest ' +
"edit can leave the other workspace's Cargo.lock behind. Set " +
'`versioning-strategy: lockfile-only` on each.',
).toEqual([])
})
})
Loading
Loading