Skip to content

[PPSC-181] feat(api): implement streaming multipart uploads#91

Merged
yiftach-armis merged 8 commits intomainfrom
feat/PPSC-181-streaming-multipart-uploads
Mar 3, 2026
Merged

[PPSC-181] feat(api): implement streaming multipart uploads#91
yiftach-armis merged 8 commits intomainfrom
feat/PPSC-181-streaming-multipart-uploads

Conversation

@yiftach-armis
Copy link
Collaborator

@yiftach-armis yiftach-armis commented Mar 2, 2026

Related Issue

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Performance improvement

Problem

Large repository/image uploads could cause out-of-memory (OOM) issues because the entire file was buffered in memory before being sent to the API. Additionally, users saw spurious warnings on macOS: "failed to flush stdout before exit: sync /dev/stdout: inappropriate ioctl for device".

Solution

  1. Streaming multipart uploads: Replaced in-memory buffering with io.Pipe streaming for uploads. The API client now streams data directly to the HTTP request body, preventing OOM on large files.

  2. Separate upload HTTP client: Added a dedicated upload client without retry logic (uploads are not idempotent) and without timeout (large uploads can take a long time).

  3. Suppress stdout sync warnings: Silently ignore ENOTTY, EINVAL, and ENOTSUP errors from os.Stdout.Sync() which occur when stdout is a pipe or /dev/stdout that doesn't support fsync.

Testing

Automated Tests

  • Unit tests added/updated
  • All tests passing locally

Manual Testing

  • Build: make build
  • Lint: make lint (0 issues)
  • Tests: make test (1233 tests pass, 77.8% coverage)

Checklist

  • Code follows project style guidelines
  • Pre-commit hooks pass
  • Self-review performed
  • No new warnings generated

Refactor StartIngest() to use io.Pipe() for streaming multipart uploads
instead of buffering entire tarballs in memory. This prevents OOM crashes
when scanning large repositories (2GB+) or container images (5GB+).

Key changes:
- Add copyWithContext() for cancellation-responsive large uploads
- Add DisableRetry option to httpclient for non-rewindable streams
- Split WithHTTPClient/WithUploadHTTPClient to preserve no-retry config
- Fix slowReader test helper to comply with io.Reader contract
- Add integration test for context cancellation during uploads

The streaming approach keeps memory usage constant regardless of file size,
achieving the ticket's goal of <500MB memory for 5GB uploads.
Addressed 4 findings from deep-review:
- Add WithUploadHTTPClient to StartIngest tests for explicit upload client config
- Update CLAUDE.md to document WithUploadHTTPClient and streaming architecture
- Include HTTP status code in write error messages for better debugging
- Increase copyChunkSize from 32KB to 256KB to reduce syscall overhead

Ticket: PPSC-181
Silently ignore ENOTTY, EINVAL, and ENOTSUP errors from os.Stdout.Sync()
which occur when stdout is a pipe, socket, or /dev/stdout that doesn't
support fsync. The output is still delivered correctly.
Copilot AI review requested due to automatic review settings March 2, 2026 11:06
@github-actions
Copy link

github-actions bot commented Mar 2, 2026

Armis AppSecArmis AppSec Security Scan Results

✅ No issues

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

Test Coverage Report

total: (statements) 80.1%

