fix: refuse a publish that would append to another project's history - #127
Open
TheAmericanMaker wants to merge 2 commits into
Open
fix: refuse a publish that would append to another project's history#127TheAmericanMaker wants to merge 2 commits into
TheAmericanMaker wants to merge 2 commits into
Conversation
Slugs derive from the trailing path segment of source_repo, so acme/whisper and openai/whisper both produce "whisper". Publishing the second wrote it as v2 of the first. The entry's version history then spanned two unrelated codebases, and since buildIndexEntry reads only the newest version's metadata, index.yaml attributed every prior version to whichever repo published last. Nothing surfaced the collision at publish time or afterwards. publishEntry now reads the source_repo recorded on the newest version and fails before writing when it denotes a different repository. Comparison is normalized so that re-publishing one repository spelled another way is unaffected: scheme or none, git@host:path SCP syntax, a www. prefix, a trailing .git, trailing slashes, backslash separators, and casing all collapse to the same form. Only spellings that are unambiguously the same target collapse, since the caller treats "different" as fatal. Three details worth noting for review: - The check sits ahead of the content-hash branch. Identical spec bytes take the metadata-only path, which overwrote the other project's source_repo and headline in place, so guarding only the new-version path would have left the quieter half of the bug. - forceNewVersion does not bypass it. That option means "another version of this entry", not "overwrite a different project". - Unreadable or malformed recorded metadata skips the check. There is nothing to compare, and refusing on unknown would turn a corrupt v1 into an unpublishable entry. A genuine repository move is the one legitimate reason to change the recorded value, so allowSourceRepoChange opts out, exposed on the MCP surface as allow_source_repo_change. docs/library-format.md promised auto-suffixing (-2, -3) for this case. That was never implemented, and silent suffixing is its own surprise for an agent-driven tool, so the doc now describes the refusal and the override. Eight tests. Three fail without the guard: the two-project collision, the metadata-only path, and forceNewVersion. The rest pin the normalization equivalences, keep genuinely distinct repos distinct, and hold the false-positive and malformed-metadata paths open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-review of #127 found the guard refusing a legitimate re-publish, which is the failure direction the guard was supposed to avoid. normalizeSourceRepo ran the SCP branch before stripping the scheme and never stripped a userinfo prefix, so four common spellings of one repository each reduced to something different: ssh://git@github.com/acme/tool.git -> git@github.com/acme/tool ssh://git@github.com:22/acme/tool.git -> git@github.com:22/acme/tool https://git@github.com/acme/tool -> git@github.com/acme/tool https://user:token@github.com/acme/tool -> user:token@github.com/acme/tool Publishing once from an HTTPS clone and later from an SSH clone of the same repo was therefore refused as a different project. Reordered: scheme off first, then userinfo, then the SCP branch, which now runs only when there was no scheme, since that is the only place a colon means host:path rather than a port. Default ports 22, 80 and 443 are dropped; any other port is kept, because two services on one host can differ by port alone. The SCP branch also now requires a dotted host, so a Windows drive letter is not read as host:path. The original tests missed all of this: every URL form they used had either no scheme or no credentials. Widened to nine more equivalent spellings, and the distinctness assertions now pin non-default ports, drive letters, and a same-host different-path pair. Two tests fail against the previous normalization. 351 tests, suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #123.
deriveSluguses only the trailing path segment ofsource_repo, soacme/whisperandopenai/whisperboth producewhisper, and publishing the second wrote it asv2of the first. The entry's version history then spanned two unrelated codebases, and sincebuildIndexEntryreads only the newest version's metadata,index.yamlattributed the whole entry to whichever repo published last. Nothing reported it, at publish time or after.publishEntrynow reads thesource_reporecorded on the newest version and fails before writing anything when it denotes a different repository.Comparison is normalized so re-publishing one repository spelled another way is unaffected. Scheme or none, embedded credentials (
git@,user:token@),git@host:pathSCP syntax, a default port, awww.prefix, a trailing.git, trailing slashes, backslash separators and casing all collapse to one form. A non-default port is kept, since two services on one host can differ by port alone, and only spellings that are unambiguously the same target collapse, because the caller treats "different" as fatal.Second commit: a false positive I introduced and then found
Worth reading on its own. The first pass ran the SCP branch before stripping the scheme and never stripped a userinfo prefix, so four ordinary spellings of one repository each reduced to something different:
Publishing once from an HTTPS clone and later from an SSH clone of the same repo was refused as a different project, which is the failure direction this guard exists to avoid. Reordered to strip scheme, then userinfo, then apply the SCP branch only when there was no scheme, since that is the only context where a colon means
host:pathrather than a port.The first round of tests missed it because every URL form they used had either no scheme or no credentials. That gap is closed: nine more equivalent spellings, plus distinctness assertions pinning non-default ports, Windows drive letters, and a same-host different-path pair. Two tests fail against the first commit's normalization.
A repository that genuinely moved is the one legitimate reason to change the recorded value, so
allowSourceRepoChangeopts out, exposed on MCP asallow_source_repo_change.Type of change
Checklist
npm testpasses locally).codecarto/files)feat:,fix:,docs:,refactor:,ci:,framework:)Eight new tests, 350 total, suite green from a 342 baseline. Three fail with the guard disabled: the two-project collision, the metadata-only path, and
forceNewVersion. The other five pin the normalization equivalences, keep genuinely distinct repos distinct, and hold the false-positive and malformed-metadata paths open. No.codecarto/files touched, so the invariant tests were unaffected either way, and they pass.Related issues
Fixes #123.
Found in the same audit, filed separately since each needs its own decision: #124 (the documented
confidentialityvsvisibilitycomparison does not exist), #125 (publish is documented as committing by default, butcommitPublishhas no callers), #126 (three more placeslibrary-format.mddescribes a shape the code does not produce).Additional notes
Three decisions worth your attention:
The check sits ahead of the content-hash branch. Identical spec bytes take the metadata-only path, which overwrote the other project's
source_repoandheadlinein place, so guarding only the new-version path would have left the quieter half of the bug intact.forceNewVersiondoes not bypass it. That option means "another version of this entry", not "overwrite a different project".Unreadable or malformed recorded metadata skips the check. There is nothing to compare against, and refusing on unknown would turn a corrupt
v1into an entry nobody can publish to.On the doc change:
library-format.mdpromised auto-suffixing (-2,-3) for this case. It was never implemented, and silent suffixing is its own kind of surprise for an agent-driven tool, so the doc now describes the refusal and the override rather than the behaviour we would have had to build to match it.Deliberately not in this PR
The escape hatch is MCP only, which is the opposite of the surface priority in CLAUDE.md. Closing it on Pi is UX work on the primary surface and wanted its own discussion rather than a ride-along in a bug fix. Two pre-existing Pi gaps, neither caused by this change:
/codecarto-publishtakes no arguments (handler: async (_args, ctx)), so a Pi user who trips the guard after genuinely moving a directory gets a clear error with no way to act on it. Pi already usesctx.ui.confirmfor the publish preview, which looks like the right place to surface the conflict and offer the override.Pi passes
ctx.cwdassource_repo, a local absolute path. That is also why the collision is easy to hit there:slugandsource_repoboth derive fromctx.cwd, so two same-named directories under different parents collide. The format doc calls local paths "permitted but discouraged for shared libraries", and using the git remote when there is one looks like the better default.