@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
304 lines (253 loc) • 8.88 kB
JavaScript
/**
* Upload Service
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const chalk = require('chalk');
class UploadService {
/**
* Upload report file to API endpoint using curl
*/
async uploadReportToApi(filePath, apiUrl, options = {}) {
try {
this.validateUploadParameters(filePath, apiUrl);
const oidcToken = this.getOidcToken();
console.log(chalk.blue(`📤 Uploading report to: ${apiUrl}`));
if (oidcToken) {
console.log(chalk.green(`🔐 Using OIDC authentication (CI detected)`));
}
const uploadResult = await this.executeUploadCommand(filePath, apiUrl, options);
console.log(chalk.green(`✅ Report uploaded successfully!`));
return {
success: true,
url: apiUrl,
filePath: filePath,
response: uploadResult.response,
statusCode: uploadResult.statusCode
};
} catch (error) {
const errorContext = {
filePath,
apiUrl,
error: error.message,
timestamp: new Date().toISOString()
};
console.error(chalk.red('❌ Upload failed:'), error.message);
if (options.verbose || options.debug) {
console.error('Upload error context:', errorContext);
}
return {
success: false,
url: apiUrl,
filePath: filePath,
error: error.message,
errorContext
};
}
}
/**
* Validate upload parameters
*/
validateUploadParameters(filePath, apiUrl) {
if (!filePath) {
throw new Error('File path is required for upload');
}
if (!apiUrl) {
throw new Error('API URL is required for upload');
}
if (!fs.existsSync(filePath)) {
throw new Error(`Upload file does not exist: ${filePath}`);
}
// Basic URL validation
try {
new URL(apiUrl);
} catch (error) {
throw new Error(`Invalid API URL format: ${apiUrl}`);
}
}
/**
* Execute curl command to upload file
*/
async executeUploadCommand(filePath, apiUrl, options = {}) {
const fileName = path.basename(filePath);
const timeout = options.timeout || 30; // 30 seconds default timeout
// Build curl command with status code output
const curlCommand = this.buildCurlCommand(filePath, apiUrl, fileName, timeout);
try {
// Execute curl command with write-out to get status code
const output = execSync(curlCommand, {
encoding: 'utf8',
maxBuffer: 1024 * 1024, // 1MB buffer
timeout: timeout * 1000 // Convert to milliseconds
});
// Parse response with headers included (-i flag)
// Output format: "HTTP/1.1 200 OK\nHeaders...\n\nResponse Body"
const lines = output.trim().split('\n');
// Find the status line (first line starting with HTTP)
let statusCode = null;
const statusLine = lines.find(line => line.startsWith('HTTP/'));
if (statusLine) {
const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d{3})/);
if (statusMatch) {
statusCode = parseInt(statusMatch[1]);
}
}
// Find response body (after empty line separating headers from body)
let response = '';
let foundEmptyLine = false;
for (const line of lines) {
if (foundEmptyLine) {
response += (response ? '\n' : '') + line;
} else if (line.trim() === '') {
foundEmptyLine = true;
}
}
// If no empty line found, assume entire output is response
if (!foundEmptyLine) {
response = output.trim();
}
return {
response: response,
statusCode: statusCode
};
} catch (error) {
throw new Error(`curl command failed: ${error.message}. Command: ${curlCommand}`);
}
}
/**
* Build curl command for file upload
*/
buildCurlCommand(filePath, apiUrl, fileName, timeout) {
const curlOptions = [
'curl',
'-X POST',
`--connect-timeout ${timeout}`,
`--max-time ${timeout}`,
'-H "Content-Type: application/json"',
'-H "User-Agent: SunLint-Report-Uploader/1.0"'
];
// Add Idempotency-Key for safe retries
const idempotencyKey = this.generateIdempotencyKey(filePath);
curlOptions.push(`-H "Idempotency-Key: ${idempotencyKey}"`);
// Add OIDC token if running in CI environment
const oidcToken = this.getOidcToken();
if (oidcToken) {
curlOptions.push(`-H "Authorization: Bearer ${oidcToken}"`);
}
curlOptions.push(
`--data-binary @"${filePath}"`,
'-i', // Include response headers in output
'-s', // Silent mode (no progress meter)
`"${apiUrl}"`
);
return curlOptions.join(' ');
}
/**
* Check if curl is available on system
*/
checkCurlAvailability() {
try {
execSync('curl --version', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
/**
* Get OIDC token from environment variables (for CI authentication)
*/
getOidcToken() {
// Try to get GitHub Actions OIDC token first (requires API call)
if (process.env.GITHUB_ACTIONS && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
try {
const githubToken = this.requestGitHubOidcToken();
if (githubToken) {
return githubToken;
}
} catch (error) {
console.log(chalk.yellow(`⚠️ Failed to get GitHub OIDC token: ${error.message}`));
}
}
// Check if running in GitHub Actions without OIDC token
if (process.env.GITHUB_ACTIONS) {
console.log(chalk.yellow('⚠️ Running in GitHub Actions but no OIDC token available. Upload may require authentication.'));
}
return null;
}
/**
* Request OIDC token from GitHub Actions
*/
requestGitHubOidcToken() {
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
if (!requestToken || !requestUrl) {
throw new Error('Missing GitHub OIDC request credentials');
}
try {
// Use curl to request OIDC token from GitHub with specific audience
const curlCommand = `curl -H "Authorization: bearer ${requestToken}" "${requestUrl}&audience=coding-standards-report-api"`;
const response = execSync(curlCommand, {
encoding: 'utf8',
timeout: 10000, // 10 second timeout
stdio: 'pipe'
});
const responseData = JSON.parse(response);
if (responseData.value) {
return responseData.value;
} else {
throw new Error('No token in response');
}
} catch (error) {
throw new Error(`GitHub OIDC request failed: ${error.message}`);
}
}
/**
* Generate idempotency key for safe retries
*/
generateIdempotencyKey(filePath) {
// Create deterministic key based on file content and timestamp
const fileContent = fs.readFileSync(filePath, 'utf8');
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
// Include CI context for uniqueness across environments and attempts
const ciContext = process.env.GITHUB_ACTIONS ?
`${process.env.GITHUB_REPOSITORY || 'unknown'}-${process.env.GITHUB_RUN_ID || 'local'}-${process.env.GITHUB_RUN_ATTEMPT || '1'}` :
'local';
// Create hash from content + date + CI context + run attempt
const hashInput = `${fileContent}-${timestamp}-${ciContext}`;
const hash = crypto.createHash('sha256').update(hashInput).digest('hex');
// Return first 32 characters for reasonable key length
return `sunlint-${hash.substring(0, 24)}`;
}
/**
* Validate API endpoint accessibility
*/
async validateApiEndpoint(apiUrl, options = {}) {
try {
const timeout = options.timeout || 10;
// Build command with auth header if available
let testCommand = `curl -X HEAD --connect-timeout ${timeout} --max-time ${timeout} -s -o /dev/null -w "%{http_code}"`;
const oidcToken = this.getOidcToken();
if (oidcToken) {
testCommand += ` -H "Authorization: Bearer ${oidcToken}"`;
}
testCommand += ` "${apiUrl}"`;
const statusCode = execSync(testCommand, { encoding: 'utf8' }).trim();
return {
accessible: true,
statusCode: parseInt(statusCode),
url: apiUrl,
authenticated: !!oidcToken
};
} catch (error) {
return {
accessible: false,
error: error.message,
url: apiUrl,
authenticated: !!this.getOidcToken()
};
}
}
}
module.exports = UploadService;