Coverage by function
github.com/ArmisSecurity/armis-cli/cmd/armis-cli/main.go:18:			main					0.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:61:			copyWithContext				70.4%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:134:			WithHTTPClient				100.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:143:			WithUploadHTTPClient			100.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:151:			WithAllowLocalURLs			100.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:163:			NewClient				100.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:211:			IsDebug					100.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:228:			setAuthHeader				77.8%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:262:			StartIngest				72.3%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:421:			GetIngestStatus				82.6%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:462:			WaitForIngest				84.6%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:513:			FetchNormalizedResults			74.2%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:568:			FetchAllNormalizedResults		91.7%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:593:			GetScanResult				68.4%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:628:			WaitForScan				90.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:649:			formatBytes				100.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:671:			FetchArtifactScanResults		75.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:726:			ValidatePresignedURL			100.0%
github.com/ArmisSecurity/armis-cli/internal/api/client.go:762:			DownloadFromPresignedURL		84.2%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:52:			NewAuthProvider				95.2%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:98:			GetAuthorizationHeader			100.0%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:118:			GetTenantID				85.7%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:133:			IsLegacy				100.0%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:146:			GetRawToken				85.7%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:163:			exchangeCredentials			83.3%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:193:			refreshIfNeeded				100.0%
github.com/ArmisSecurity/armis-cli/internal/auth/auth.go:222:			parseJWTClaims				93.3%
github.com/ArmisSecurity/armis-cli/internal/auth/client.go:31:			NewAuthClient				100.0%
github.com/ArmisSecurity/armis-cli/internal/auth/client.go:72:			Authenticate				71.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:54:			InitColors				73.3%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:82:			ColorsEnabled				100.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:88:			ColorsForced				100.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:92:			enableColors				100.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:99:			disableColors				100.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:114:			parseErrorMessage			92.9%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:145:			PrintError				100.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:158:			PrintErrorf				0.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:164:			PrintWarning				100.0%
github.com/ArmisSecurity/armis-cli/internal/cli/color.go:169:			PrintWarningf				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/auth.go:35:			init					100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/auth.go:41:			runAuth					93.8%
github.com/ArmisSecurity/armis-cli/internal/cmd/context.go:24:			NewSignalContext			100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/context.go:33:			handleScanError				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/help.go:30:			SetupHelp				91.7%
github.com/ArmisSecurity/armis-cli/internal/cmd/help.go:58:			styledUsageTemplate			100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/help.go:101:			defaultUsageTemplate			100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/help.go:108:			initColorsForHelp			35.3%
github.com/ArmisSecurity/armis-cli/internal/cmd/help.go:149:			styleHelpOutput				83.3%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:124:			SetVersion				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:132:			Execute					100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:136:			init					76.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:179:			PrintUpdateNotification			50.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:198:			getEnvOrDefault				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:205:			getEnvOrDefaultInt			100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:215:			getAPIBaseURL				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:228:			getAuthProvider				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:239:			getPageLimit				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:246:			validatePageLimit			100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:256:			validateFailOn				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/root.go:274:			getFailOn				100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/scan.go:83:			init					100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/scan_image.go:146:		init					100.0%
github.com/ArmisSecurity/armis-cli/internal/cmd/scan_repo.go:134:		init					100.0%
github.com/ArmisSecurity/armis-cli/internal/httpclient/client.go:31:		NewClient				100.0%
github.com/ArmisSecurity/armis-cli/internal/httpclient/client.go:57:		Do					86.1%
github.com/ArmisSecurity/armis-cli/internal/output/errno_unix.go:12:		isSyncNotSupported			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:54:			wrapText				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:77:			wrapLine				91.7%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:115:		formatRecommendations			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:185:		wrapTextWithFirstLinePrefix		90.9%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:224:		write					66.7%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:255:		Write					89.5%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:285:		Format					100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:290:		FormatWithOptions			84.4%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:360:		SyncColors				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:364:		sortFindingsBySeverity			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:375:		loadSnippetFromFile			69.4%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:487:		formatCodeSnippetWithFrame		91.1%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:580:		truncatePlainLine			0.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:592:		highlightColumns			93.5%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:637:		scanDuration				89.5%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:670:		pluralize				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:679:		renderBriefStatus			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:719:		renderSummaryDashboard			56.4%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:800:		renderFindings				88.9%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:829:		renderFinding				69.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:919:		renderGroupedFindings			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:943:		groupFindings				96.8%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1000:		severityRank				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1007:		isGitRepo				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1014:		getGitBlame				38.1%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1051:		parseGitBlame				95.2%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1087:		maskEmail				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1110:		getTopLevelDomain			75.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1122:		getHumanDisplayTitle			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1136:		wrapTitle				93.9%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1195:		maskFixForDisplay			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1230:		formatFixSection			0.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1295:		formatProposedSnippet			0.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1378:		limitHunkContext			64.7%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1454:		parseDiffHunk				91.7%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1476:		parseDiffLines				94.6%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1567:		findInlineChanges			73.5%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1638:		computeLCS				92.3%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1690:		buildTokenPositions			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1706:		tokenizeLine				92.9%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1734:		isWordChar				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1741:		formatDiffWithColorsStyled		77.1%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1815:		extractDiffFilename			80.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1837:		formatDiffHunkLine			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1857:		formatDiffContextLine			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1868:		formatDiffRemoveLine			86.4%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1909:		formatDiffAddLine			86.4%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1951:		applyInlineHighlights			81.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:1993:		truncateDiffLine			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2000:		truncateDiffLineWithFlag		66.7%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2014:		adjustHighlightSpans			83.3%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2036:		groupDiffHunks				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2067:		collectRenderOps			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2110:		renderChangeBlock			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2169:		formatDiffHunkSeparator			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2184:		formatValidationSection			0.0%
github.com/ArmisSecurity/armis-cli/internal/output/human.go:2241:		getExposureDescription			0.0%
github.com/ArmisSecurity/armis-cli/internal/output/icons.go:24:			GetConfidenceIcon			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/json.go:15:			Format					100.0%
github.com/ArmisSecurity/armis-cli/internal/output/json.go:24:			FormatWithOptions			66.7%
github.com/ArmisSecurity/armis-cli/internal/output/json.go:32:			formatWithDebug				0.0%
github.com/ArmisSecurity/armis-cli/internal/output/json.go:58:			maskScanResultForOutput			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/json.go:78:			maskFindingSecrets			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/junit.go:48:			Format					100.0%
github.com/ArmisSecurity/armis-cli/internal/output/junit.go:55:			FormatWithOptions			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/junit.go:63:			formatWithSeverities			83.3%
github.com/ArmisSecurity/armis-cli/internal/output/junit.go:88:			isFailureSeverity			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/junit.go:98:			convertToJUnitCasesWithSeverities	91.7%
github.com/ArmisSecurity/armis-cli/internal/output/junit.go:130:		countFailuresWithSeverities		100.0%
github.com/ArmisSecurity/armis-cli/internal/output/output.go:34:		GetFormatter				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/output.go:50:		ShouldFail				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/output.go:66:		ExitIfNeeded				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:159:		stripMarkdown				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:170:		Format					100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:197:		buildRules				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:261:		convertToSarifResults			88.5%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:351:		buildMessageText			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:358:		severityToSarifLevel			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:377:		severityToSecurityScore			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:395:		generateHelpURI				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:422:		convertFixToSarif			90.5%
github.com/ArmisSecurity/armis-cli/internal/output/sarif.go:539:		FormatWithOptions			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/styles.go:138:		DefaultStyles				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/styles.go:276:		NoColorStyles				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/styles.go:353:		GetStyles				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/styles.go:361:		SyncStylesWithColorMode			100.0%
github.com/ArmisSecurity/armis-cli/internal/output/styles.go:386:		GetSeverityText				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/styles.go:414:		TerminalWidth				33.3%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:21:		GetLexer				100.0%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:32:		GetChromaStyle				80.0%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:45:		HighlightCode				81.2%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:79:		HighlightLine				75.0%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:88:		getTerminalFormatter			60.0%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:103:		HighlightLineWithBackground		87.5%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:126:		getBackgroundANSI			58.3%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:158:		rgbToANSI256				0.0%
github.com/ArmisSecurity/armis-cli/internal/output/syntax.go:171:		parseHexColor				76.9%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:33:		IsCI					100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:61:		isTerminalWriter			100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:69:		NewReader				100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:84:		NewWriter				50.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:118:		NewSpinner				100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:126:		NewSpinnerWithTimeout			100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:142:		NewSpinnerWithContext			100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:150:		SetWriter				100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:159:		Start					89.8%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:268:		Stop					100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:303:		Update					100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:310:		GetElapsed				100.0%
github.com/ArmisSecurity/armis-cli/internal/progress/progress.go:317:		formatDuration				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/finding_type.go:9:		DeriveFindingType			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:46:		NewScanner				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:60:		WithPollInterval			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:66:		WithSBOMVEXOptions			0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:73:		WithPullPolicy				0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:79:		ScanImage				0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:110:		ScanTarball				77.1%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:201:		exportImage				0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:251:		isDockerAvailable			42.9%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:265:		getDockerCommand			75.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:274:		validateDockerCommand			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:282:		imageExistsLocally			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:290:		determinePullBehavior			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:306:		buildScanResult				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:333:		convertNormalizedFindings		85.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:456:		shouldFilterByExploitability		100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:475:		cleanDescription			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:494:		isEmptyFinding				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/image.go:509:		generateFindingTitle			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/image/validate.go:11:		validateImageName			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/mask.go:21:			MaskFixSecrets				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/files.go:26:		ParseFileList				87.5%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/files.go:41:		addFile					87.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/files.go:93:		Files					100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/files.go:98:		RepoRoot				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/files.go:103:		ValidateExistence			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/ignore.go:18:		LoadIgnorePatterns			75.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/ignore.go:52:		loadIgnoreFile				89.5%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/ignore.go:86:		Match					100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/ignore.go:98:		shouldSkipDir				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:43:		NewScanner				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:57:		WithPollInterval			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:63:		WithIncludeFiles			0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:69:		WithSBOMVEXOptions			0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:75:		Scan					70.9%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:240:		tarGzDirectory				71.8%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:323:		isPathContained				75.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:332:		tarGzFiles				78.6%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:419:		calculateFilesSize			0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:440:		calculateDirSize			81.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:480:		shouldSkip				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:511:		isTestFile				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:555:		buildScanResult				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:582:		convertNormalizedFindings		73.3%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:705:		shouldFilterByExploitability		100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:724:		cleanDescription			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:745:		generateFindingTitle			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/repo/repo.go:749:		isEmptyFinding				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/sbom_vex.go:38:		NewSBOMVEXDownloader			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/sbom_vex.go:50:		Download				85.2%
github.com/ArmisSecurity/armis-cli/internal/scan/sbom_vex.go:102:		downloadAndSave				77.8%
github.com/ArmisSecurity/armis-cli/internal/scan/status.go:16:			FormatScanStatus			100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/status.go:35:			FormatElapsed				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/status.go:48:			MapSeverity				100.0%
github.com/ArmisSecurity/armis-cli/internal/scan/testhelpers/findings.go:9:	CreateNormalizedFinding			0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/testhelpers/findings.go:14:	CreateNormalizedFindingWithLabels	0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/testhelpers/findings.go:19:	CreateNormalizedFindingFull		0.0%
github.com/ArmisSecurity/armis-cli/internal/scan/title.go:14:			GenerateFindingTitle			0.0%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:66:		NewChecker				100.0%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:81:		CheckInBackground			100.0%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:101:		check					85.7%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:144:		fetchLatestVersion			89.5%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:177:		getCacheFilePath			44.4%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:195:		readCache				84.6%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:218:		writeCache				76.9%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:241:		IsNewer					100.0%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:264:		parseVersion				100.0%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:287:		FormatNotification			100.0%
github.com/ArmisSecurity/armis-cli/internal/update/update.go:305:		getUpdateCommand			40.0%
github.com/ArmisSecurity/armis-cli/internal/util/format.go:7:			FormatCategory				100.0%
github.com/ArmisSecurity/armis-cli/internal/util/mask.go:109:			MaskSecretInLine			86.4%
github.com/ArmisSecurity/armis-cli/internal/util/mask.go:163:			maskValue				83.3%
github.com/ArmisSecurity/armis-cli/internal/util/mask.go:189:			MaskSecretInLines			100.0%
github.com/ArmisSecurity/armis-cli/internal/util/mask.go:203:			MaskSecretInMultiLineString		100.0%
github.com/ArmisSecurity/armis-cli/internal/util/mask.go:217:			MaskSecretsInStringMap			100.0%
github.com/ArmisSecurity/armis-cli/internal/util/path.go:13:			SanitizePath				90.9%
github.com/ArmisSecurity/armis-cli/internal/util/path.go:51:			SafeJoinPath				87.5%
github.com/ArmisSecurity/armis-cli/test/sample-repo/src/main.go:6:		main					0.0%
total:										(statements)				80.1%

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements streaming multipart uploads in the API client to avoid buffering large artifacts in memory, adds upload-specific HTTP client behavior for non-idempotent streaming requests, and suppresses known benign stdout sync warnings.

