takos-runtime-service 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +29 -0
  2. package/src/__tests__/middleware/rate-limit.test.ts +33 -0
  3. package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
  4. package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
  5. package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
  6. package/src/__tests__/routes/cli-proxy.test.ts +72 -0
  7. package/src/__tests__/routes/git-http.test.ts +218 -0
  8. package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
  9. package/src/__tests__/routes/sessions/store.test.ts +72 -0
  10. package/src/__tests__/routes/workspace-scope.test.ts +45 -0
  11. package/src/__tests__/runtime/action-registry.test.ts +208 -0
  12. package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
  13. package/src/__tests__/runtime/actions/executor.test.ts +131 -0
  14. package/src/__tests__/runtime/composite-expression.test.ts +294 -0
  15. package/src/__tests__/runtime/file-parsers.test.ts +129 -0
  16. package/src/__tests__/runtime/logging.test.ts +65 -0
  17. package/src/__tests__/runtime/paths.test.ts +236 -0
  18. package/src/__tests__/runtime/secrets.test.ts +247 -0
  19. package/src/__tests__/runtime/validation.test.ts +516 -0
  20. package/src/__tests__/setup.ts +126 -0
  21. package/src/__tests__/shared/errors.test.ts +117 -0
  22. package/src/__tests__/storage/r2.test.ts +106 -0
  23. package/src/__tests__/utils/audit-log.test.ts +163 -0
  24. package/src/__tests__/utils/error-message.test.ts +38 -0
  25. package/src/__tests__/utils/sandbox-env.test.ts +74 -0
  26. package/src/app.ts +245 -0
  27. package/src/index.ts +1 -0
  28. package/src/middleware/rate-limit.ts +91 -0
  29. package/src/middleware/space-scope.ts +95 -0
  30. package/src/routes/actions/action-types.ts +20 -0
  31. package/src/routes/actions/execution.ts +229 -0
  32. package/src/routes/actions/index.ts +17 -0
  33. package/src/routes/actions/job-lifecycle.ts +242 -0
  34. package/src/routes/actions/job-queries.ts +52 -0
  35. package/src/routes/cli/proxy.ts +105 -0
  36. package/src/routes/git/http.ts +565 -0
  37. package/src/routes/git/init.ts +88 -0
  38. package/src/routes/repos/branches.ts +160 -0
  39. package/src/routes/repos/content.ts +209 -0
  40. package/src/routes/repos/read.ts +130 -0
  41. package/src/routes/repos/repo-validation.ts +136 -0
  42. package/src/routes/repos/write.ts +274 -0
  43. package/src/routes/runtime/exec.ts +147 -0
  44. package/src/routes/runtime/tools.ts +113 -0
  45. package/src/routes/sessions/execution.ts +263 -0
  46. package/src/routes/sessions/files.ts +326 -0
  47. package/src/routes/sessions/session-routes.ts +241 -0
  48. package/src/routes/sessions/session-utils.ts +88 -0
  49. package/src/routes/sessions/snapshot.ts +208 -0
  50. package/src/routes/sessions/storage.ts +329 -0
  51. package/src/runtime/actions/action-registry.ts +450 -0
  52. package/src/runtime/actions/action-result-converter.ts +31 -0
  53. package/src/runtime/actions/builtin/artifacts.ts +292 -0
  54. package/src/runtime/actions/builtin/cache-operations.ts +358 -0
  55. package/src/runtime/actions/builtin/checkout.ts +58 -0
  56. package/src/runtime/actions/builtin/index.ts +5 -0
  57. package/src/runtime/actions/builtin/setup-node.ts +86 -0
  58. package/src/runtime/actions/builtin/tar-parser.ts +175 -0
  59. package/src/runtime/actions/composite-executor.ts +192 -0
  60. package/src/runtime/actions/composite-expression.ts +190 -0
  61. package/src/runtime/actions/executor.ts +578 -0
  62. package/src/runtime/actions/file-parsers.ts +51 -0
  63. package/src/runtime/actions/job-manager.ts +213 -0
  64. package/src/runtime/actions/process-spawner.ts +275 -0
  65. package/src/runtime/actions/secrets.ts +162 -0
  66. package/src/runtime/command.ts +120 -0
  67. package/src/runtime/exec-runner.ts +309 -0
  68. package/src/runtime/git-http-backend.ts +145 -0
  69. package/src/runtime/git.ts +98 -0
  70. package/src/runtime/heartbeat.ts +57 -0
  71. package/src/runtime/logging.ts +26 -0
  72. package/src/runtime/paths.ts +264 -0
  73. package/src/runtime/secure-fs.ts +82 -0
  74. package/src/runtime/tools/network.ts +161 -0
  75. package/src/runtime/tools/worker.ts +335 -0
  76. package/src/runtime/validation.ts +292 -0
  77. package/src/shared/config.ts +149 -0
  78. package/src/shared/errors.ts +65 -0
  79. package/src/shared/temp-id.ts +10 -0
  80. package/src/storage/r2.ts +287 -0
  81. package/src/types/hono.d.ts +23 -0
  82. package/src/utils/audit-log.ts +92 -0
  83. package/src/utils/process-kill.ts +18 -0
  84. package/src/utils/sandbox-env.ts +136 -0
  85. package/src/utils/temp-dir.ts +74 -0
