shipshape_core/release/adapters/homebrew.rs
1//! Homebrew distribution adapter: `homebrew-tap` and `homebrew-core`.
2//!
3//! Updates a Homebrew formula (a custom tap, or a `homebrew-core` bump PR) so
4//! `brew install` resolves the new version. `verify` fetches the destination
5//! formula and checks its version and platform URL/checksum stanzas. Engine-owned
6//! `homebrew-tap` formulas must also carry shipshape's ownership marker; CI-delegated
7//! cargo-dist formulas are intentionally unmarked. A fetch failure is `Unknown`,
8//! never a false `Missing`.
9//!
10//! ## Three formula paths: create, tap-write, bump-PR
11//!
12//! A release either **creates** the first `<name>.rb` on an empty tap or
13//! **updates** an existing one. This adapter chooses by asking the tap whether the
14//! formula already exists (through the injected
15//! [`CommandRunner`](crate::ports::CommandRunner), so it is testable with no real
16//! network or tap):
17//!
18//! - **configured tap, formula absent** → the *create* path: generate a
19//! source-build formula (the release tarball's `url` + `sha256`, the license, a
20//! cargo build/install stanza), clone the tap, commit the new file on a branch,
21//! and open a PR.
22//! - **configured tap, formula present** → the *tap-write* path
23//! (`FormulaPath::TapWrite`): render the updated `<name>.rb` from the verified
24//! `url` + `sha256`, clone the tap, update the file, commit, and **push
25//! directly to the tap's default branch** — no `brew`, no `bump-formula-pr`, no
26//! `brew audit`. This is deterministic, needs no local `brew`/ruby/gem toolchain
27//! on the cutting machine, and mirrors the manual fallback that has always
28//! worked. It **fails closed** without a verified `sha256` (unlike create, which
29//! can open a draft-PR placeholder, a formula on the tap's *default branch* is
30//! what `brew install` resolves — so an unverified digest would ship a broken
31//! install), and it is a **clean no-op** when the tap already carries exactly
32//! this formula (an idempotent resume/re-run at the target version).
33//! - **no configured tap** (a `homebrew-core` target, or a `homebrew-tap` with no
34//! resolved tap) → the *bump-PR* path: `brew bump-formula-pr` carrying the
35//! release tarball's `--url` (+ `--sha256` when a digest is available). First
36//! submission to `homebrew-core` is a human review process where the full core
37//! `brew audit` is appropriate, so the PR path is kept for it.
38//!
39//! ## Why tap-write replaced `brew bump-formula-pr` for the tap-bump case
40//!
41//! `brew bump-formula-pr` runs a full `brew audit` internally and aborts the whole
42//! bump on any finding — including cosmetic core-lint changes irrelevant to a
43//! personal tap the maintainer controls (issue `homebrew-dist-brew-audit-fails`:
44//! the first engine dogfood cut failed here with a swallowed audit message). The
45//! tap-write path removes that dependency entirely and surfaces any real git/`gh`
46//! failure verbatim through the shared `run_all` runner rather than as a black box.
47
48use std::fmt::Write as _;
49use std::path::PathBuf;
50use std::time::Duration;
51
52use crate::contract::schema::Adapter;
53use crate::protocol::release::{
54 BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
55};
56
57use super::{
58 make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, HomebrewAsset, HomebrewFormula,
59 ReleaseAdapter, SourceTarball,
60};
61
62/// The stable, greppable prefix of the **ownership marker** every shipshape-generated
63/// formula carries as its first line. Its presence is how the *tap-write* path tells
64/// a Shipshape-managed formula (safe to fully regenerate) from a hand-maintained one
65/// (never clobber — surgically edit `url`/`sha256` only, or refuse). The prefix
66/// deliberately omits the version integer so a future `template-version` bump still
67/// reads as "shipshape-managed". See [`render_formula`] (writer) and
68/// [`formula_carries_marker`] (reader).
69const FORMULA_MARKER_PREFIX: &str =
70 "# Generated by shipshape; do not edit by hand (template-version:";
71/// Legacy marker emitted by ossctl. It remains trusted so a renamed installation can
72/// safely resume and update formulas created before the product migration (ADR-0005).
73const LEGACY_FORMULA_MARKER_PREFIX: &str =
74 "# Generated by ossctl; do not edit by hand (template-version:";
75
76/// The current formula-template version, embedded in the ownership marker. Bump this
77/// when the generated formula's *shape* changes in a way worth recording; the marker
78/// still identifies the file as shipshape-managed regardless of the integer.
79const FORMULA_TEMPLATE_VERSION: u32 = 2;
80
81/// Whether `bytes` carry the shipshape ownership marker on their **first line** —
82/// exactly where [`render_formula`] writes it. The check is deliberately anchored to
83/// the first line (not a search anywhere in the file): a hand-maintained formula that
84/// merely *quotes* the marker string in a comment, `desc`, `caveats`, or embedded
85/// `__END__`/patch data must NOT be mistaken for shipshape output and fully regenerated
86/// (that would clobber the hand-authored stanzas this whole path exists to protect).
87/// Operates on bytes so a non-UTF-8 formula needs no lossy conversion; a trailing
88/// `\r` (CRLF) after the marker is irrelevant to the `starts_with` prefix test. A
89/// marked formula is shipshape-managed → safe to fully regenerate; an unmarked one is
90/// hand-maintained → the tap-write path must not clobber it.
91fn formula_carries_marker(bytes: &[u8]) -> bool {
92 let first_line = match bytes.iter().position(|&b| b == b'\n') {
93 Some(i) => &bytes[..i],
94 None => bytes,
95 };
96 first_line.starts_with(FORMULA_MARKER_PREFIX.as_bytes())
97 || first_line.starts_with(LEGACY_FORMULA_MARKER_PREFIX.as_bytes())
98}
99
100/// The homebrew distribution adapter, operating as `homebrew-tap` or
101/// `homebrew-core`.
102pub struct HomebrewAdapter {
103 adapter: Adapter,
104}
105
106/// Which formula operation the adapter resolved for a target.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum FormulaPath {
109 /// Generate + PR the initial `<name>.rb` (configured tap, no formula yet).
110 Create,
111 /// Render + write the updated `<name>.rb` directly to the configured tap's
112 /// default branch (configured tap, formula already present) — no `brew`, no
113 /// PR. See the module docs' *tap-write* path.
114 TapWrite,
115 /// `brew bump-formula-pr` an already-present formula — the fallback for a
116 /// target with no configured tap (`homebrew-core`, or an unconfigured
117 /// `homebrew-tap`), where a reviewed PR + full `brew audit` is the right path.
118 BumpPr,
119}
120
121impl HomebrewAdapter {
122 /// Construct for a resolved homebrew adapter identity.
123 #[must_use]
124 pub fn new(adapter: Adapter) -> Self {
125 debug_assert!(matches!(
126 adapter,
127 Adapter::HomebrewTap | Adapter::HomebrewCore
128 ));
129 Self { adapter }
130 }
131
132 /// The destination tap slug (`owner/repo`) for a `homebrew-tap` create, when
133 /// the contract configured one. `None` for `homebrew-core` or an unconfigured
134 /// tap — which pins the adapter to the bump path (no bootstrap destination).
135 fn tap<'a>(&self, artifacts: Option<&'a HomebrewFormula>) -> Option<&'a str> {
136 if self.adapter != Adapter::HomebrewTap {
137 return None;
138 }
139 artifacts.and_then(|h| h.tap.as_deref())
140 }
141
142 /// Decide the formula path for `target`. A `homebrew-tap` with a configured
143 /// tap probes the tap for the formula: absent → [`FormulaPath::Create`],
144 /// present → [`FormulaPath::TapWrite`] (direct write to the tap). Every other
145 /// case (no configured tap: `homebrew-core`, or an unconfigured tap) is
146 /// [`FormulaPath::BumpPr`].
147 ///
148 /// The probe runs through the injected runner (`gh api …/contents/…`); a
149 /// non-zero exit (a `404`, typically) reads as *absent* → create. A spawn
150 /// failure is a real error and is propagated.
151 fn resolve_path(
152 &self,
153 ctx: &EffectCtx<'_>,
154 t: &AdapterTarget,
155 ) -> Result<FormulaPath, AdapterError> {
156 let homebrew = ctx.artifacts.homebrew.as_ref();
157 match self.tap(homebrew) {
158 Some(tap) => {
159 if Self::formula_exists(ctx, tap, &t.package)? {
160 Ok(FormulaPath::TapWrite)
161 } else {
162 Ok(FormulaPath::Create)
163 }
164 }
165 None => Ok(FormulaPath::BumpPr),
166 }
167 }
168
169 /// Ask the tap whether `Formula/<name>.rb` already exists, through the runner.
170 ///
171 /// Uses `gh api` against the tap's contents endpoint so no local checkout is
172 /// needed for the probe. Only three outcomes are safe to act on:
173 ///
174 /// - exit `0` (the file is served) ⇒ **present** → bump.
175 /// - a genuine `404` (`gh` renders it as `Not Found (HTTP 404)`) ⇒ **absent**
176 /// → create.
177 /// - **anything else** — auth failure, rate-limit, network error, a private or
178 /// renamed tap, a 5xx — is an [`AdapterError::Command`], **not** "absent".
179 /// Treating an infrastructure error as absence would trigger a spurious
180 /// create that clones the tap and could overwrite an existing formula.
181 ///
182 /// A spawn failure (the port could not run `gh`) is a genuine
183 /// [`AdapterError::Io`].
184 fn formula_exists(ctx: &EffectCtx<'_>, tap: &str, name: &str) -> Result<bool, AdapterError> {
185 let endpoint = format!("repos/{tap}/contents/Formula/{name}.rb");
186 let cmd = PlannedCommand::new("gh", &["api", "--silent", &endpoint]);
187 let out = ctx
188 .runner
189 .run("gh", &["api", "--silent", &endpoint], ctx.repo_root)
190 .map_err(|e| AdapterError::Io {
191 command: cmd.rendered(),
192 source: e.to_string(),
193 })?;
194 if out.status == Some(0) {
195 return Ok(true);
196 }
197 // A 404 is the only non-zero exit that means "absent". `gh` prints
198 // `Not Found (HTTP 404)`; match the stable `404` token on either stream.
199 if out.stderr.contains("404") || out.stdout.contains("404") {
200 return Ok(false);
201 }
202 let detail = if out.stderr.trim().is_empty() {
203 out.stdout
204 } else {
205 out.stderr
206 };
207 Err(AdapterError::Command {
208 command: cmd.rendered(),
209 code: out.status,
210 stderr: detail,
211 })
212 }
213
214 /// The `brew bump-formula-pr` command for an existing formula, carrying the
215 /// threaded release tarball `--url` (+ `--sha256` when a digest is present).
216 /// Options precede the `--` terminator so a formula name is never parsed as a
217 /// flag. Unchanged from the pre-bootstrap behaviour.
218 fn bump_command(&self, tarball: Option<&SourceTarball>, name: &str) -> PlannedCommand {
219 let mut args: Vec<String> = match self.adapter {
220 Adapter::HomebrewCore => vec!["bump-formula-pr".into(), "--no-fork".into()],
221 _ => vec!["bump-formula-pr".into()],
222 };
223 if let Some(tarball) = tarball {
224 args.push("--url".into());
225 args.push(tarball.url.clone());
226 if let Some(sha256) = &tarball.sha256 {
227 args.push("--sha256".into());
228 args.push(sha256.clone());
229 }
230 }
231 args.push("--".into());
232 args.push(name.to_string());
233 PlannedCommand {
234 program: "brew".into(),
235 args,
236 }
237 }
238
239 /// A **fresh, unpredictable** scratch checkout the create path clones the tap
240 /// into. Unique per attempt (pid + a monotonic-ish nanosecond stamp) so:
241 /// concurrent cuts/tests never collide; a retry never trips over a prior
242 /// attempt's leftover dir (the old "deterministic" path made `gh repo clone`
243 /// fail into a non-empty dir); and the unpredictable name defeats the classic
244 /// world-writable-`/tmp` symlink pre-creation (TOCTOU) attack. The file write
245 /// additionally uses create-new semantics (see [`Self::write_formula`]).
246 fn fresh_workdir(name: &str, version: &str) -> PathBuf {
247 let nanos = std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .map_or(0, |d| d.as_nanos());
250 std::env::temp_dir().join(format!(
251 "shipshape-homebrew-{name}-{version}-{}-{nanos}",
252 std::process::id()
253 ))
254 }
255
256 /// The branch the create path commits the new formula on.
257 fn create_branch(name: &str, version: &str) -> String {
258 format!("shipshape-homebrew-{name}-{version}")
259 }
260
261 /// The commit/PR title for a first formula.
262 fn create_title(name: &str, version: &str) -> String {
263 format!("{name} {version} (new formula)")
264 }
265
266 /// The ordered git/`gh` commands the create path runs (clone → branch → add →
267 /// commit → push → PR), into the pre-computed `workdir`. The generated `.rb`
268 /// is written to disk *between* the clone and the `add` (see
269 /// [`Self::publish`]); these are only the process steps, shared by
270 /// [`Self::dry_run`]'s preview and [`Self::publish`].
271 ///
272 /// `sha256_present` gates two things: a **draft** PR and a blocker in the body.
273 /// When the source-tarball digest is not yet known (the coordinator threads
274 /// `sha256: None` pre-tag), the generated formula carries only a `sha256`
275 /// TODO and would fail `brew audit` / cannot install — so the PR is opened as a
276 /// draft whose body states the one remaining manual step, rather than a
277 /// mergeable-looking PR that is silently broken.
278 fn create_commands(
279 tap: &str,
280 name: &str,
281 version: &str,
282 workdir: &str,
283 sha256_present: bool,
284 ) -> Vec<PlannedCommand> {
285 let branch = Self::create_branch(name, version);
286 let title = Self::create_title(name, version);
287 let formula_rel = format!("Formula/{name}.rb");
288 let body = if sha256_present {
289 "Automated first-formula bootstrap by shipshape.".to_string()
290 } else {
291 "Automated first-formula bootstrap by shipshape.\n\n**Blocked:** the \
292 `sha256` of the published release tarball is not yet known at cut \
293 time (the tag archive does not exist until after publish). Fill in \
294 the `sha256` once the tag is pushed, then mark this PR ready."
295 .to_string()
296 };
297 let mut pr = vec![
298 "pr".to_string(),
299 "create".to_string(),
300 "--repo".to_string(),
301 tap.to_string(),
302 "--head".to_string(),
303 branch.clone(),
304 "--title".to_string(),
305 title.clone(),
306 "--body".to_string(),
307 body,
308 ];
309 if !sha256_present {
310 pr.push("--draft".to_string());
311 }
312 vec![
313 PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
314 PlannedCommand::new("git", &["-C", workdir, "checkout", "-b", &branch]),
315 PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
316 // Set the commit identity explicitly (via `-c`): the freshly-cloned tap
317 // inherits no `user.name`/`user.email`, so on a clean CI runner an
318 // identity-less `git commit` fails with "Author identity unknown".
319 // Disable `commit.gpgsign` so a machine with global signing on cannot
320 // hang the automated commit waiting for a passphrase / missing GPG.
321 PlannedCommand::new(
322 "git",
323 &[
324 "-C",
325 workdir,
326 "-c",
327 "user.name=shipshape",
328 "-c",
329 "user.email=shipshape@users.noreply.github.com",
330 "-c",
331 "commit.gpgsign=false",
332 "commit",
333 "-m",
334 &title,
335 ],
336 ),
337 PlannedCommand::new(
338 "git",
339 &["-C", workdir, "push", "--set-upstream", "origin", &branch],
340 ),
341 PlannedCommand {
342 program: "gh".to_string(),
343 args: pr,
344 },
345 ]
346 }
347
348 /// Run the create path: generate the initial formula, clone the tap, write the
349 /// file, commit it on a branch, and open a PR — all effects through the runner
350 /// except the single filesystem write of the generated `.rb`.
351 fn run_create(
352 ctx: &EffectCtx<'_>,
353 t: &AdapterTarget,
354 tap: &str,
355 ) -> Result<PublishReceipt, AdapterError> {
356 // The package name reaches a filesystem path and a git pathspec; reject any
357 // traversal/separator before it can escape the checkout or the `Formula/` dir.
358 validate_package_name(&t.package)?;
359 let homebrew = ctx.artifacts.homebrew.as_ref();
360 let assets = verified_assets(ctx)?;
361 let license = homebrew.and_then(|h| h.license.as_deref());
362 let description = homebrew.and_then(|h| h.description.as_deref());
363 let homepage_slug = ctx.artifacts.repo_slug.as_deref();
364 let formula = render_formula(
365 &t.package,
366 &t.version,
367 homepage_slug,
368 description,
369 assets,
370 license,
371 );
372
373 // One workdir, computed once, used by both the clone and the write.
374 let workdir = Self::fresh_workdir(&t.package, &t.version);
375 let workdir_str = workdir.to_string_lossy().to_string();
376 let commands = Self::create_commands(tap, &t.package, &t.version, &workdir_str, true);
377 // 1. clone the tap.
378 run_all(ctx, &commands[..1])?;
379 // 2. write the generated formula into the checkout (create-new: refuses to
380 // overwrite a formula that already exists in the clone — the last-line
381 // guard against a probe/clone race or a mis-detected "absent").
382 Self::write_formula(&workdir, &t.package, &formula, WriteMode::CreateNew)?;
383 // 3. branch → add → commit → push → PR.
384 let outputs = run_all(ctx, &commands[1..])?;
385
386 // Record the PR URL `gh pr create` prints as the receipt's `remote_url`
387 // (the field already existed — recording it is not a JSON-shape change).
388 // `gh` can precede the URL with status lines, so take the last line that
389 // looks like a URL rather than the whole stdout blob.
390 let remote_url = outputs.last().and_then(|o| {
391 o.stdout
392 .lines()
393 .rev()
394 .map(str::trim)
395 .find(|line| line.starts_with("https://"))
396 .map(str::to_string)
397 });
398 Ok(make_receipt(ctx, t, None, remote_url))
399 }
400
401 /// The commit title for a tap-write formula update.
402 fn update_title(name: &str, version: &str) -> String {
403 format!("{name} {version}")
404 }
405
406 /// The ordered git/`gh` commands the *tap-write* path runs: clone → add →
407 /// commit → push **to the tap's default branch** (no branch, no PR — the
408 /// generated `.rb` is what `brew install` resolves). The rendered formula is
409 /// overwritten onto disk *between* the clone (`commands[..1]`) and the `add`
410 /// (`commands[1..]`), exactly like [`Self::create_commands`]; these are only
411 /// the process steps, shared by [`Self::dry_run`]'s preview and
412 /// [`Self::run_tap_write`].
413 ///
414 /// `git push origin HEAD` publishes the freshly-committed default branch (the
415 /// clone checks the default branch out, so `HEAD` is it) — matching the manual
416 /// fallback that pushed the formula straight to the tap.
417 fn update_commands(tap: &str, name: &str, version: &str, workdir: &str) -> Vec<PlannedCommand> {
418 let title = Self::update_title(name, version);
419 let formula_rel = format!("Formula/{name}.rb");
420 vec![
421 PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
422 PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
423 // Set the commit identity explicitly (via `-c`): the freshly-cloned tap
424 // inherits no `user.name`/`user.email`, so on a clean CI runner an
425 // identity-less `git commit` fails with "Author identity unknown".
426 // Disable `commit.gpgsign` so a machine with global signing on cannot
427 // hang the automated commit waiting for a passphrase / missing GPG.
428 PlannedCommand::new(
429 "git",
430 &[
431 "-C",
432 workdir,
433 "-c",
434 "user.name=shipshape",
435 "-c",
436 "user.email=shipshape@users.noreply.github.com",
437 "-c",
438 "commit.gpgsign=false",
439 "commit",
440 "-m",
441 &title,
442 ],
443 ),
444 PlannedCommand::new("git", &["-C", workdir, "push", "origin", "HEAD"]),
445 ]
446 }
447
448 /// Run the *tap-write* path: render the updated formula from the **verified**
449 /// `url` + `sha256`, clone the tap, overwrite `Formula/<name>.rb`, commit, and
450 /// push to the tap's default branch.
451 ///
452 /// **Fail-closed contract.** This path pushes to the tap's default branch — the
453 /// ref `brew install <tap>/<name>` resolves — so it refuses to write a formula
454 /// without a verified `sha256`: a missing tarball, an absent digest, or one that
455 /// is not exactly 64 hex chars is a hard [`AdapterError::Command`], never a TODO
456 /// placeholder. Unlike the create path's draft PR, there is no human review gate
457 /// here, so a guessed/absent/malformed digest would ship a broken install.
458 ///
459 /// **Must already exist.** `resolve_path` chose this path from a `gh api` probe
460 /// that reported the formula present; after cloning, this re-checks that the tap
461 /// actually carries `Formula/<name>.rb` as a *regular file* before overwriting.
462 /// If a probe/clone race left it absent (or it is a symlink/dir), it fails closed
463 /// rather than *synthesize* a new formula straight onto the default branch — that
464 /// would bypass the create path's PR review gate (and, for a symlink, clobber a
465 /// file outside the checkout).
466 ///
467 /// **Ownership-marker safety** (issue `homebrew-tapwrite-preserve-formula`). It
468 /// never blindly overwrites the existing formula. If the tap's current file
469 /// carries the shipshape ownership marker (see [`render_formula`]), it *is* shipshape
470 /// output and is safe to fully regenerate. If it does not (a hand-maintained
471 /// formula with extra `depends_on`s, `resource`s, `caveats`, a custom `test`, …),
472 /// this path refuses with instructions to add the ownership marker deliberately.
473 /// It never silently claims or rewrites hand-authored content. The reader accepts
474 /// both Shipshape and legacy ossctl ownership markers (ADR-0005).
475 ///
476 /// **Idempotent.** It compares the regenerated content against the tap's current
477 /// bytes; an exact match is a clean no-op
478 /// success (a resume/re-run at the target version), so it neither rewrites the
479 /// file nor pushes an empty commit.
480 fn run_tap_write(
481 ctx: &EffectCtx<'_>,
482 t: &AdapterTarget,
483 tap: &str,
484 ) -> Result<PublishReceipt, AdapterError> {
485 // The package name reaches a filesystem path and a git pathspec; reject any
486 // traversal/separator before it can escape the checkout or the `Formula/` dir.
487 validate_package_name(&t.package)?;
488 let homebrew = ctx.artifacts.homebrew.as_ref();
489 let assets = verified_assets(ctx)?;
490 let license = homebrew.and_then(|h| h.license.as_deref());
491 let description = homebrew.and_then(|h| h.description.as_deref());
492 let homepage_slug = ctx.artifacts.repo_slug.as_deref();
493
494 let workdir = Self::fresh_workdir(&t.package, &t.version);
495 let workdir_str = workdir.to_string_lossy().to_string();
496 let commands = Self::update_commands(tap, &t.package, &t.version, &workdir_str);
497 // 1. clone the tap (its default branch).
498 run_all(ctx, &commands[..1])?;
499 // 2. the formula must already be a regular file in the clone (see the
500 // "Must already exist" contract above) — read its current bytes.
501 let formula_path = workdir.join("Formula").join(format!("{}.rb", t.package));
502 let current = Self::read_existing_formula(&formula_path, &t.package)?;
503 // 3. ownership marker (issue `homebrew-tapwrite-preserve-formula`): only a
504 // formula Shipshape or ossctl generated (carrying either recognized marker)
505 // is safe to fully regenerate. An unmarked, hand-maintained formula must NOT
506 // be clobbered. The actionable refusal below requires the maintainer to add
507 // the marker explicitly before Shipshape claims full ownership.
508 let updated = if formula_carries_marker(¤t) {
509 render_formula(
510 &t.package,
511 &t.version,
512 homepage_slug,
513 description,
514 assets,
515 license,
516 )
517 } else {
518 return Err(AdapterError::Command {
519 command: "homebrew formula update".into(),
520 code: None,
521 stderr: format!(
522 "refusing to replace an unmarked source-build or hand-maintained formula with a generated prebuilt formula; to explicitly authorize Shipshape ownership, make the file's first line exactly `# Generated by shipshape; do not edit by hand (template-version: {FORMULA_TEMPLATE_VERSION})`"
523 ),
524 });
525 };
526 // 4. idempotent no-op: the tap already carries exactly this content.
527 let remote_url = Some(format!(
528 "https://github.com/{tap}/blob/HEAD/Formula/{}.rb",
529 t.package
530 ));
531 if current == updated.as_bytes() {
532 return Ok(make_receipt(ctx, t, None, remote_url));
533 }
534 // 5. overwrite the existing formula, then add → commit → push.
535 Self::write_formula(&workdir, &t.package, &updated, WriteMode::Overwrite)?;
536 run_all(ctx, &commands[1..])?;
537 Ok(make_receipt(ctx, t, None, remote_url))
538 }
539
540 /// Read the tap's current `Formula/<name>.rb` bytes, enforcing the tap-write
541 /// invariant that it is an **already-present regular file**. A missing file (a
542 /// probe/clone race) or a non-regular node (a symlink the overwrite would follow
543 /// out of the checkout, or a directory) is a fail-closed [`AdapterError`] — never
544 /// a silent create. Uses `symlink_metadata` so a symlink is *detected*, not
545 /// traversed.
546 fn read_existing_formula(path: &std::path::Path, name: &str) -> Result<Vec<u8>, AdapterError> {
547 let meta = std::fs::symlink_metadata(path).map_err(|e| AdapterError::Command {
548 command: "homebrew formula update".into(),
549 code: None,
550 stderr: format!(
551 "the tap was probed as carrying `{name}.rb` but the cloned checkout does not \
552 (`{}`: {e}) — refusing to synthesize a formula on the default branch without the \
553 create-path review gate",
554 path.display()
555 ),
556 })?;
557 if !meta.file_type().is_file() {
558 return Err(AdapterError::Filesystem {
559 path: path.to_string_lossy().to_string(),
560 source: "not a regular file (symlink or directory) — refusing to overwrite".into(),
561 });
562 }
563 std::fs::read(path).map_err(|e| AdapterError::Filesystem {
564 path: path.to_string_lossy().to_string(),
565 source: e.to_string(),
566 })
567 }
568
569 /// Write the generated formula to `<workdir>/Formula/<name>.rb`, creating the
570 /// `Formula/` directory if the freshly-cloned tap does not carry it yet.
571 ///
572 /// This is the one direct-filesystem effect in the adapter — Homebrew has no
573 /// "add a formula" CLI; a new formula *is* a committed file, so `run_all`
574 /// (which only *runs processes*) cannot express it. It is deliberately scoped:
575 /// it writes exactly one file into a private, unpredictable [`Self::fresh_workdir`]
576 /// the calling path just cloned into. A general filesystem port on `EffectCtx`
577 /// is the cleaner long-term home (issue `homebrew-adapter-fs-port`); until then
578 /// this is mapped to a distinct [`AdapterError::Filesystem`] so the effect is
579 /// explicit, not hidden.
580 ///
581 /// [`WriteMode`] gates the open semantics:
582 /// - [`WriteMode::CreateNew`] (the create path) uses **create-new** (`O_EXCL`)
583 /// so it never follows a symlink onto, or truncates, an existing file — which
584 /// also fails loudly if the tap already carries the formula (a last-line guard
585 /// against a mis-detected "absent" formula).
586 /// - [`WriteMode::Overwrite`] (the tap-write path) truncates the already-present
587 /// formula **without** `create` — the caller ([`Self::run_tap_write`]) has
588 /// already verified via [`Self::read_existing_formula`] that it is an existing
589 /// regular file, so an open failure here means it vanished under us (a race),
590 /// which is a fail-closed error rather than a silent create.
591 fn write_formula(
592 workdir: &std::path::Path,
593 name: &str,
594 formula: &str,
595 mode: WriteMode,
596 ) -> Result<(), AdapterError> {
597 let dir = workdir.join("Formula");
598 std::fs::create_dir_all(&dir).map_err(|e| AdapterError::Filesystem {
599 path: dir.to_string_lossy().to_string(),
600 source: e.to_string(),
601 })?;
602 let path = dir.join(format!("{name}.rb"));
603 let mut opts = std::fs::OpenOptions::new();
604 opts.write(true);
605 match mode {
606 WriteMode::CreateNew => {
607 opts.create_new(true);
608 }
609 WriteMode::Overwrite => {
610 opts.truncate(true);
611 }
612 }
613 let mut file = opts.open(&path).map_err(|e| AdapterError::Filesystem {
614 path: path.to_string_lossy().to_string(),
615 source: e.to_string(),
616 })?;
617 std::io::Write::write_all(&mut file, formula.as_bytes()).map_err(|e| {
618 AdapterError::Filesystem {
619 path: path.to_string_lossy().to_string(),
620 source: e.to_string(),
621 }
622 })
623 }
624}
625
626/// How [`HomebrewAdapter::write_formula`] opens the target `.rb`: create-new
627/// (`O_EXCL`, the first-formula create) or truncate-an-existing-file (the tap-write
628/// bump, whose caller has already proven the file is a present regular file).
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630enum WriteMode {
631 /// Refuse to open an existing file (`O_EXCL`) — the create path's guard.
632 CreateNew,
633 /// Truncate an existing file; **no** `create`, so a vanished file is an error,
634 /// not a silent create — the tap-write path replacing an existing formula.
635 Overwrite,
636}
637
638impl ReleaseAdapter for HomebrewAdapter {
639 fn adapter(&self) -> Adapter {
640 self.adapter
641 }
642
643 fn dry_run(
644 &self,
645 ctx: &EffectCtx<'_>,
646 t: &AdapterTarget,
647 ) -> Result<DryRunReport, AdapterError> {
648 let tarball = ctx.artifacts.source_tarball.as_ref();
649 let path = self.resolve_path(ctx, t)?;
650 let (planned_commands, mut notes) = match path {
651 FormulaPath::Create => {
652 // `tap` is Some whenever resolve_path returned Create.
653 let tap = self
654 .tap(ctx.artifacts.homebrew.as_ref())
655 .unwrap_or_default();
656 let workdir = Self::fresh_workdir(&t.package, &t.version);
657 let sha256_present = tarball.and_then(|tb| tb.sha256.as_deref()).is_some();
658 (
659 Self::create_commands(
660 tap,
661 &t.package,
662 &t.version,
663 &workdir.to_string_lossy(),
664 sha256_present,
665 ),
666 vec![format!(
667 "create path: `{}` has no `{}.rb` yet — generating the initial \
668 source-build formula and opening a{} PR",
669 tap,
670 t.package,
671 if sha256_present { "" } else { " draft" }
672 )],
673 )
674 }
675 FormulaPath::TapWrite => {
676 // `tap` is Some whenever resolve_path returned TapWrite.
677 let tap = self
678 .tap(ctx.artifacts.homebrew.as_ref())
679 .unwrap_or_default();
680 let workdir = Self::fresh_workdir(&t.package, &t.version);
681 let mut notes = vec![format!(
682 "tap-write path: `{}` already serves `{}.rb` — rendering the updated \
683 formula and pushing it directly to the tap's default branch (no \
684 `brew`, no PR)",
685 tap, t.package,
686 )];
687 // Surface the fail-closed requirement rather than let publish fail
688 // late: this path refuses to push without a verified 64-hex sha256,
689 // which the coordinator threads only in the post-tag dist phase.
690 if tarball.and_then(|tb| tb.sha256.as_deref()).is_none() {
691 notes.push(
692 "publish will require a verified post-tag sha256 (absent in this \
693 pre-tag preview); the coordinator supplies it after the tag is pushed"
694 .to_string(),
695 );
696 }
697 (
698 Self::update_commands(tap, &t.package, &t.version, &workdir.to_string_lossy()),
699 notes,
700 )
701 }
702 FormulaPath::BumpPr => (
703 vec![self.bump_command(tarball, &t.package)],
704 vec![
705 "bump-PR path: no configured tap — `brew bump-formula-pr` opens a reviewed PR"
706 .to_string(),
707 ],
708 ),
709 };
710 match tarball {
711 Some(tb) => {
712 // The bump-PR path lets `brew` derive the digest from `--url`; the
713 // create / tap-write paths get a verified digest threaded post-tag.
714 let sha = tb.sha256.as_deref().unwrap_or({
715 if path == FormulaPath::BumpPr {
716 "(computed by brew from --url)"
717 } else {
718 "(resolved and verified by the coordinator post-tag)"
719 }
720 });
721 notes.push(format!("url: {} ; sha256: {sha}", tb.url));
722 }
723 None => notes
724 .push("source tarball url is resolved by the coordinator at cut time".to_string()),
725 }
726 Ok(DryRunReport {
727 adapter: self.adapter,
728 planned_commands,
729 notes,
730 })
731 }
732
733 fn build(
734 &self,
735 _ctx: &EffectCtx<'_>,
736 _t: &AdapterTarget,
737 ) -> Result<BuildArtifacts, AdapterError> {
738 // Homebrew has no build phase of its own — it repackages an existing
739 // release artifact. Return an empty manifest rather than shelling out.
740 Ok(BuildArtifacts {
741 adapter: self.adapter,
742 artifacts: vec![],
743 notes: vec!["homebrew has no build phase (formula create/update only)".to_string()],
744 })
745 }
746
747 fn publish(
748 &self,
749 ctx: &EffectCtx<'_>,
750 t: &AdapterTarget,
751 ) -> Result<PublishReceipt, AdapterError> {
752 // PER-TARGET IRREVERSIBLE (pushes a formula to the tap, or opens a PR).
753 match self.resolve_path(ctx, t)? {
754 FormulaPath::Create => {
755 let tap = self
756 .tap(ctx.artifacts.homebrew.as_ref())
757 .expect("resolve_path returns Create only when a tap is configured");
758 Self::run_create(ctx, t, tap)
759 }
760 FormulaPath::TapWrite => {
761 let tap = self
762 .tap(ctx.artifacts.homebrew.as_ref())
763 .expect("resolve_path returns TapWrite only when a tap is configured");
764 Self::run_tap_write(ctx, t, tap)
765 }
766 FormulaPath::BumpPr => {
767 let cmd = self.bump_command(ctx.artifacts.source_tarball.as_ref(), &t.package);
768 let outputs = run_all(ctx, &[cmd])?;
769 let remote_url = outputs.last().and_then(|output| {
770 output
771 .stdout
772 .lines()
773 .rev()
774 .map(str::trim)
775 .find(|line| line.starts_with("https://"))
776 .map(str::to_string)
777 });
778 Ok(make_receipt(ctx, t, None, remote_url))
779 }
780 }
781 }
782
783 fn verify(
784 &self,
785 ctx: &EffectCtx<'_>,
786 receipt: &PublishReceipt,
787 ) -> Result<VerifyOutcome, AdapterError> {
788 // The receipt records the tap URL produced by the publish path; use it,
789 // rather than re-reading the mutable contract, to locate the artifact.
790 let Some(url) = receipt.remote_url.as_deref() else {
791 return Ok(VerifyOutcome::Unknown);
792 };
793 let parts: Vec<_> = url.split('/').collect();
794 let Some(github) = parts.iter().position(|part| *part == "github.com") else {
795 return Ok(VerifyOutcome::Unknown);
796 };
797 let (Some(owner), Some(repo)) = (parts.get(github + 1), parts.get(github + 2)) else {
798 return Ok(VerifyOutcome::Unknown);
799 };
800 Ok(verify_tap_formula(
801 ctx,
802 &format!("{owner}/{repo}"),
803 &receipt.package,
804 &receipt.version,
805 self.adapter == Adapter::HomebrewTap,
806 None,
807 ))
808 }
809
810 fn timeout(&self) -> Duration {
811 Duration::from_secs(600)
812 }
813}
814
815/// Read a tap formula and verify the released version plus each expected platform
816/// stanza. `require_marker` is true only for engine-owned `homebrew-tap` formulas:
817/// cargo-dist's CI-owned formulas have no shipshape marker by design.
818pub(crate) fn verify_tap_formula(
819 ctx: &EffectCtx<'_>,
820 tap: &str,
821 package: &str,
822 version: &str,
823 require_marker: bool,
824 expected_platforms: Option<&[String]>,
825) -> VerifyOutcome {
826 // A delegated formula is written asynchronously after the tag. Give each poll
827 // a distinct URL so a cached 404 or prior formula body cannot outlive the CI
828 // write for the whole bounded verify window.
829 let raw = format!(
830 "https://raw.githubusercontent.com/{tap}/HEAD/Formula/{package}.rb?shipshape_verify={}",
831 ctx.clock.now_unix()
832 );
833 let (status, body) = match ctx.registry.http_get(&raw) {
834 Ok(response) => response,
835 Err(_) => return VerifyOutcome::Unknown,
836 };
837 if status == 404 {
838 return VerifyOutcome::Missing;
839 }
840 if status != 200 {
841 return VerifyOutcome::Unknown;
842 }
843 let formula = match String::from_utf8(body) {
844 Ok(formula) => formula,
845 Err(_) => return VerifyOutcome::Unknown,
846 };
847 if require_marker && !formula_carries_marker(formula.as_bytes()) {
848 return VerifyOutcome::Missing;
849 }
850 if !formula
851 .lines()
852 .any(|line| line.trim() == format!("version \"{version}\""))
853 {
854 return VerifyOutcome::Missing;
855 }
856 // Every Homebrew-servable platform declared by a stored plan must retain a
857 // URL+sha stanza. Pre-plan-store journals cannot recover that exact set; for
858 // them require at least one complete supported-platform stanza rather than
859 // falling back to the old structurally-unobservable `Unknown` result.
860 let expected = expected_platforms
861 .filter(|platforms| !platforms.is_empty())
862 .or_else(|| {
863 ctx.artifacts
864 .homebrew
865 .as_ref()
866 .map(|homebrew| homebrew.platforms.as_slice())
867 });
868 let observed = [
869 "if OS.mac? && Hardware::CPU.arm?",
870 "if OS.mac? && Hardware::CPU.intel?",
871 "if OS.linux? && Hardware::CPU.arm?",
872 "if OS.linux? && Hardware::CPU.intel?",
873 ];
874 let conditions: Vec<&str> = match expected {
875 Some(platforms) => platforms
876 .iter()
877 .filter_map(|triple| homebrew_platform_condition(triple))
878 .collect(),
879 None => observed
880 .into_iter()
881 .filter(|condition| formula.contains(condition))
882 .collect(),
883 };
884 if conditions.is_empty() {
885 return VerifyOutcome::Missing;
886 }
887 for condition in conditions {
888 if !formula_has_platform_stanza(&formula, condition) {
889 return VerifyOutcome::Missing;
890 }
891 }
892 VerifyOutcome::Matches
893}
894
895/// Whether a formula contains a URL and checksum in the exact block for
896/// `condition`. shipshape's renderer uses one combined condition, while cargo-dist
897/// 0.28.x nests a CPU block inside an OS block and later repeats combined guards
898/// in `install` without download fields. Block-scoped matching prevents those
899/// later guards from shadowing a complete nested download stanza.
900fn formula_has_platform_stanza(formula: &str, condition: &str) -> bool {
901 fn block_for_condition<'a>(lines: &'a [&str], condition: &str) -> Option<&'a [&'a str]> {
902 let start = lines.iter().position(|line| line.trim() == condition)?;
903 let indent = lines[start].len() - lines[start].trim_start().len();
904 let end = lines[start + 1..].iter().position(|line| {
905 line.trim() == "end" && line.len() - line.trim_start().len() == indent
906 })?;
907 Some(&lines[start + 1..start + 1 + end])
908 }
909
910 fn has_url_and_sha(lines: &[&str]) -> bool {
911 lines
912 .iter()
913 .any(|line| line.trim_start().starts_with("url \""))
914 && lines
915 .iter()
916 .any(|line| line.trim_start().starts_with("sha256 \""))
917 }
918
919 let (os, cpu) = match condition {
920 "if OS.mac? && Hardware::CPU.arm?" => ("if OS.mac?", "if Hardware::CPU.arm?"),
921 "if OS.mac? && Hardware::CPU.intel?" => ("if OS.mac?", "if Hardware::CPU.intel?"),
922 "if OS.linux? && Hardware::CPU.arm?" => ("if OS.linux?", "if Hardware::CPU.arm?"),
923 "if OS.linux? && Hardware::CPU.intel?" => ("if OS.linux?", "if Hardware::CPU.intel?"),
924 _ => return false,
925 };
926 let lines: Vec<&str> = formula.lines().collect();
927
928 // Prefer cargo-dist's nested download shape. Its later install method uses
929 // combined guards with the same platform identity but deliberately has no URL.
930 if let Some(os_block) = block_for_condition(&lines, os) {
931 if let Some(cpu_block) = block_for_condition(os_block, cpu) {
932 if has_url_and_sha(cpu_block) {
933 return true;
934 }
935 }
936 }
937
938 block_for_condition(&lines, condition).is_some_and(has_url_and_sha)
939}
940
941/// Render a prebuilt-archive Homebrew formula. The archives are fetched and
942/// hashed by the post-tag coordinator before this is called; there is deliberately
943/// no source-build fallback.
944pub(super) fn render_formula(
945 name: &str,
946 version: &str,
947 homepage_slug: Option<&str>,
948 description: Option<&str>,
949 assets: &[HomebrewAsset],
950 license: Option<&str>,
951) -> String {
952 let class = formula_class(name);
953 let name_lit = ruby_escape(name);
954 let homepage = homepage_slug.map_or_else(
955 || "https://github.com".to_string(),
956 |s| format!("https://github.com/{s}"),
957 );
958 let desc = ruby_escape(description.unwrap_or(name));
959 let mut platforms = String::new();
960 for asset in assets {
961 let Some(condition) = homebrew_platform_condition(&asset.triple) else {
962 continue;
963 };
964 let _ = write!(
965 platforms,
966 " {condition}\n url \"{}\"\n sha256 \"{}\"\n end\n",
967 ruby_escape(&asset.url),
968 ruby_escape(&asset.sha256)
969 );
970 }
971 let license_line = license
972 .map(|l| format!(" license \"{}\"\n", ruby_escape(l)))
973 .unwrap_or_default();
974 let marker = format!("{FORMULA_MARKER_PREFIX} {FORMULA_TEMPLATE_VERSION})");
975 format!("{marker}\nclass {class} < Formula\n desc \"{desc}\"\n homepage \"{}\"\n version \"{}\"\n{platforms}{license_line}\n def install\n bin.install \"{name_lit}\"\n end\n\n test do\n system bin/\"{name_lit}\", \"version\"\n end\nend\n", ruby_escape(&homepage), ruby_escape(version))
976}
977
978/// Return the Homebrew formula condition for a cargo-dist target triple, if
979/// Homebrew can serve it. Other distribution platforms, such as Windows, are
980/// served through the GitHub Release instead.
981pub(crate) fn homebrew_platform_condition(triple: &str) -> Option<&'static str> {
982 match triple {
983 "aarch64-apple-darwin" => Some("if OS.mac? && Hardware::CPU.arm?"),
984 "x86_64-apple-darwin" => Some("if OS.mac? && Hardware::CPU.intel?"),
985 "aarch64-unknown-linux-musl" => Some("if OS.linux? && Hardware::CPU.arm?"),
986 "x86_64-unknown-linux-musl" => Some("if OS.linux? && Hardware::CPU.intel?"),
987 _ => None,
988 }
989}
990
991/// Return only a complete, checked archive set. A default-branch tap is user-facing,
992/// so a missing, duplicate, or malformed release asset is a hard failure rather than
993/// a source-build fallback or an unchecked formula.
994fn verified_assets<'a>(ctx: &'a EffectCtx<'a>) -> Result<&'a [HomebrewAsset], AdapterError> {
995 let assets = &ctx.artifacts.homebrew_assets;
996 if assets.is_empty()
997 || assets
998 .iter()
999 .any(|a| !is_sha256_hex(&a.sha256) || a.url.is_empty())
1000 {
1001 return Err(AdapterError::Command {
1002 command: "homebrew formula update".into(),
1003 code: None,
1004 stderr: "refusing to write a Homebrew formula without verified prebuilt release assets and SHA-256 checksums".into(),
1005 });
1006 }
1007 Ok(assets)
1008}
1009
1010/// Escape a value for inclusion in a Ruby double-quoted string literal:
1011/// backslashes first, then double quotes, then `#`. Prevents a contract-supplied
1012/// `"` or `\` from terminating the literal, and — critically — escaping `#` closes
1013/// Ruby's `#{…}` string **interpolation**, which would otherwise evaluate arbitrary
1014/// Ruby (code execution when `brew` loads the formula) from a value like
1015/// `#{system('…')}`. `\#` renders as a literal `#`, so escaping every `#` is safe.
1016fn ruby_escape(s: &str) -> String {
1017 s.replace('\\', "\\\\")
1018 .replace('"', "\\\"")
1019 .replace('#', "\\#")
1020}
1021
1022/// Surgically update **only** the double-quoted value of the single `url "…"` and
1023/// `sha256 "…"` stanzas of a hand-maintained (unmarked) formula, preserving every
1024/// other byte verbatim — `depends_on`s, `resource` blocks, `caveats`, `service`,
1025/// custom `test`, comments, blank lines, the file's indentation, any trailing
1026/// stanza options (`url "…", using: :git`, `sha256 "…" => :arm64`), inline `#`
1027/// comments, and the original line endings (LF or CRLF).
1028///
1029/// **Only the quoted value is rewritten, in place** (not the whole line): the bytes
1030/// before the opening quote (indent + keyword + spacing) and everything after the
1031/// closing quote are copied through untouched. This is what keeps trailing options,
1032/// comments, and a CRLF `\r` intact, so an already-current formula stays a
1033/// byte-for-byte no-op.
1034///
1035/// **Fail-closed.** The tap-write path pushes straight to the tap's default branch,
1036/// so this refuses to guess. A stanza matches only Homebrew's canonical
1037/// `<indent><keyword> "<value>"` shape (keyword as a whole token, its argument the
1038/// immediately-following double-quoted literal); anything else — `url = x`,
1039/// `url(...)`, a parenthesized/heredoc form — simply does not match. It then requires
1040/// **exactly one** `url` line and **exactly one** `sha256` line, and every matched
1041/// literal to be properly closed. Zero matches (no editable stanza), multiple matches
1042/// (a `resource` block's own pair, an arch-conditional bottle/`sha256` set), or an
1043/// unterminated literal (a continuation line) is a hard [`AdapterError`] — never a
1044/// partial or ambiguous rewrite. The interpolated `url`/`sha256` are [`ruby_escape`]d
1045/// exactly as the full renderer escapes them.
1046#[allow(dead_code)]
1047fn surgical_url_sha_edit(current: &str, url: &str, sha256: &str) -> Result<String, AdapterError> {
1048 // A canonical stanza line: `<indent><keyword> "<value>"…` — the keyword is a
1049 // whole token (whitespace follows it, so `url` never matches `urls`/`url_x`), and
1050 // its argument is the immediately-following double-quoted literal (so `url = x`
1051 // and `url(...)` do not match and are left for the fail-closed hit-count guard).
1052 fn is_stanza_line(line: &str, keyword: &str) -> bool {
1053 let trimmed = line.trim_start();
1054 let Some(rest) = trimmed.strip_prefix(keyword) else {
1055 return false;
1056 };
1057 rest.starts_with(|c: char| c.is_ascii_whitespace()) && rest.trim_start().starts_with('"')
1058 }
1059
1060 // Replace the contents of the first double-quoted literal on `line` with
1061 // `new_value` (ruby-escaped), preserving the prefix (indent + keyword + spacing)
1062 // and the entire suffix after the closing quote (trailing options, `# comments`,
1063 // and the CRLF `\r`). Returns `None` if the literal is not closed (a malformed or
1064 // continuation line) so the caller can fail closed. The closing quote is the next
1065 // un-escaped `"`; `"`/`\` are ASCII, so byte scanning never splits a UTF-8 char.
1066 fn rewrite_quoted_value(line: &str, new_value: &str) -> Option<String> {
1067 let open = line.find('"')?;
1068 let after = &line[open + 1..];
1069 let bytes = after.as_bytes();
1070 let mut i = 0;
1071 let close = loop {
1072 match bytes.get(i)? {
1073 b'\\' => i += 2, // skip the escaped char (a trailing `\` runs off → None)
1074 b'"' => break i,
1075 _ => i += 1,
1076 }
1077 };
1078 let prefix = &line[..open];
1079 let suffix = &after[close + 1..];
1080 Some(format!("{prefix}\"{}\"{suffix}", ruby_escape(new_value)))
1081 }
1082
1083 let mut url_hits = 0usize;
1084 let mut sha_hits = 0usize;
1085 let mut malformed = false;
1086 // Preserve the input's exact line structure — indentation, blank lines, a
1087 // trailing newline (a final empty segment rejoins to reproduce it), and each
1088 // line's own `\r` — by splitting on '\n' and writing back with '\n'.
1089 let mut rebuilt = String::with_capacity(current.len() + 64);
1090 for (idx, line) in current.split('\n').enumerate() {
1091 if idx > 0 {
1092 rebuilt.push('\n');
1093 }
1094 let (hits, new_value) = if is_stanza_line(line, "url") {
1095 (Some(&mut url_hits), url)
1096 } else if is_stanza_line(line, "sha256") {
1097 (Some(&mut sha_hits), sha256)
1098 } else {
1099 (None, "")
1100 };
1101 if let Some(counter) = hits {
1102 *counter += 1;
1103 if let Some(rewritten) = rewrite_quoted_value(line, new_value) {
1104 rebuilt.push_str(&rewritten);
1105 } else {
1106 malformed = true;
1107 rebuilt.push_str(line);
1108 }
1109 } else {
1110 rebuilt.push_str(line);
1111 }
1112 }
1113
1114 if url_hits != 1 || sha_hits != 1 || malformed {
1115 return Err(AdapterError::Command {
1116 command: "homebrew formula update".into(),
1117 code: None,
1118 stderr: format!(
1119 "refusing to update the hand-maintained tap formula: it carries no shipshape \
1120 ownership marker, and a safe surgical `url`/`sha256` edit needs exactly one \
1121 canonical `url \"…\"` line and one `sha256 \"…\"` line with a properly closed \
1122 literal, but found {url_hits} `url` and {sha_hits} `sha256`{} — update the \
1123 formula by hand, or add the shipshape marker \
1124 (`{FORMULA_MARKER_PREFIX} {FORMULA_TEMPLATE_VERSION})`) as the first line to \
1125 opt into full regeneration",
1126 if malformed {
1127 " (a matched stanza's quoted value was not closed on its line)"
1128 } else {
1129 ""
1130 }
1131 ),
1132 });
1133 }
1134 Ok(rebuilt)
1135}
1136
1137/// Whether `s` is a syntactically valid SHA-256 digest: exactly 64 ASCII hex
1138/// characters. The tap-write fail-closed check rejects an absent OR malformed digest
1139/// (`Some("")`, `Some("garbage")`, a wrong length) — `Some(_)` alone is not proof of
1140/// a verified hash.
1141fn is_sha256_hex(s: &str) -> bool {
1142 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
1143}
1144
1145/// Reject a package name that could escape the `Formula/` directory or the git
1146/// pathspec when interpolated into `Formula/<name>.rb` / `<name>.rb` — an empty
1147/// name, a path separator (`/`, `\`), a `..` traversal component, or a leading `.`.
1148/// The name is otherwise trusted (it reaches `desc`/`bin` via [`ruby_escape`]); this
1149/// guards only the filesystem/path uses.
1150fn validate_package_name(name: &str) -> Result<(), AdapterError> {
1151 let bad = name.is_empty()
1152 || name.starts_with('.')
1153 || name.contains('/')
1154 || name.contains('\\')
1155 || name.split(['/', '\\']).any(|seg| seg == "..");
1156 if bad {
1157 return Err(AdapterError::Filesystem {
1158 path: name.to_string(),
1159 source: "invalid Homebrew package name — must not be empty, start with `.`, or \
1160 contain a path separator or `..` traversal component"
1161 .into(),
1162 });
1163 }
1164 Ok(())
1165}
1166
1167/// Homebrew's formula class name for `name`: alphanumeric runs capitalised and
1168/// concatenated (`my-tool` → `MyTool`, `shipshape` → `Shipshape`). A small, faithful
1169/// subset of Homebrew's `Formulary.class_s` — enough for the ordinary tap names
1170/// this generator targets.
1171///
1172/// A Ruby constant may not begin with a digit, so a leading-digit name is
1173/// prefixed with `X` (as Homebrew itself does: `2fa` → `X2fa`); a name that
1174/// reduces to nothing falls back to `Formula` so the output is always a legal
1175/// constant rather than a syntax error.
1176fn formula_class(name: &str) -> String {
1177 let mut out = String::new();
1178 for segment in name.split(|c: char| !c.is_ascii_alphanumeric()) {
1179 let mut chars = segment.chars();
1180 if let Some(first) = chars.next() {
1181 out.extend(first.to_uppercase());
1182 out.push_str(chars.as_str());
1183 }
1184 }
1185 if out.is_empty() {
1186 return "Formula".to_string();
1187 }
1188 if out.starts_with(|c: char| c.is_ascii_digit()) {
1189 out.insert(0, 'X');
1190 }
1191 out
1192}
1193
1194#[cfg(test)]
1195mod platform_stanza_tests {
1196 use super::{formula_carries_marker, formula_has_platform_stanza};
1197
1198 #[test]
1199 fn current_and_legacy_ownership_markers_are_both_trusted() {
1200 assert!(formula_carries_marker(
1201 b"# Generated by shipshape; do not edit by hand (template-version: 2)\nclass Tool"
1202 ));
1203 assert!(formula_carries_marker(
1204 b"# Generated by ossctl; do not edit by hand (template-version: 2)\nclass Tool"
1205 ));
1206 assert!(!formula_carries_marker(
1207 b"# hand maintained\n# Generated by ossctl; do not edit by hand (template-version: 2)"
1208 ));
1209 }
1210
1211 const CARGO_DIST_0_28_2_FORMULA: &str =
1212 include_str!("../fixtures/project-canon-cargo-dist-0.28.2.rb");
1213
1214 #[test]
1215 fn cargo_dist_nested_downloads_are_not_shadowed_by_install_guards() {
1216 for condition in [
1217 "if OS.mac? && Hardware::CPU.arm?",
1218 "if OS.linux? && Hardware::CPU.arm?",
1219 "if OS.linux? && Hardware::CPU.intel?",
1220 ] {
1221 assert!(
1222 formula_has_platform_stanza(CARGO_DIST_0_28_2_FORMULA, condition),
1223 "cargo-dist 0.28.2 formula should contain {condition}"
1224 );
1225 }
1226 }
1227
1228 #[test]
1229 fn a_matching_install_guard_without_a_download_stanza_is_not_enough() {
1230 let formula = r#"class Tool < Formula
1231 version "1.0.0"
1232 def install
1233 if OS.linux? && Hardware::CPU.arm?
1234 bin.install "tool"
1235 end
1236 end
1237end
1238"#;
1239 assert!(!formula_has_platform_stanza(
1240 formula,
1241 "if OS.linux? && Hardware::CPU.arm?"
1242 ));
1243 }
1244}