Skip to content

Check for duplicate linux software installers - #44234

Merged
jkatz01 merged 6 commits into
mainfrom
43959-duplicate-installers
Apr 27, 2026
Merged

Check for duplicate linux software installers#44234
jkatz01 merged 6 commits into
mainfrom
43959-duplicate-installers

Conversation

@jkatz01

@jkatz01 jkatz01 commented Apr 27, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #43959 #44038
Refactored checkSoftwareConflictsByIdentifier to a switch statement with different logic per platform

Checklist for submitter

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.

  • Timeouts are implemented and retries are limited to avoid infinite loops

  • If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes

Testing

Summary by CodeRabbit

Bug Fixes

  • Prevented duplicate software installer entries on Linux.
  • Improved conflict detection for software installers across iOS, macOS, Windows, and Linux platforms to prevent incompatible uploads.

@codecov

codecov Bot commented Apr 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.79%. Comparing base (b471e24) to head (3b96a68).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
server/datastore/mysql/software_installers.go 65.38% 6 Missing and 3 partials ⚠️
server/datastore/mysql/in_house_apps.go 88.88% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #44234      +/-   ##
==========================================
+ Coverage   66.73%   66.79%   +0.05%     
==========================================
  Files        2627     2627              
  Lines      211192   211202      +10     
  Branches     9505     9422      -83     
