Skip to main content

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(&current) {
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}