Changes:

  • Stream StartIngest multipart uploads via io.Pipe with context-aware copying for cancellation responsiveness.
  • Add DisableRetry support to the internal HTTP client and configure a dedicated no-retry upload client.
  • Suppress stdout Sync() warnings for specific “not supported” errno cases and update documentation/tests accordingly.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
internal/api/client.go Switch upload to streaming multipart via io.Pipe; add copyWithContext; add upload-specific HTTP client option/config.
internal/api/client_test.go Add tests for upload streaming error handling/cancellation and copyWithContext; update client construction to include upload client option.
internal/httpclient/client.go Add DisableRetry flag and ensure retry logic can be fully disabled.
internal/httpclient/client_test.go Add coverage to verify retry disabling behavior.
internal/output/output.go Ignore specific stdout sync errno failures to suppress spurious warnings.
CLAUDE.md Update package documentation to reflect streaming uploads and upload client behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +76 to +81
// Silently ignore "sync not supported" errors - these occur when stdout
// is a pipe, socket, or /dev/stdout which don't support fsync.
// The output is still delivered correctly.
if !errors.Is(err, syscall.ENOTTY) && // "inappropriate ioctl for device"
!errors.Is(err, syscall.EINVAL) && // "invalid argument"
!errors.Is(err, syscall.ENOTSUP) { // "operation not supported"
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The use of syscall.ENOTTY/EINVAL/ENOTSUP here will not compile on Windows (these errno constants are not defined in the windows syscall package). Since this repo builds releases for windows, please gate the errno checks behind OS-specific build tags (e.g., a helper implemented in *_unix.go vs *_windows.go) or otherwise avoid referencing unix-only constants from cross-platform code.

Copilot uses AI. Check for mistakes.
}
// Return some data before failing
toRead := min(len(p), f.failAfter-f.bytesRead)
for i := range toRead {
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

This loop won’t compile: range cannot iterate over an int (toRead). Use an index-based loop (e.g., for i := 0; i < toRead; i++) or copy from a prefilled buffer.

Suggested change
for i := range toRead {
for i := 0; i < toRead; i++ {

Copilot uses AI. Check for mistakes.
Comment on lines 175 to 178
httpClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second})
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 1*time.Minute, WithHTTPClient(httpClient))
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 1*time.Minute,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
if err != nil {
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

These tests pass the general-purpose HTTP client as the upload client. That client has retries enabled by default, which is incompatible with the streaming request body used by StartIngest (retries can’t safely resend a non-rewindable body and may cause flaky behavior on transient errors). Consider constructing a dedicated upload client for tests with DisableRetry: true (and the desired timeout) and pass that via WithUploadHTTPClient.

Copilot uses AI. Check for mistakes.
Comment on lines 206 to 209
httpClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second})
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 1*time.Minute, WithHTTPClient(httpClient))
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 1*time.Minute,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
if err != nil {
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Same concern as above: reusing a retry-enabled httpclient.Client for uploads can trigger retries with a non-rewindable streaming body. Prefer a dedicated upload client in tests with DisableRetry: true (and an appropriate timeout) when passing WithUploadHTTPClient.

Copilot uses AI. Check for mistakes.
Comment on lines 233 to 237

httpClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second})
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 50*time.Millisecond, WithHTTPClient(httpClient))
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 50*time.Millisecond,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
if err != nil {
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Same concern as above: WithUploadHTTPClient is being set to a client that retries by default. Since StartIngest streams via io.Pipe, retries can’t safely resend the body. Use an upload client configured with DisableRetry: true for these tests.

Copilot uses AI. Check for mistakes.
Comment on lines +269 to +270
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 5*time.Second,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Same concern as above: the upload client passed here has retries enabled by default, which is incompatible with StartIngest’s streaming request body. Prefer a dedicated upload client with DisableRetry: true for tests.

Suggested change
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 5*time.Second,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
uploadClient := httpclient.NewClient(httpclient.Config{Timeout: 2 * time.Second, DisableRetry: true})
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 5*time.Second,
WithHTTPClient(httpClient), WithUploadHTTPClient(uploadClient))

