All files / src/entities/pipelines artifact-path.ts

100% Statements 27/27
100% Branches 22/22
100% Functions 4/4
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 4037x   37x 37x 37x     37x 108x 3x   105x     144x 141x 141x 148x 7x     141x 9x   141x 7x   134x           249x 2675x 2675x 2429x      
import { encodeApiSegment } from '../../utils/api-path';
 
export const ARTIFACT_DEFAULT_BYTES = 64 * 1024;
export const ARTIFACT_MAX_BYTES = 1024 * 1024;
export const ARTIFACT_MAX_OFFSET = 20 * 1024 * 1024;
 
/** Keep a nested archive path relative and unambiguous through URL and Rails decoding. */
export function encodeArtifactPath(path: string): string {
  if (!path || path.length > 4096 || path.includes('\\') || hasControl(path)) {
    throw new Error('Artifact path must be a relative file path without control characters');
  }
  return path
    .split('/')
    .map((segment) => {
      if (!segment) throw new Error('Artifact path cannot contain empty segments');
      let decoded = segment;
      for (;;) {
        if (decoded === '.' || decoded === '..' || /[/\\]/.test(decoded) || hasControl(decoded)) {
          throw new Error('Artifact path cannot contain traversal or encoded separators');
        }
        // Only ASCII escapes alter path syntax; other percent sequences are literal filenames.
        const next = decoded.replace(/%([0-7][0-9a-f])/gi, (_encoded, hex: string) =>
          String.fromCodePoint(Number.parseInt(hex, 16)),
        );
        if (next === decoded) break;
        decoded = next;
      }
      return encodeApiSegment(segment);
    })
    .join('/');
}
 
function hasControl(value: string): boolean {
  for (let index = 0; ; index++) {
    const code = value.codePointAt(index);
    if (code === undefined) return false;
    if (code < 32 || code === 127) return true;
  }
}