@@ -0,0 +1,292 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { GetObjectCommand, PutObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
4
+ import { type ActionContext } from '../executor.js';
5
+ import { pushLog } from '../../logging.js';
6
+ import { resolvePathWithin, isPathWithinBase } from '../../paths.js';
7
+ import { s3Client, isR2Configured } from '../../../storage/r2.js';
8
+ import { R2_BUCKET } from '../../../shared/config.js';
9
+ import { getErrorMessage } from 'takos-common/errors';
10
+ import { LINE_UNSAFE_CHARS_PATTERN } from './cache-operations.js';
11
+
12
+ function toStringArray(value: string | string[]): string[] {
13
+ return Array.isArray(value) ? value : [value];
14
+ }
15
+
16
+ function requireR2(operation: string): void {
17
+ if (!isR2Configured()) {
18
+ throw new Error(`R2 storage not configured, cannot ${operation}`);
19
+ }
20
+ }
21
+
22
+ function formatBytes(bytes: number): string {
23
+ if (bytes === 0) return '0 B';
24
+ const k = 1024;
25
+ const sizes = ['B', 'KB', 'MB', 'GB'];
26
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
27
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
28
+ }
29
+
30
+ function logTransferSummary(
31
+ context: ActionContext,
32
+ verb: string,
33
+ count: number,
34
+ totalBytes: number
35
+ ): void {
36
+ pushLog(context.logs, `${verb} ${count} file(s), ${formatBytes(totalBytes)} total`);
37
+ }
38
+
39
+ function validateArtifactName(rawName: string): string {
40
+ if (typeof rawName !== 'string' || rawName.trim().length === 0) {
41
+ throw new Error('Artifact name is required');
42
+ }
43
+
44
+ const artifactName = rawName.trim();
45
+ if (artifactName.includes('..') || artifactName.includes('/') || artifactName.includes('\\')) {
46
+ throw new Error('Artifact name contains invalid path characters');
47
+ }
48
+ if (LINE_UNSAFE_CHARS_PATTERN.test(artifactName)) {
49
+ throw new Error('Artifact name contains control characters');
50
+ }
51
+
52
+ return artifactName;
53
+ }
54
+
55
+ /**
56
+ * Checks the ifNoFiles policy. Throws on 'error', logs a warning on 'warn',
57
+ * and silently does nothing on 'ignore'.
58
+ */
59
+ function handleNoFilesFound(
60
+ context: ActionContext,
61
+ policy: 'warn' | 'error' | 'ignore',
62
+ message: string
63
+ ): void {
64
+ if (policy === 'error') {
65
+ throw new Error(message);
66
+ }
67
+ if (policy === 'warn') {
68
+ pushLog(context.logs, `Warning: ${message}`);
69
+ }
70
+ }
71
+
72
+ function buildArtifactR2Prefix(runId: string, artifactName: string): string {
73
+ return `actions/artifacts/${runId}/${artifactName}/`;
74
+ }
75
+
76
+ async function collectFiles(
77
+ dirPath: string,
78
+ basePath: string
79
+ ): Promise<Array<{ localPath: string; relativePath: string }>> {
80
+ const files: Array<{ localPath: string; relativePath: string }> = [];
81
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
82
+
83
+ for (const entry of entries) {
84
+ if (entry.isSymbolicLink()) {
85
+ continue;
86
+ }
87
+
88
+ const fullPath = path.join(dirPath, entry.name);
89
+ const relativePath = path.relative(basePath, fullPath);
90
+
91
+ if (entry.isFile()) {
92
+ files.push({ localPath: fullPath, relativePath });
93
+ } else if (entry.isDirectory()) {
94
+ const subFiles = await collectFiles(fullPath, basePath);
95
+ files.push(...subFiles);
96
+ }
97
+ }
98
+
99
+ return files;
100
+ }
101
+
102
+ export async function uploadArtifact(
103
+ inputs: {
104
+ name: string;
105
+ path: string | string[];
106
+ 'retention-days'?: number;
107
+ 'if-no-files-found'?: 'warn' | 'error' | 'ignore';
108
+ 'compression-level'?: number;
109
+ },
110
+ context: ActionContext
111
+ ): Promise<void> {
112
+ pushLog(context.logs, 'Running actions/upload-artifact');
113
+ requireR2('upload artifacts');
114
+
115
+ const artifactName = validateArtifactName(inputs.name);
116
+ const artifactPaths = toStringArray(inputs.path);
117
+ const retentionDays = inputs['retention-days'] || 90;
118
+ const ifNoFiles = inputs['if-no-files-found'] || 'warn';
119
+
120
+ pushLog(context.logs, `Artifact name: ${artifactName}`);
121
+ pushLog(context.logs, `Artifact paths: ${artifactPaths.join(', ')}`);
122
+
123
+ const runId = context.env.GITHUB_RUN_ID || 'unknown';
124
+ const r2ArtifactPrefix = buildArtifactR2Prefix(runId, artifactName);
125
+
126
+ const filesToUpload: Array<{ localPath: string; relativePath: string }> = [];
127
+
128
+ for (const artifactPath of artifactPaths) {
129
+ try {
130
+ const fullPath = resolvePathWithin(
131
+ context.workspacePath,
132
+ artifactPath,
133
+ 'artifact upload'
134
+ );
135
+ const lstat = await fs.lstat(fullPath);
136
+ if (lstat.isSymbolicLink()) {
137
+ throw new Error('Symbolic links are not allowed for artifact upload');
138
+ }
139
+
140
+ const stat = await fs.stat(fullPath);
141
+
142
+ if (stat.isFile()) {
143
+ filesToUpload.push({
144
+ localPath: fullPath,
145
+ relativePath: path.basename(fullPath),
146
+ });
147
+ } else if (stat.isDirectory()) {
148
+ const files = await collectFiles(fullPath, fullPath);
149
+ filesToUpload.push(...files);
150
+ }
151
+ } catch (err) {
152
+ if (ifNoFiles === 'error') {
153
+ throw new Error(`Artifact path is invalid or not found: ${artifactPath} (${getErrorMessage(err)})`);
154
+ }
155
+ if (ifNoFiles === 'warn') {
156
+ pushLog(context.logs, `Warning: Artifact path is invalid or not found: ${artifactPath}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ if (filesToUpload.length === 0) {
162
+ handleNoFilesFound(context, ifNoFiles, 'No files found to upload');
163
+ return;
164
+ }
165
+
166
+ pushLog(context.logs, `Uploading ${filesToUpload.length} file(s)...`);
167
+
168
+ let uploadedCount = 0;
169
+ let totalBytes = 0;
170
+
171
+ for (const file of filesToUpload) {
172
+ try {
173
+ const content = await fs.readFile(file.localPath);
174
+ const r2Key = `${r2ArtifactPrefix}${file.relativePath}`;
175
+
176
+ await s3Client.send(
177
+ new PutObjectCommand({
178
+ Bucket: R2_BUCKET,
179
+ Key: r2Key,
180
+ Body: content,
181
+ Metadata: {
182
+ 'artifact-name': artifactName,
183
+ 'file-path': file.relativePath,
184
+ 'uploaded-at': new Date().toISOString(),
185
+ 'retention-days': String(retentionDays),
186
+ },
187
+ })
188
+ );
189
+
190
+ uploadedCount++;
191
+ totalBytes += content.length;
192
+ } catch (err) {
193
+ pushLog(context.logs, `Warning: Failed to upload ${file.relativePath}: ${err}`);
194
+ }
195
+ }
196
+
197
+ logTransferSummary(context, 'Uploaded', uploadedCount, totalBytes);
198
+ context.setOutput('artifact-url', `r2://${R2_BUCKET}/${r2ArtifactPrefix}`);
199
+ }
200
+
201
+ export async function downloadArtifact(
202
+ inputs: {
203
+ name: string;
204
+ path?: string;
205
+ 'run-id'?: string;
206
+ },
207
+ context: ActionContext
208
+ ): Promise<void> {
209
+ pushLog(context.logs, 'Running actions/download-artifact');
210
+ requireR2('download artifacts');
211
+
212
+ const artifactName = validateArtifactName(inputs.name);
213
+ const downloadPath = inputs.path
214
+ ? resolvePathWithin(context.workspacePath, inputs.path, 'artifact download')
215
+ : path.join(context.workspacePath, artifactName);
216
+ const runId = inputs['run-id'] || context.env.GITHUB_RUN_ID || 'unknown';
217
+
218
+ pushLog(context.logs, `Downloading artifact: ${artifactName}`);
219
+ pushLog(context.logs, `Download path: ${downloadPath}`);
220
+
221
+ await fs.mkdir(downloadPath, { recursive: true });
222
+
223
+ const r2ArtifactPrefix = buildArtifactR2Prefix(runId, artifactName);
224
+
225
+ let continuationToken: string | undefined;
226
+ let downloadedCount = 0;
227
+ let totalBytes = 0;
228
+
229
+ do {
230
+ const listResult = await s3Client.send(
231
+ new ListObjectsV2Command({
232
+ Bucket: R2_BUCKET,
233
+ Prefix: r2ArtifactPrefix,
234
+ ContinuationToken: continuationToken,
235
+ })
236
+ );
237
+
238
+ for (const obj of listResult.Contents || []) {
239
+ if (!obj.Key) continue;
240
+
241
+ const relativePath = obj.Key.slice(r2ArtifactPrefix.length);
242
+ if (!relativePath) continue;
243
+
244
+ // Security: reject relative paths containing ".." segments or absolute
245
+ // paths to prevent path traversal when writing downloaded artifacts.
246
+ if (relativePath.includes('..') || path.isAbsolute(relativePath)) {
247
+ pushLog(context.logs, `Warning: Skipping unsafe artifact path: ${relativePath}`);
248
+ continue;
249
+ }
250
+
251
+ try {
252
+ const getResult = await s3Client.send(
253
+ new GetObjectCommand({
254
+ Bucket: R2_BUCKET,
255
+ Key: obj.Key,
256
+ })
257
+ );
258
+
259
+ if (getResult.Body) {
260
+ const content = await getResult.Body.transformToByteArray();
261
+ const localPath = path.join(downloadPath, relativePath);
262
+
263
+ // Security: verify the resolved path is still within downloadPath
264
+ // after path.join() normalisation (defense-in-depth).
265
+ const resolvedDownload = path.resolve(downloadPath);
266
+ const resolvedLocal = path.resolve(localPath);
267
+ if (!isPathWithinBase(resolvedDownload, resolvedLocal, { allowBase: false })) {
268
+ pushLog(context.logs, `Warning: Skipping artifact that escapes download directory: ${relativePath}`);
269
+ continue;
270
+ }
271
+
272
+ await fs.mkdir(path.dirname(localPath), { recursive: true });
273
+ await fs.writeFile(localPath, content);
274
+
275
+ downloadedCount++;
276
+ totalBytes += content.length;
277
+ }
278
+ } catch (err) {
279
+ pushLog(context.logs, `Warning: Failed to download ${relativePath}: ${err}`);
280
+ }
281
+ }
282
+
283
+ continuationToken = listResult.NextContinuationToken;
284
+ } while (continuationToken);
285
+
286
+ if (downloadedCount === 0) {
287
+ throw new Error(`Artifact not found: ${artifactName}`);
288
+ }
289
+
290
+ logTransferSummary(context, 'Downloaded', downloadedCount, totalBytes);
291
+ context.setOutput('download-path', downloadPath);
292
+ }
@@ -0,0 +1,358 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { createHash } from 'crypto';
5
+ import { GetObjectCommand } from '@aws-sdk/client-s3';
6
+ import { type ActionContext } from '../executor.js';
7
+ import { pushLog } from '../../logging.js';
8
+ import { s3Client, isR2Configured } from '../../../storage/r2.js';
9
+ import { R2_BUCKET } from '../../../shared/config.js';
10
+ import { getErrorMessage } from 'takos-common/errors';
11
+ import { parseTarEntriesFromGzipArchive } from './tar-parser.js';
12
+
13
+ function toStringArray(value: string | string[]): string[] {
14
+ return Array.isArray(value) ? value : [value];
15
+ }
16
+
17
+ /**
18
+ * Matches only null, CR, and LF — the characters that break line-oriented
19
+ * key/name formats (cache keys, artifact names).
20
+ *
21
+ * This is intentionally narrower than ALL_CONTROL_CHARS_PATTERN in
22
+ * runtime/validation.ts, which rejects all C0 control characters + DEL
23
+ * for git paths, author names, and similar security-sensitive inputs.
24
+ */
25
+ export const LINE_UNSAFE_CHARS_PATTERN = /[\0\r\n]/;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Constants
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const CACHE_R2_PREFIX = 'actions/cache';
32
+ const CACHE_NAMESPACE_VERSION = 'v2';
33
+ const CACHE_NAMESPACE_HASH_LENGTH = 24;
34
+ const CACHE_MAX_KEY_LENGTH = 512;
35
+ const CACHE_ALLOWED_TAR_ENTRY_TYPES = new Set(['0', '1', '2', '5', '7']);
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ interface CacheNamespaceInfo {
42
+ value: string;
43
+ source: string;
44
+ }
45
+
46
+ interface CacheObjectCandidate {
47
+ r2Key: string;
48
+ mode: 'namespaced' | 'legacy';
49
+ }
50
+
51
+ function spawnTar(args: string[], cwd: string): Promise<void> {
52
+ return new Promise<void>((resolve, reject) => {
53
+ const tar = spawn('tar', args, { cwd });
54
+
55
+ tar.on('close', (code) => {
56
+ if (code === 0) {
57
+ resolve();
58
+ } else {
59
+ reject(new Error(`tar failed with code ${code}`));
60
+ }
61
+ });
62
+
63
+ tar.on('error', reject);
64
+ });
65
+ }
66
+
67
+ async function cleanupTempFile(tempPath: string, context: ActionContext, reason: string): Promise<void> {
68
+ try {
69
+ await fs.unlink(tempPath);
70
+ } catch (err) {
71
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
72
+ const message = getErrorMessage(err);
73
+ pushLog(context.logs, `Warning: Failed to clean up temp file (${reason}): ${tempPath} - ${message}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Cache key validation
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function validateCacheKey(rawKey: string, label: string): string {
83
+ if (typeof rawKey !== 'string') {
84
+ throw new Error(`Invalid ${label}: expected string`);
85
+ }
86
+ if (rawKey.trim().length === 0) {
87
+ throw new Error(`Invalid ${label}: must not be empty`);
88
+ }
89
+ if (rawKey.length > CACHE_MAX_KEY_LENGTH) {
90
+ throw new Error(`Invalid ${label}: exceeds ${CACHE_MAX_KEY_LENGTH} characters`);
91
+ }
92
+ if (LINE_UNSAFE_CHARS_PATTERN.test(rawKey)) {
93
+ throw new Error(`Invalid ${label}: contains control characters`);
94
+ }
95
+ return rawKey;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // R2 object helpers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function isObjectNotFoundError(err: unknown): boolean {
103
+ if (!err || typeof err !== 'object') {
104
+ return false;
105
+ }
106
+
107
+ const maybeErr = err as {
108
+ name?: string;
109
+ Code?: string;
110
+ $metadata?: {
111
+ httpStatusCode?: number;
112
+ };
113
+ };
114
+
115
+ return (
116
+ maybeErr.name === 'NoSuchKey' ||
117
+ maybeErr.Code === 'NoSuchKey' ||
118
+ maybeErr.$metadata?.httpStatusCode === 404
119
+ );
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Cache namespace resolution
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function firstNonEmpty(values: Array<string | undefined>): string | undefined {
127
+ for (const value of values) {
128
+ if (typeof value !== 'string') continue;
129
+ const trimmed = value.trim();
130
+ if (trimmed.length > 0) {
131
+ return trimmed;
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function buildCacheNamespace(context: ActionContext): CacheNamespaceInfo {
138
+ const spaceId = firstNonEmpty([context.env.TAKOS_SPACE_ID, context.env.SPACE_ID]);
139
+ const repository = firstNonEmpty([context.env.GITHUB_REPOSITORY]);
140
+ const workflow = firstNonEmpty([context.env.GITHUB_WORKFLOW]);
141
+
142
+ const namespaceParts: string[] = [];
143
+ if (spaceId) {
144
+ namespaceParts.push(`workspace:${spaceId}`);
145
+ }
146
+ if (repository) {
147
+ namespaceParts.push(`repository:${repository}`);
148
+ }
149
+ if (namespaceParts.length === 0 && workflow) {
150
+ namespaceParts.push(`workflow:${workflow}`);
151
+ }
152
+ if (namespaceParts.length === 0) {
153
+ namespaceParts.push('default');
154
+ }
155
+
156
+ const seed = namespaceParts.join('|');
157
+ const value = createHash('sha256')
158
+ .update(seed)
159
+ .digest('hex')
160
+ .slice(0, CACHE_NAMESPACE_HASH_LENGTH);
161
+
162
+ return { value, source: seed };
163
+ }
164
+
165
+ function buildNamespacedCacheObjectKey(cacheKey: string, namespace: CacheNamespaceInfo): string {
166
+ return `${CACHE_R2_PREFIX}/${CACHE_NAMESPACE_VERSION}/${namespace.value}/${cacheKey}.tar.gz`;
167
+ }
168
+
169
+ function buildLegacyCacheObjectKey(cacheKey: string): string {
170
+ return `${CACHE_R2_PREFIX}/${cacheKey}.tar.gz`;
171
+ }
172
+
173
+ function buildCacheObjectCandidates(
174
+ cacheKey: string,
175
+ namespace: CacheNamespaceInfo
176
+ ): CacheObjectCandidate[] {
177
+ return [
178
+ {
179
+ r2Key: buildNamespacedCacheObjectKey(cacheKey, namespace),
180
+ mode: 'namespaced',
181
+ },
182
+ {
183
+ r2Key: buildLegacyCacheObjectKey(cacheKey),
184
+ mode: 'legacy',
185
+ },
186
+ ];
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Archive path safety
191
+ // ---------------------------------------------------------------------------
192
+
193
+ function normalizeArchivePath(value: string): string {
194
+ const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
195
+ return normalized || '.';
196
+ }
197
+
198
+ function isAbsoluteArchivePath(value: string): boolean {
199
+ return value.startsWith('/') || /^[A-Za-z]:\//.test(value);
200
+ }
201
+
202
+ function containsTraversalSegments(value: string): boolean {
203
+ return value.split('/').some((segment) => segment === '..');
204
+ }
205
+
206
+ function assertSafeArchivePath(value: string, label: string, allowDot: boolean): string {
207
+ if (typeof value !== 'string' || value.length === 0) {
208
+ throw new Error(`${label} is empty`);
209
+ }
210
+
211
+ const normalizedValue = normalizeArchivePath(value);
212
+ if (!allowDot && normalizedValue === '.') {
213
+ throw new Error(`${label} resolves to workspace root`);
214
+ }
215
+ if (isAbsoluteArchivePath(normalizedValue)) {
216
+ throw new Error(`${label} is absolute: ${value}`);
217
+ }
218
+ if (containsTraversalSegments(normalizedValue)) {
219
+ throw new Error(`${label} contains path traversal: ${value}`);
220
+ }
221
+
222
+ return normalizedValue;
223
+ }
224
+
225
+ function validateCacheArchiveEntries(archiveData: Uint8Array): void {
226
+ const entries = parseTarEntriesFromGzipArchive(archiveData);
227
+
228
+ for (const entry of entries) {
229
+ if (!CACHE_ALLOWED_TAR_ENTRY_TYPES.has(entry.type)) {
230
+ throw new Error(`Unsupported archive entry type "${entry.type}" (${entry.path || '<empty>'})`);
231
+ }
232
+
233
+ const entryPath = assertSafeArchivePath(entry.path, 'Archive entry path', true);
234
+ if (entryPath === '.' && entry.type !== '5') {
235
+ throw new Error('Archive entry path "." is only allowed for directory entries');
236
+ }
237
+
238
+ if (entry.type === '1' || entry.type === '2') {
239
+ if (entry.linkPath.trim().length === 0) {
240
+ throw new Error(`Archive link entry "${entryPath}" has empty target`);
241
+ }
242
+ assertSafeArchivePath(entry.linkPath, `Archive link target for "${entryPath}"`, true);
243
+ }
244
+ }
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Exported cache operations
249
+ // ---------------------------------------------------------------------------
250
+
251
+ export async function cache(
252
+ inputs: {
253
+ path: string | string[];
254
+ key: string;
255
+ 'restore-keys'?: string[];
256
+ },
257
+ context: ActionContext
258
+ ): Promise<{ cacheHit: boolean }> {
259
+ pushLog(context.logs, 'Running actions/cache');
260
+
261
+ if (!isR2Configured()) {
262
+ pushLog(context.logs, 'Warning: R2 storage not configured, cache disabled');
263
+ return { cacheHit: false };
264
+ }
265
+
266
+ const cachePaths = toStringArray(inputs.path);
267
+ const cacheKey = validateCacheKey(inputs.key, 'cache key');
268
+ const restoreKeys: string[] = [];
269
+ for (const restoreKey of inputs['restore-keys'] || []) {
270
+ try {
271
+ restoreKeys.push(validateCacheKey(restoreKey, 'restore key'));
272
+ } catch (err) {
273
+ pushLog(context.logs, `Warning: Ignoring invalid restore key: ${getErrorMessage(err)}`);
274
+ }
275
+ }
276
+
277
+ pushLog(context.logs, `Cache key: ${cacheKey}`);
278
+ pushLog(context.logs, `Cache paths: ${cachePaths.join(', ')}`);
279
+ const cacheNamespace = buildCacheNamespace(context);
280
+ pushLog(
281
+ context.logs,
282
+ `Cache namespace: ${cacheNamespace.value} (seed: ${cacheNamespace.source})`
283
+ );
284
+
285
+ const keysToTry = [...new Set([cacheKey, ...restoreKeys])];
286
+ let cacheHit = false;
287
+ let matchedKey = '';
288
+
289
+ restoreLoop: for (const key of keysToTry) {
290
+ for (const candidate of buildCacheObjectCandidates(key, cacheNamespace)) {
291
+ try {
292
+ const getResult = await s3Client.send(
293
+ new GetObjectCommand({
294
+ Bucket: R2_BUCKET,
295
+ Key: candidate.r2Key,
296
+ })
297
+ );
298
+
299
+ if (!getResult.Body) {
300
+ continue;
301
+ }
302
+
303
+ pushLog(
304
+ context.logs,
305
+ `Cache hit for key: ${key} (${candidate.mode}, object: ${candidate.r2Key})`
306
+ );
307
+
308
+ const cacheData = await getResult.Body.transformToByteArray();
309
+ const tempTarPath = path.join(context.workspacePath, '.cache-temp.tar.gz');
310
+ await fs.writeFile(tempTarPath, cacheData);
311
+
312
+ try {
313
+ validateCacheArchiveEntries(cacheData);
314
+ await spawnTar(
315
+ ['-xzf', tempTarPath, '--no-same-owner', '--no-same-permissions', '-C', context.workspacePath],
316
+ context.workspacePath
317
+ );
318
+ } catch (err) {
319
+ throw new Error(
320
+ `Rejected cache archive "${candidate.r2Key}": ${getErrorMessage(err)}`
321
+ );
322
+ } finally {
323
+ await cleanupTempFile(tempTarPath, context, 'cache restore');
324
+ }
325
+
326
+ if (candidate.mode === 'legacy') {
327
+ pushLog(
328
+ context.logs,
329
+ `Warning: Restored legacy global cache key "${key}". Future saves use namespaced keys.`
330
+ );
331
+ }
332
+
333
+ cacheHit = true;
334
+ matchedKey = key;
335
+ break restoreLoop;
336
+ } catch (err) {
337
+ if (isObjectNotFoundError(err)) {
338
+ continue;
339
+ }
340
+
341
+ pushLog(
342
+ context.logs,
343
+ `Warning: Cache restore failed for key "${key}" (${candidate.mode}): ${getErrorMessage(err)}`
344
+ );
345
+ }
346
+ }
347
+ }
348
+
349
+ context.setOutput('cache-primary-key', cacheKey);
350
+ context.setOutput('cache-matched-key', cacheHit ? matchedKey : '');
351
+
352
+ if (!cacheHit) {
353
+ pushLog(context.logs, 'Cache miss');
354
+ }
355
+
356
+ return { cacheHit };
357
+ }
358
+
@@ -0,0 +1,58 @@
1
+ import * as fs from 'fs/promises';
2
+ import { type ActionContext } from '../executor.js';
3
+ import { pushLog } from '../../logging.js';
4
+ import { runGitCommand, cloneAndCheckout } from '../../git.js';
5
+ import { resolvePathWithin } from '../../paths.js';
6
+ import { GIT_ENDPOINT_URL } from '../../../shared/config.js';
7
+
8
+ export async function checkout(
9
+ inputs: {
10
+ ref?: string;
11
+ path?: string;
12
+ repository?: string;
13
+ token?: string;
14
+ 'fetch-depth'?: number;
15
+ },
16
+ context: ActionContext
17
+ ): Promise<void> {
18
+ pushLog(context.logs, 'Running actions/checkout');
19
+
20
+ const checkoutPath = inputs.path
21
+ ? resolvePathWithin(context.workspacePath, inputs.path, 'checkout path')
22
+ : context.workspacePath;
23
+
24
+ const repository = inputs.repository || context.env.GITHUB_REPOSITORY || '';
25
+ const ref = inputs.ref || context.env.GITHUB_REF || 'main';
26
+ const fetchDepth = inputs['fetch-depth'] ?? 1;
27
+
28
+ pushLog(context.logs, `Repository: ${repository}`);
29
+ pushLog(context.logs, `Ref: ${ref}`);
30
+ pushLog(context.logs, `Path: ${checkoutPath}`);
31
+
32
+ await fs.mkdir(checkoutPath, { recursive: true });
33
+
34
+ const gitUrl = repository.includes('://')
35
+ ? repository
36
+ : `${GIT_ENDPOINT_URL}/${repository}.git`;
37
+
38
+ const cloneResult = await cloneAndCheckout({
39
+ repoUrl: gitUrl,
40
+ targetDir: checkoutPath,
41
+ ref,
42
+ shallow: fetchDepth > 0,
43
+ env: context.env,
44
+ });
45
+
46
+ if (!cloneResult.success) {
47
+ throw new Error(`Git clone failed: ${cloneResult.output}`);
48
+ }
49
+
50
+ const revParseResult = await runGitCommand(['rev-parse', 'HEAD'], checkoutPath, context.env);
51
+ if (revParseResult.exitCode === 0) {
52
+ const sha = revParseResult.output.trim();
53
+ context.setOutput('sha', sha);
54
+ pushLog(context.logs, `Checked out: ${sha}`);
55
+ }
56
+
57
+ pushLog(context.logs, 'Checkout completed successfully');
58
+ }