==========================================
+ Hits       140938   141065     +127     
+ Misses      57466    57318     -148     
- Partials    12788    12819      +31     
Flag Coverage Δ
backend 68.56% <75.00%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jkatz01
jkatz01 marked this pull request as ready for review April 27, 2026 20:09
@jkatz01
jkatz01 requested a review from a team as a code owner April 27, 2026 20:09

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This pull request implements platform-specific conflict detection logic to prevent duplicate software installer entries. A new datastore method checkInstallerExistsByName queries whether an installer exists by name, source, and platform. The conflict checking logic is refactored to use platform-specific rules: iOS/iPadOS checks bundle identifiers and VPP apps on dual platforms, macOS validates VPP title identifiers and installer existence, and Windows/Linux perform name-based installer existence checks with optional upgrade code validation. Test coverage is added to validate conflict detection across all platforms, and integration test assertions are updated to reflect revised error messaging.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Check for duplicate linux software installers' is specific and relates to the main change in the PR, which adds a datastore helper to check for duplicate installers on Linux and refactors conflict checking logic.
Linked Issues check ✅ Passed The PR implements the core requirement from issue #43959 to prevent duplicate uploads of the same software package per title+team by adding conflict checks that now properly reject subsequent uploads with an 'already has an installer available' error rather than silently allowing duplicates.
Out of Scope Changes check ✅ Passed All changes are scoped to software installer conflict detection: adding a helper function, refactoring conflict logic by platform, updating tests, and adjusting error messages. The changes directly address the linked issues and do not introduce unrelated modifications.
Description check ✅ Passed The PR description includes a related issue reference, a clear refactoring summary, and a substantially completed checklist addressing code quality, testing, and manual QA.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 43959-duplicate-installers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/datastore/mysql/software_installers_test.go`:
- Around line 5053-5300: The test
testMatchOrCreateSoftwareInstallerDuplicateConflicts only covers team-scoped
uploads; add matching global-scope variants (TeamID nil) for each conflict
scenario to exercise the global path of MatchOrCreateSoftwareInstaller and
ensure VPP bundle ID, Title/version, and Windows UpgradeCode conflict checks are
exercised globally; for each existing team-scoped call that expects
require.ErrorContains(t, err, conflictMsg) or require.NoError, duplicate the
call but omit TeamID (leave it nil) and use distinct StorageID/Filename to avoid
hash duplicates so the test verifies conflict behavior for global
UploadSoftwareInstallerPayloads as well.

In `@server/datastore/mysql/software_installers.go`:
- Around line 3746-3753: The pre-insert read using checkInstallerExistsByName
with ds.reader(ctx) is racy and can still allow duplicates under concurrency;
fix by enforcing uniqueness at the write boundary: add a DB uniqueness
constraint/index on (team_id, title, source, platform) and modify the
create/insert path to either (A) perform the insert inside a writer transaction
and catch/translate the DB duplicate-key error into alreadyExists("installer",
payload.Title), or (B) wrap the read+write in the same writer transaction and
use a SELECT ... FOR UPDATE (or equivalent) before inserting; update the code
paths that call checkInstallerExistsByName to rely on the DB constraint or the
transactional lock and remove the standalone racy pre-check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3c161809-c42b-4bd0-8ba6-eb932440963d

📥 Commits

Reviewing files that changed from the base of the PR and between b471e24 and 3b96a68.

📒 Files selected for processing (5)
  • changes/43959-duplicate-installers
  • server/datastore/mysql/in_house_apps.go
  • server/datastore/mysql/software_installers.go
  • server/datastore/mysql/software_installers_test.go
  • server/service/integration_enterprise_test.go

Comment on lines +5053 to +5300
func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Datastore) {
ctx := context.Background()

user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()})
require.NoError(t, err)

const conflictMsg = "already has an installer available for"

// macOS installer conflicting with a VPP app on the same bundle id.
test.CreateInsertGlobalVPPToken(t, ds)
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_mac", Platform: fleet.MacOSPlatform}},
Name: "Mac VPP",
BundleIdentifier: "com.example.vpp",
}, &team.ID)
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "mac-vpp-clash-storage",
Filename: "vpp-clash.pkg",
Title: "Mac VPP Clash",
BundleIdentifier: "com.example.vpp",
Extension: "pkg",
Source: "apps",
Platform: "darwin",
Version: "1.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.ErrorContains(t, err, conflictMsg)

// macOS installer sharing a bundle id with an in-house app is allowed:
// in-house apps only target iOS/iPadOS, so they don't conflict with macOS.
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "iha-storage",
Filename: "iha.ipa",
Title: "iOS App",
BundleIdentifier: "com.example.iha",
Extension: "ipa",
Source: "ios_apps",
Platform: "ios",
Version: "1.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "mac-iha-coexist-storage",
Filename: "mac-iha-coexist.pkg",
Title: "Mac IHA Coexist",
BundleIdentifier: "com.example.iha",
Extension: "pkg",
Source: "apps",
Platform: "darwin",
Version: "1.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

// macOS installer conflicting with the same installer at a newer version.
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "mac-base-storage",
Filename: "mac-app.pkg",
Title: "Mac App",
BundleIdentifier: "com.example.mac",
Extension: "pkg",
Source: "apps",
Platform: "darwin",
Version: "1.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "mac-v2-storage",
Filename: "mac-app-v2.pkg",
Title: "Mac App",
BundleIdentifier: "com.example.mac",
Extension: "pkg",
Source: "apps",
Platform: "darwin",
Version: "2.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.ErrorContains(t, err, conflictMsg)

// Windows installer conflicting with the same Title at a newer version.
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-base-storage",
Filename: "win-app.msi",
Title: "Win App",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "1.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-v2-storage",
Filename: "win-app-v2.msi",
Title: "Win App",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "2.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.ErrorContains(t, err, conflictMsg)

// Windows installer conflicting on the upgrade code with a different Title.
const winUpgradeCode = "{ABCDEF12-3456-7890-ABCD-EF1234567890}"
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-uc-base-storage",
Filename: "win-uc.msi",
Title: "Win UC App",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "1.0",
UpgradeCode: winUpgradeCode,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-uc-v2-storage",
Filename: "win-uc-other.msi",
Title: "Win UC App Renamed",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "2.0",
UpgradeCode: winUpgradeCode,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.ErrorContains(t, err, conflictMsg)

// Windows: existing installer has an upgrade code, new upload has the same
// Title but no upgrade code.
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-uc-existing-storage",
Filename: "win-uc-existing.msi",
Title: "Win UC Same Name",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "1.0",
UpgradeCode: "{11111111-1111-1111-1111-111111111111}",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-uc-noupgrade-storage",
Filename: "win-uc-custom.msi",
Title: "Win UC Same Name",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "2.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.ErrorContains(t, err, conflictMsg)

// Reverse: existing installer has no upgrade code, new upload has the same
// Title with an upgrade code.
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-plain-base-storage",
Filename: "win-plain.msi",
Title: "Win Plain App",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "1.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "win-plain-uc-storage",
Filename: "win-plain-uc.msi",
Title: "Win Plain App",
Extension: "msi",
Source: "programs",
Platform: "windows",
Version: "2.0",
UpgradeCode: "{22222222-2222-2222-2222-222222222222}",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.ErrorContains(t, err, conflictMsg)

// Linux installer conflicting with the same Title at a newer version.
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "linux-base-storage",
Filename: "linux-app.deb",
Title: "Linux App",
Extension: "deb",
Source: "deb_packages",
Platform: "linux",
Version: "1.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.NoError(t, err)

_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: "linux-v2-storage",
Filename: "linux-app-v2.deb",
Title: "Linux App",
Extension: "deb",
Source: "deb_packages",
Platform: "linux",
Version: "2.0",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
})
require.ErrorContains(t, err, conflictMsg)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether duplicate-conflict coverage already exists for nil-TeamID / global-scope uploads.
rg -n -C 2 'MatchOrCreateSoftwareInstallerDuplicateConflicts|TeamID:\s*nil|already has an installer available for' \
  server/datastore/mysql/software_installers_test.go \
  server/service/integration_enterprise_test.go

Repository: fleetdm/fleet

Length of output: 14232


🏁 Script executed:

#!/bin/bash
# Look for other test functions that call MatchOrCreateSoftwareInstaller 
# and check if any use TeamID: nil
rg -A 50 'func test.*Duplicate|func test.*Conflict' server/datastore/mysql/software_installers_test.go | head -100

Repository: fleetdm/fleet

Length of output: 3764


🏁 Script executed:

#!/bin/bash
# Check for any tests combining nil-TeamID with MatchOrCreateSoftwareInstaller
# to see if the global path has existing coverage
grep -n 'TeamID.*nil' server/datastore/mysql/software_installers_test.go | \
  head -20

Repository: fleetdm/fleet

Length of output: 116


Add global-scope test cases to cover the conflict scenarios.

The new subtest only exercises team-scoped uploads (TeamID: &team.ID). While testMatchOrCreateSoftwareInstallerDuplicateHash does test global scope, it covers duplicate hash detection—not the conflict scenarios introduced here (VPP app bundle ID conflicts, title conflicts, upgrade code conflicts). If the conflict-detection logic diverges between team and global scopes, regressions in the global path will not be caught.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/datastore/mysql/software_installers_test.go` around lines 5053 - 5300,
The test testMatchOrCreateSoftwareInstallerDuplicateConflicts only covers
team-scoped uploads; add matching global-scope variants (TeamID nil) for each
conflict scenario to exercise the global path of MatchOrCreateSoftwareInstaller
and ensure VPP bundle ID, Title/version, and Windows UpgradeCode conflict checks
are exercised globally; for each existing team-scoped call that expects
require.ErrorContains(t, err, conflictMsg) or require.NoError, duplicate the
call but omit TeamID (leave it nil) and use distinct StorageID/Filename to avoid
hash duplicates so the test verifies conflict behavior for global
UploadSoftwareInstallerPayloads as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From claude:
Finding 1 (test coverage for global-scope) — Not a real gap.**