Copilot uses AI. Check for mistakes.
Comment on lines +324 to +325
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 1*time.Minute,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Same concern as above: passing the general HTTP client as uploadHTTPClient enables retries, which is unsafe for io.Pipe streaming bodies. Create a separate upload client for tests with DisableRetry: true (plus the desired timeout) and pass that to WithUploadHTTPClient.

Suggested change
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 1*time.Minute,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
uploadHTTPClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second, DisableRetry: true})
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 1*time.Minute,
WithHTTPClient(httpClient), WithUploadHTTPClient(uploadHTTPClient))

Copilot uses AI. Check for mistakes.
Comment on lines +368 to +369
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 5*time.Second,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Same concern as above: this sets uploadHTTPClient to a client that retries by default. Retries are not safe with StartIngest’s streaming body; use a dedicated upload client for tests with DisableRetry: true to match production behavior and avoid flakiness on transient errors.

Suggested change
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 5*time.Second,
WithHTTPClient(httpClient), WithUploadHTTPClient(httpClient))
uploadClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second, DisableRetry: true})
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 5*time.Second,
WithHTTPClient(httpClient), WithUploadHTTPClient(uploadClient))

Copilot uses AI. Check for mistakes.
- Fix Windows compilation by extracting errno checks into platform-specific
  files (errno_unix.go, errno_windows.go) with build tags. ENOTSUP is not
  defined on Windows.
