Skip to content
File

Blob: src/pyodide/internal/tar.ts

typescript147 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5// This is based on the info about the tar file format on wikipedia
6// And some trial and error with real tar files.
7// https://en.wikipedia.org/wiki/Tar_(computing)#File_format
8 
9import { PythonWorkersInternalError } from 'pyodide-internal:util';
10 
11const decoder = new TextDecoder();
12function decodeString(buf: Uint8Array): string {
13 const nullIdx = buf.indexOf(0);
14 if (nullIdx >= 0) {
15 buf = buf.subarray(0, nullIdx);
16 }
17 return decoder.decode(buf);
18}
19function decodeField(buf: Uint8Array, offset: number, size: number): string {
20 return decodeString(buf.subarray(offset, offset + size));
21}
22function decodeNumber(buf: Uint8Array, offset: number, size: number): number {
23 return parseInt(decodeField(buf, offset, size), 8);
24}
25 
26function decodeHeader(buf: Uint8Array, reader: Reader): TarFSInfo {
27 const nameBase = decodeField(buf, 0, 100);
28 const namePrefix = decodeField(buf, 345, 155);
29 let path = namePrefix + nameBase;
30 // Trim possible leading ./
31 if (path.startsWith('./')) {
32 path = path.slice(2);
33 }
34 const mode = decodeNumber(buf, 100, 8);
35 const size = decodeNumber(buf, 124, 12);
36 const modtime = decodeNumber(buf, 136, 12);
37 const type = String.fromCharCode(buf[156]!);
38 return {
39 path,
40 name: path,
41 mode,
42 size,
43 modtime,
44 type,
45 parts: [],
46 children: undefined,
47 reader,
48 };
49}
50 
51export function parseTarInfo(reader: Reader): [TarFSInfo, string[]] {
52 const directories: TarFSInfo[] = [];
53 const soFiles = [];
54 const root: TarFSInfo = {
55 children: new Map(),
56 mode: 0o777,
57 type: '5',
58 modtime: 0,
59 size: 0,
60 path: '',
61 name: '',
62 parts: [],
63 reader,
64 };
65 let directory = root;
66 const buf = new Uint8Array(512);
67 let offset = 0;
68 let longName = null; // if truthy, overwrites the filename of the next header
69 while (true) {
70 reader.read(offset, buf);
71 const info = decodeHeader(buf, reader);
72 if (isNaN(info.mode)) {
73 // Invalid mode means we're done
74 return [root, soFiles];
75 }
76 if (longName) {
77 info.path = longName;
78 info.name = longName;
79 longName = null;
80 }
81 const contentsOffset = offset + 512;
82 offset += 512 * Math.ceil(info.size / 512 + 1);
83 if (info.path === '') {
84 // skip possible leading ./ directory
85 continue;
86 }
87 if (info.path.includes('PaxHeader')) {
88 // Ignore PaxHeader extension
89 // These metadata directories don't actually have a directory entry which
90 // is going to cause us to crash below.
91 // Our tar files shouldn't have these anyways...
92 continue;
93 }
94 if (info.type === 'L') {
95 const buf = new Uint8Array(info.size);
96 reader.read(contentsOffset, buf);
97 longName = decodeString(buf);
98 continue;
99 }
100 
101 // Navigate to the correct directory by going up until we're at the common
102 // ancestor of the current position and the target then back down.
103 //
104 // Most tar files I run into are lexicographically sorted, so the "go back
105 // down" step is not necessary. But some tar files are a bit out of order.
106 //
107 // We do rely on the fact that the entry for a given directory appears
108 // before any files in the directory. I don't see anywhere in the spec where
109 // it says this is required but I think it would be weird and annoying for a
110 // tar file to violate this property.
111 
112 // go up to common ancestor
113 while (directories.length && !info.name.startsWith(directory.path)) {
114 directory = directories.pop()!;
115 }
116 // go down to target (in many tar files this second loop body is evaluated 0
117 // times)
118 const parts = info.path.slice(0, -1).split('/');
119 for (let i = directories.length; i < parts.length - 1; i++) {
120 directories.push(directory);
121 directory = directory.children!.get(parts[i]!)!;
122 }
123 if (info.type === '5') {
124 // a directory
125 directories.push(directory);
126 info.parts = parts;
127 info.name = info.parts.at(-1)!;
128 info.children = new Map();
129 directory.children!.set(info.name, info);
130 directory = info;
131 } else if (info.type === '0') {
132 // a normal file
133 info.contentsOffset = contentsOffset;
134 info.name = info.path.slice(directory.path.length);
135 if (info.name.endsWith('.so')) {
136 soFiles.push(info.path);
137 }
138 directory.children!.set(info.name, info);
139 } else {
140 // fail if we encounter other values of type (e.g., symlink, LongName, etc)
141 throw new PythonWorkersInternalError(
142 `Python TarFS error: Unexpected type ${info.type}`
143 );
144 }
145 }
146}