|
| 1 | +import { ExtractedLayers, HistoryEntry } from "../extractor/types"; |
| 2 | +import { LayerAttributionEntry } from "../facts"; |
| 3 | +import { |
| 4 | + getApkDbFileContent, |
| 5 | + getApkDbFileContentAction, |
| 6 | +} from "../inputs/apk/static"; |
| 7 | +import { |
| 8 | + getAptDbFileContent, |
| 9 | + getDpkgFileContentAction, |
| 10 | +} from "../inputs/apt/static"; |
| 11 | +import { |
| 12 | + getChiselManifestAction, |
| 13 | + getChiselManifestContent, |
| 14 | +} from "../inputs/chisel/static"; |
| 15 | +import { |
| 16 | + getRpmDbFileContent, |
| 17 | + getRpmDbFileContentAction, |
| 18 | + getRpmNdbFileContent, |
| 19 | + getRpmNdbFileContentAction, |
| 20 | + getRpmSqliteDbFileContent, |
| 21 | + getRpmSqliteDbFileContentAction, |
| 22 | +} from "../inputs/rpm/static"; |
| 23 | +import { analyze as apkAnalyze } from "./package-managers/apk"; |
| 24 | +import { analyze as aptAnalyze } from "./package-managers/apt"; |
| 25 | +import { analyze as chiselAnalyze } from "./package-managers/chisel"; |
| 26 | +import { analyze as rpmAnalyze } from "./package-managers/rpm"; |
| 27 | +import { AnalysisType } from "./types"; |
| 28 | + |
| 29 | +export interface LayerAttributionResult { |
| 30 | + entries: LayerAttributionEntry[]; |
| 31 | + pkgLayerMap: Map<string, { layerIndex: number; diffID: string }>; |
| 32 | +} |
| 33 | + |
| 34 | +function buildHistoryInstructions( |
| 35 | + history: HistoryEntry[] | null | undefined, |
| 36 | +): string[] { |
| 37 | + if (!history) { |
| 38 | + return []; |
| 39 | + } |
| 40 | + return history.filter((h) => !h.empty_layer).map((h) => h.created_by ?? ""); |
| 41 | +} |
| 42 | + |
| 43 | +function pkgKey(name: string, version: string): string { |
| 44 | + return `${name}@${version}`; |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * Returns true if the layer contains a file that was processed by the given |
| 49 | + * extract action. Used to distinguish "layer has no package DB" (return null |
| 50 | + * → skip) from "layer has an empty package DB" (return empty Set → track). |
| 51 | + */ |
| 52 | +function layerHasAction(layer: ExtractedLayers, actionName: string): boolean { |
| 53 | + return Object.values(layer).some((fileContent) => actionName in fileContent); |
| 54 | +} |
| 55 | + |
| 56 | +/** |
| 57 | + * Parses the package DB for a single layer and returns the set of |
| 58 | + * "name@version" keys present in that layer. |
| 59 | + * |
| 60 | + * Returns null when the layer does not contain the package DB file at all |
| 61 | + * (e.g. a COPY or ENV instruction). An empty Set means the DB file exists |
| 62 | + * but is empty (e.g. all packages were removed in this layer). |
| 63 | + */ |
| 64 | +async function parseLayerPackages( |
| 65 | + layer: ExtractedLayers, |
| 66 | + analysisType: AnalysisType, |
| 67 | + targetImage: string, |
| 68 | +): Promise<Set<string> | null> { |
| 69 | + if (analysisType === AnalysisType.Apk) { |
| 70 | + if (!layerHasAction(layer, getApkDbFileContentAction.actionName)) { |
| 71 | + return null; |
| 72 | + } |
| 73 | + const content = getApkDbFileContent(layer); |
| 74 | + const analysis = await apkAnalyze(targetImage, content); |
| 75 | + const result = new Set<string>(); |
| 76 | + for (const pkg of analysis.Analysis) { |
| 77 | + result.add(pkgKey(pkg.Name, pkg.Version)); |
| 78 | + } |
| 79 | + return result; |
| 80 | + } |
| 81 | + |
| 82 | + if (analysisType === AnalysisType.Apt) { |
| 83 | + if (!layerHasAction(layer, getDpkgFileContentAction.actionName)) { |
| 84 | + return null; |
| 85 | + } |
| 86 | + const aptFiles = getAptDbFileContent(layer); |
| 87 | + const analysis = await aptAnalyze(targetImage, aptFiles); |
| 88 | + const result = new Set<string>(); |
| 89 | + for (const pkg of analysis.Analysis) { |
| 90 | + result.add(pkgKey(pkg.Name, pkg.Version)); |
| 91 | + } |
| 92 | + return result; |
| 93 | + } |
| 94 | + |
| 95 | + if (analysisType === AnalysisType.Rpm) { |
| 96 | + const hasBdb = layerHasAction(layer, getRpmDbFileContentAction.actionName); |
| 97 | + const hasNdb = layerHasAction(layer, getRpmNdbFileContentAction.actionName); |
| 98 | + const hasSqlite = layerHasAction( |
| 99 | + layer, |
| 100 | + getRpmSqliteDbFileContentAction.actionName, |
| 101 | + ); |
| 102 | + if (!hasBdb && !hasNdb && !hasSqlite) { |
| 103 | + return null; |
| 104 | + } |
| 105 | + const [bdbPkgs, ndbPkgs, sqlitePkgs] = await Promise.all([ |
| 106 | + hasBdb ? getRpmDbFileContent(layer) : Promise.resolve([]), |
| 107 | + hasNdb ? getRpmNdbFileContent(layer) : Promise.resolve([]), |
| 108 | + hasSqlite ? getRpmSqliteDbFileContent(layer) : Promise.resolve([]), |
| 109 | + ]); |
| 110 | + const analysis = await rpmAnalyze( |
| 111 | + targetImage, |
| 112 | + [...bdbPkgs, ...ndbPkgs, ...sqlitePkgs], |
| 113 | + [], |
| 114 | + ); |
| 115 | + const result = new Set<string>(); |
| 116 | + for (const pkg of analysis.Analysis) { |
| 117 | + result.add(pkgKey(pkg.Name, pkg.Version)); |
| 118 | + } |
| 119 | + return result; |
| 120 | + } |
| 121 | + |
| 122 | + if (analysisType === AnalysisType.Chisel) { |
| 123 | + if (!layerHasAction(layer, getChiselManifestAction.actionName)) { |
| 124 | + return null; |
| 125 | + } |
| 126 | + const pkgs = getChiselManifestContent(layer); |
| 127 | + const analysis = await chiselAnalyze(targetImage, pkgs); |
| 128 | + const result = new Set<string>(); |
| 129 | + for (const pkg of analysis.Analysis) { |
| 130 | + result.add(pkgKey(pkg.Name, pkg.Version)); |
| 131 | + } |
| 132 | + return result; |
| 133 | + } |
| 134 | + |
| 135 | + return null; |
| 136 | +} |
| 137 | + |
| 138 | +export async function computeLayerAttribution( |
| 139 | + orderedLayers: ExtractedLayers[], |
| 140 | + analysisType: AnalysisType, |
| 141 | + rootFsLayers: string[], |
| 142 | + manifestLayers: string[], |
| 143 | + history: HistoryEntry[] | null | undefined, |
| 144 | + targetImage: string, |
| 145 | +): Promise<LayerAttributionResult> { |
| 146 | + const instructions = buildHistoryInstructions(history); |
| 147 | + const entries: LayerAttributionEntry[] = []; |
| 148 | + const pkgLayerMap = new Map<string, { layerIndex: number; diffID: string }>(); |
| 149 | + const limit = Math.min(orderedLayers.length, rootFsLayers.length); |
| 150 | + |
| 151 | + let previousPkgs = new Set<string>(); |
| 152 | + |
| 153 | + for (let i = 0; i < limit; i++) { |
| 154 | + const diffID = rootFsLayers[i]; |
| 155 | + // Explicit bounds guard: manifestLayers and instructions may be shorter |
| 156 | + // than rootFsLayers for malformed or partially-described images. |
| 157 | + const digest = i < manifestLayers.length ? manifestLayers[i] : undefined; |
| 158 | + const instruction = i < instructions.length ? instructions[i] : undefined; |
| 159 | + |
| 160 | + const currentPkgs = await parseLayerPackages( |
| 161 | + orderedLayers[i], |
| 162 | + analysisType, |
| 163 | + targetImage, |
| 164 | + ); |
| 165 | + if (currentPkgs === null) { |
| 166 | + // Layer has no package DB file (e.g. COPY/ENV/LABEL instruction). |
| 167 | + // Do not update previousPkgs — the package state has not changed. |
| 168 | + continue; |
| 169 | + } |
| 170 | + |
| 171 | + const newPkgs: string[] = []; |
| 172 | + for (const key of currentPkgs) { |
| 173 | + if (!previousPkgs.has(key)) { |
| 174 | + newPkgs.push(key); |
| 175 | + pkgLayerMap.set(key, { layerIndex: i, diffID }); |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + const removedPkgs: string[] = []; |
| 180 | + for (const key of previousPkgs) { |
| 181 | + if (!currentPkgs.has(key)) { |
| 182 | + removedPkgs.push(key); |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + if (newPkgs.length > 0 || removedPkgs.length > 0) { |
| 187 | + const entry: LayerAttributionEntry = { |
| 188 | + layerIndex: i, |
| 189 | + diffID, |
| 190 | + packages: newPkgs, |
| 191 | + }; |
| 192 | + if (digest) { |
| 193 | + entry.digest = digest; |
| 194 | + } |
| 195 | + if (instruction) { |
| 196 | + entry.instruction = instruction; |
| 197 | + } |
| 198 | + if (removedPkgs.length > 0) { |
| 199 | + entry.removedPackages = removedPkgs; |
| 200 | + } |
| 201 | + entries.push(entry); |
| 202 | + } |
| 203 | + |
| 204 | + previousPkgs = currentPkgs; |
| 205 | + } |
| 206 | + |
| 207 | + return { entries, pkgLayerMap }; |
| 208 | +} |
| 209 | + |
| 210 | +/** |
| 211 | + * Merges attribution entries produced by multiple package managers into a |
| 212 | + * single list sorted by layer index. When two managers both write entries for |
| 213 | + * the same layer (e.g. APT and Chisel in a mixed image), their package lists |
| 214 | + * and removedPackages lists are combined. Layer metadata (diffID, digest, |
| 215 | + * instruction) is taken from the first entry seen for that layer index. |
| 216 | + */ |
| 217 | +export function mergeLayerAttributionEntries( |
| 218 | + entries: LayerAttributionEntry[], |
| 219 | +): LayerAttributionEntry[] { |
| 220 | + const byLayer = new Map<number, LayerAttributionEntry>(); |
| 221 | + |
| 222 | + for (const entry of entries) { |
| 223 | + const existing = byLayer.get(entry.layerIndex); |
| 224 | + if (!existing) { |
| 225 | + byLayer.set(entry.layerIndex, { |
| 226 | + ...entry, |
| 227 | + packages: [...entry.packages], |
| 228 | + removedPackages: entry.removedPackages |
| 229 | + ? [...entry.removedPackages] |
| 230 | + : undefined, |
| 231 | + }); |
| 232 | + } else { |
| 233 | + existing.packages.push(...entry.packages); |
| 234 | + if (entry.removedPackages && entry.removedPackages.length > 0) { |
| 235 | + if (!existing.removedPackages) { |
| 236 | + existing.removedPackages = [...entry.removedPackages]; |
| 237 | + } else { |
| 238 | + existing.removedPackages.push(...entry.removedPackages); |
| 239 | + } |
| 240 | + } |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + return Array.from(byLayer.values()).sort( |
| 245 | + (a, b) => a.layerIndex - b.layerIndex, |
| 246 | + ); |
| 247 | +} |
0 commit comments