- Change range-over-int syntax to traditional for loop for broader
  compatibility with older linters.
- Use dedicated upload clients with DisableRetry: true in tests to match
  production behavior. Streaming bodies (io.Pipe) cannot be rewound for
  retries.
…ds [PPSC-181]

When server returns early error (e.g., 401), it closes connection before
reading full body. This causes io.ErrClosedPipe in writer goroutine.
Previously, this pipe error surfaced instead of the clear HTTP error.

Now check HTTP status before write errors, since server rejection is
the root cause and pipe closure is just a symptom.
Copilot AI review requested due to automatic review settings March 3, 2026 08:58
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +353 to +357
resp, err := c.uploadHTTPClient.Do(req)

// Wait for the writer goroutine to finish and collect any error
writeErr := <-errChan

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

In StartIngest(), the code waits on writeErr := <-errChan immediately after uploadHTTPClient.Do(req). This can deadlock if the server/transport returns a non-2xx response early (common with HTTP/2 or proxies) and the transport stops reading from pr while the writer goroutine is still blocked writing to the pipe, so it never reaches the deferred send on errChan. Consider ensuring the pipe is closed on all non-success paths (e.g., close the pipe reader with an error / defer-close the reader), and avoid blocking on errChan until after you’ve handled the HTTP response/status (or wait with a context-aware select).