The conflict-check helpers (checkInstallerExistsByName, checkInstallerOrInHouseAppExists, checkVPPAppExistsForTitleIdentifier) all normalize *uintglobalOrTeamID via ptr.ValOrZero(teamID) (or the equivalent inline pattern) before the SQL runs. The query is identical for nil/0 vs &team.ID; only a scalar value differs. Duplicating every scenario for TeamID: nil would just exercise the same SQL with a different number, not a different code path.

The no-team path is already covered by the integration tests in server/service/integration_enterprise_test.go (lines 12636, 12762, 12954 — all TeamID: nil or TeamID: ptr.Uint(0)), which now pass after the message-string update earlier in this conversation. No change needed.

Comment on lines +3746 to 3753
// check by name before any software title renaming side effects can happen
exists, err := ds.checkInstallerExistsByName(ctx, ds.reader(ctx), payload.TeamID, payload.Title, payload.Source, payload.Platform)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if installer exists by name")
}
if exists {
return alreadyExists("installer", payload.Title)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Non-atomic duplicate check can still allow concurrent duplicates.

Line 3747 performs a pre-insert read check outside the insert transaction. Under concurrent requests, both calls can observe “not exists” and proceed to insert, so duplicates can still slip through.

Please enforce this at the write boundary (e.g., DB uniqueness constraint aligned with the rule, or lock/select-for-update in the same transaction that performs the insert).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/datastore/mysql/software_installers.go` around lines 3746 - 3753, The
pre-insert read using checkInstallerExistsByName with ds.reader(ctx) is racy and
can still allow duplicates under concurrency; fix by enforcing uniqueness at the
write boundary: add a DB uniqueness constraint/index on (team_id, title, source,
platform) and modify the create/insert path to either (A) perform the insert
inside a writer transaction and catch/translate the DB duplicate-key error into
alreadyExists("installer", payload.Title), or (B) wrap the read+write in the
same writer transaction and use a SELECT ... FOR UPDATE (or equivalent) before
inserting; update the code paths that call checkInstallerExistsByName to rely on
the DB constraint or the transactional lock and remove the standalone racy
pre-check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From claude:
Finding 2 (pre-insert read is racy) — Not a real issue, way out of scope.**

Already verified: software_installers has UNIQUE KEY idx_software_installers_team_id_title_id (global_or_team_id, title_id) from migration 20240515200020_AddSoftwareInstallerTables.go:77. A race-induced double insert is caught at the DB layer — the pre-check is just for fast-fail with a friendly error message.

The reviewer's suggested fix (new constraint on (team_id, title, source, platform), plus migration, plus moving everything into a writer transaction with SELECT ... FOR UPDATE or duplicate-key error translation) is a substantial architectural change that goes far beyond the scope of this ticket (#43959 — fixing detection of duplicate Linux installers). Software installer uploads aren't a high-concurrency surface (operator-initiated, behind auth), and the existing UNIQUE constraint provides correctness; the pre-check provides UX. No change needed.

@@ -3699,68 +3699,63 @@ LIMIT 1`
}

func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "by identifier" isn't really correct anymore (and wasn't for a while) but I didn't rename the function in this PR.

@cdcme cdcme left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great as-is 👍

@jkatz01
jkatz01 merged commit 899dc5a into main Apr 27, 2026
51 checks passed
@jkatz01
jkatz01 deleted the 43959-duplicate-installers branch April 27, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Don't let upload of already existing package

2 participants