Copilot uses AI. Check for mistakes.
Comment on lines +253 to +265
// Use io.Pipe to stream multipart data directly to the HTTP request body.
// This avoids buffering the entire file in memory, preventing OOM on large uploads.
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
contentType := writer.FormDataContentType() // Capture before goroutine starts

// Channel to receive errors from the writer goroutine
errChan := make(chan error, 1)

// Goroutine writes multipart data to the pipe; HTTP request reads from it
go func() {
var writeErr error
defer func() {
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The multipart writer goroutine is started before the request is created and before setAuthHeader succeeds. If request construction/auth fails, the goroutine may already be blocked in opts.Data.Read() and StartIngest then waits on <-errChan, delaying/possibly hanging the error return (since context cancellation won’t necessarily interrupt an arbitrary Reader). Consider only starting the writer goroutine after the request is successfully created/authenticated, or avoid blocking on errChan in these early-failure paths (close the pipe and return).

Copilot uses AI. Check for mistakes.
…ontext [PPSC-181]

Address GitHub code scanning alert by:
- Using `nw > nr` instead of `nr < nw` to match Go stdlib style
- Adding clear comments distinguishing invalid writes (nw < 0 or nw > nr)
  from short writes (nw < nr)
- Using distinct error types: errInvalidWrite vs io.ErrShortWrite

The original logic was correct but the reversed comparison order confused
static analysis tools. This change improves clarity without changing behavior.
Address two potential deadlock scenarios identified in PR review:

1. Defer goroutine start until after request/auth succeed, preventing
   hangs when early failures occur while goroutine is blocked reading

2. Always close pipe reader after Do(req) returns to unblock the writer
   goroutine if server returns early (non-2xx, HTTP/2, proxies)

Co-authored-by: copilot-pull-request-reviewer
Copilot AI review requested due to automatic review settings March 3, 2026 09:13
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +361 to +370
resp, err := c.uploadHTTPClient.Do(req)

// Close the pipe reader to unblock the writer goroutine.
// This is critical: if the server returns early (non-2xx) or the transport
// stops reading (HTTP/2, proxies), the writer may be blocked on pw.Write().
// CloseWithError ensures the writer sees an error instead of blocking forever.
_ = pr.CloseWithError(io.ErrUnexpectedEOF)

// Wait for the writer goroutine to finish and collect any error
writeErr := <-errChan
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

pr.CloseWithError(io.ErrUnexpectedEOF) is executed unconditionally right after uploadHTTPClient.Do(req) returns. With HTTP/2 (or any server that responds before fully reading the request body), Do can return while the request body is still being streamed; closing the pipe reader here can prematurely abort the upload and then the resulting io.ErrUnexpectedEOF is later ignored. Consider only closing the pipe reader to abort the writer in error/non-2xx paths, and in the 2xx path wait for the writer goroutine to finish (and then close pr normally) so a successful response can’t be returned while the upload stream was truncated.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +51
// errInvalidWrite indicates a Write returned an impossible byte count.
// This matches Go's internal io package error for invalid writes.
var errInvalidWrite = fmt.Errorf("invalid write result")
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

errInvalidWrite is a static sentinel error but is created with fmt.Errorf. Prefer errors.New for non-formatted constant errors to avoid formatting overhead and to better communicate intent (this also matches how the stdlib defines sentinel errors).

Copilot uses AI. Check for mistakes.
- Use errors.New for sentinel errInvalidWrite (style/perf)
- Add zero-byte read protection to prevent infinite loop DoS
- Fix premature pipe closure: only close pipe in error paths,
  let writer complete naturally on success (prevents HTTP/2 truncation)
@yiftach-armis yiftach-armis merged commit 189ce43 into main Mar 3, 2026
9 checks passed
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.

2 participants