click-parser
Version:
Extract data from Ubuntu's click packages
590 lines (521 loc) • 16.8 kB
JavaScript
const ar = require('ar');
const fs = require('fs');
const path = require('path');
const streamifier = require('streamifier');
const tar = require('tar');
const tarstream = require('tar-stream');
const uuid = require('uuid');
const xml2js = require('xml2js');
const zlib = require('zlib');
const utf8 = require('utf8');
const App = require('./app');
const utils = require('./utils');
// TODO detect indicators
function extractIcon(fileData, data, callback) {
let iconpath = data.iconpath;
if (iconpath.indexOf('./') !== 0) {
iconpath = `./${data.iconpath}`;
}
const write = path.join('/tmp', `${uuid.v4()}${path.extname(data.iconpath).toLowerCase()}`);
const f = fs.createWriteStream(write)
.on('finish', () => {
data.icon = write;
callback(null, data);
});
let found = false;
streamifier.createReadStream(fileData)
.on('error', (err) => {
console.error(err);
callback(null, data);
})
.pipe(zlib.Unzip())
.pipe(new tar.Parse())
.on('entry', (entry) => {
if (entry.path == iconpath) {
entry.pipe(f);
found = true;
}
else {
entry.resume();
}
})
.on('end', () => {
if (!found) {
callback(null, data);
}
});
}
function parseJsonFile(stream, callback) {
stream.on('data', (fdata) => {
const str = fdata.toString();
if (utils.isJson(str)) {
callback(JSON.parse(str));
}
else {
callback();
}
});
}
function parseXmlFile(stream, callback) {
stream.on('data', (fdata) => {
const str = fdata.toString();
xml2js.parseString(str, {
explicitArray: false,
mergeAttrs: true,
normalizeTags: true,
normalize: true,
}, (err, result) => {
if (err) {
callback();
}
else {
callback(result);
}
});
});
}
function parseIniFile(stream, callback) {
stream.on('data', (fdata) => {
const data = {};
const desktop = fdata.toString().split('\n');
const other = [];
desktop.forEach((line) => {
const lline = line.toLowerCase().trim();
if (!(lline[0] == '[' && lline[lline.length - 1] == ']') && lline.length > 0) {
const pos = lline.indexOf('=');
if (pos > -1) {
const key = lline.substring(0, pos).trim();
const value = line.trim().substring(pos + 1).trim();
data[key] = value;
}
else {
other.push(line);
}
}
});
if (other.length > 0) {
data.other = other;
}
callback(data);
});
}
function parseQmlImports(str) {
const split = str.split('\n');
return split.filter((line) => line.trim().startsWith('import ')).map((line) => {
const tokens = line.split(' ');
const module = tokens[1].replace(/"/g, '');
const isJs = module.endsWith('.js');
const version = isJs ? null : tokens[2];
return {
module,
version,
};
});
}
function parseData(fileData, data, icon, callback) {
let potentialOgraWebapp = false;
streamifier.createReadStream(fileData)
.pipe(zlib.Unzip())
.pipe(tarstream.extract())
.on('entry', (header, stream, cb) => {
const filename = header.name.replace('./', '');
if (filename) {
data.files.push(filename);
}
let found = false;
data.apps.forEach((app) => {
const scopeIni = `${data.name}_${app.name}.ini`;
if (app.hooks.desktop && header.name == `./${app.hooks.desktop}`) {
found = true;
parseIniFile(stream, (json) => {
app.desktop = json || {};
if (app.desktop.exec) {
if (app.desktop.exec.indexOf('webapp-container') === 0) {
/*
Check the arguments to webapp-container, if they contain
an external url as the main url the assume it's a webapp.
*/
let url = null;
const exec = app.desktop.exec.replace(' ', ' ').replace('webapp-container', '').trim();
// Match spaces not encased in quotes: http://stackoverflow.com/a/16261693
const execSplit = exec.match(/(?:[^\s"]+|"[^"]*")+/g);
execSplit.forEach((value) => {
if (
value && (
value.substring(0, 7) == 'http://' ||
value.substring(0, 8) == 'https://'
)
) {
url = value;
}
});
if (url) {
app.type = 'webapp';
app.webappUrl = url;
}
}
}
if (app.desktop.icon) {
data.iconpath = app.desktop.icon;
}
cb();
});
}
else if (app.hooks.apparmor && header.name == `./${app.hooks.apparmor}`) {
found = true;
parseJsonFile(stream, (json) => {
app.apparmor = json || {};
cb();
});
}
else if (app.hooks['content-hub'] && header.name == `./${app.hooks['content-hub']}`) {
found = true;
parseJsonFile(stream, (json) => {
app.contentHub = json || {};
cb();
});
}
else if (app.hooks.urls && header.name == `./${app.hooks.urls}`) {
found = true;
parseJsonFile(stream, (json) => {
app.urlDispatcher = json || [];
cb();
});
}
else if (app.hooks['push-helper'] && header.name == `./${app.hooks['push-helper']}`) {
found = true;
parseJsonFile(stream, (json) => {
app.pushHelper = json || {};
cb();
});
}
else if (app.hooks['account-service'] && header.name == `./${app.hooks['account-service']}`) {
found = true;
parseXmlFile(stream, (json) => {
app.accountService = json || {};
cb();
});
}
else if (app.hooks['account-application'] && header.name == `./${app.hooks['account-application']}`) {
found = true;
parseXmlFile(stream, (json) => {
app.accountApplication = json || {};
cb();
});
}
else if (app.type == 'scope' && header.name.indexOf(scopeIni, header.name.length - scopeIni.length) !== -1) {
found = true;
parseIniFile(stream, (json) => {
app.scopeIni = json || {};
if (app.scopeIni.icon) {
// Assume the icon is relative to the ini file
data.iconpath = header.name.replace(scopeIni, app.scopeIni.icon).replace('./', '');
}
cb();
});
}
else if ((app.type == 'app' || app.type == 'webapp+') && header.name == './config.js') {
/*
Detect the Ogra's alternate webapp container.
Detect both the presence of a config.js file and
Main.qml file with certain parameters.
*/
stream.on('data', (fdata) => {
const str = fdata.toString();
if (
str.indexOf('var webappName') >= 0 &&
str.indexOf('var webappUrl') >= 0 &&
str.indexOf('var webappUrlPattern') >= 0
) {
if (potentialOgraWebapp) {
app.type = 'webapp+';
}
else {
potentialOgraWebapp = true;
}
}
cb();
});
}
else if ((app.type == 'app' || app.type == 'webapp+') && header.name == './qml/Main.qml') {
// Detect the Ogra's alternate webapp container http://bazaar.launchpad.net/~ogra/alternate-webapp-container/trunk/files
stream.on('data', (fdata) => {
const str = fdata.toString();
if (str.indexOf('WebView') >= 0 || str.indexOf('WebEngineView') >= 0) {
if (potentialOgraWebapp) {
app.type = 'webapp+';
}
else {
potentialOgraWebapp = true;
}
}
app.qmlImports = app.qmlImports.concat(parseQmlImports(str));
cb();
});
}
else if (header.name.endsWith('.qml')) {
stream.on('data', (fdata) => {
const str = fdata.toString();
app.qmlImports = app.qmlImports.concat(parseQmlImports(str));
cb();
});
}
});
if (!found) {
if (header.name == './webapp-properties.json') {
stream.on('data', (fdata) => {
let webappProperties = fdata.toString();
if (utils.isJson(webappProperties)) {
webappProperties = JSON.parse(webappProperties);
data.webappProperties = webappProperties;
if (webappProperties.scripts && webappProperties.scripts.length > 0) {
data.webappInject = true;
}
}
cb();
});
}
else if (header.name.indexOf('LC_MESSAGES') >= 0 && header.name.indexOf('.mo') >= 0) {
const langs = header.name.split('/');
const pos = langs.indexOf('LC_MESSAGES');
let lang = null;
if (pos >= 1) {
lang = langs[pos - 1].trim();
}
if (lang && data.languages.indexOf(lang) == -1) {
data.languages.push(lang);
}
cb();
}
else {
cb();
}
}
stream.resume();
})
.on('error', (err) => {
console.error(err);
callback(err);
})
.on('finish', () => {
if (icon && data.iconpath) {
extractIcon(fileData, data, callback);
}
else {
callback(null, data);
}
});
}
function parseControl(control, fileData, icon, callback) {
const data = {
apps: [],
architecture: 'all',
description: '',
framework: null,
icon: null,
iconpath: null,
maintainer: null,
maintainerEmail: null,
name: null,
permissions: [], // TODO add read/write paths
title: null,
types: [],
urls: [],
version: null,
installedSize: null,
webappInject: false,
webappProperties: {},
languages: [],
files: [],
};
streamifier.createReadStream(control)
.pipe(zlib.Unzip())
.pipe(tarstream.extract())
.on('entry', (header, stream, cb) => {
if (header.name == './manifest') {
let manifest = '';
stream.on('data', (fdata) => {
manifest += fdata.toString();
});
stream.on('end', () => {
if (utils.isJson(manifest)) {
data.manifest = JSON.parse(manifest);
if (!data.manifest.hooks || Object.keys(data.manifest.hooks).length === 0) {
cb('Manifest file does not have any hooks in it');
}
else {
Object.keys(data.manifest.hooks).forEach((name) => {
const hook = data.manifest.hooks[name];
const app = new App(name, hook);
if (hook.desktop) {
app.type = 'app';
}
else if (hook.scope) {
app.type = 'scope';
}
else if (hook['push-helper']) {
app.type = 'push';
}
data.apps.push(app);
});
}
cb();
}
else {
cb('Manifest file is not in a json format');
}
});
}
else {
cb();
}
stream.resume();
})
.on('error', (err) => {
callback(err);
})
.on('finish', () => {
let maintainerEmail = '';
let maintainer = '';
if (data.manifest.maintainer) {
const match = data.manifest.maintainer.match(/<.*>/);
if (match && match.length == 1) {
maintainerEmail = match[0].replace('<', '').replace('>', '').trim();
}
maintainer = data.manifest.maintainer.replace(/<.*>/, '').trim();
try {
// Ensure that special characters are properly encoded
maintainer = utf8.decode(maintainer);
}
catch (e) {
if (
e.message != 'Invalid UTF-8 detected' &&
e.message != 'Invalid continuation byte' &&
e.message != 'Invalid byte index'
) {
throw e;
}
}
}
if (data.manifest.architecture) {
data.architecture = data.manifest.architecture;
}
data.description = data.manifest.description;
data.framework = data.manifest.framework;
data.maintainer = maintainer;
data.maintainerEmail = maintainerEmail;
data.name = data.manifest.name;
data.title = data.manifest.title;
data.version = data.manifest.version;
if (data.manifest['installed-size']) {
data.installedSize = parseInt(data.manifest['installed-size'], 10);
}
parseData(fileData, data, icon, callback);
});
}
module.exports = function parseClickPackage(filepath, iconOrCallback, callback) {
return new Promise((resolve, reject) => {
let icon = iconOrCallback;
if (typeof icon == 'function' && !callback) {
callback = icon;
icon = false;
}
let dataFile = null;
let controlFile = null;
const archive = new ar.Archive(fs.readFileSync(filepath));
archive.getFiles().forEach((file) => {
if (file.name() == 'data.tar.gz') {
dataFile = file.fileData();
}
else if (file.name() == 'control.tar.gz') {
controlFile = file.fileData();
}
});
if (dataFile === null || controlFile === null) {
reject(new Error('Malformed click package'));
if (callback) {
callback('Malformed click package');
}
}
else {
parseControl(controlFile, dataFile, icon, (err, data) => {
if (err) {
reject(err);
if (callback) {
callback(err);
}
}
else if (!data) {
reject(new Error('Control data is undefined'));
if (callback) {
callback('Control data is undefined');
}
}
else {
data.apps.forEach((app) => {
if (data.types.indexOf(app.type) == -1) {
data.types.push(app.type);
}
if (Object.keys(app.contentHub).length > 0) {
app.features.push('content_hub');
}
if (Object.keys(app.urlDispatcher).length > 0) {
app.features.push('url_dispatcher');
}
if (Object.keys(app.pushHelper).length > 0) {
app.features.push('push_helper');
}
if (Object.keys(app.accountService).length > 0) {
app.features.push('account_service');
}
if (app.type == 'webapp') {
app.webappProperties = data.webappProperties;
app.webappInject = data.webappInject;
}
if (app.apparmor && app.apparmor.policy_groups) {
data.permissions = data.permissions.concat(app.apparmor.policy_groups.filter((permission) => {
return data.permissions.indexOf(permission) < 0;
}));
}
if (app.apparmor && app.apparmor.template == 'unconfined') {
data.permissions.push('unconfined');
}
if (app.urlDispatcher && Array.isArray(app.urlDispatcher)) {
app.urlDispatcher.forEach((ud) => {
let url = '';
if (ud.protocol) {
url = `${ud.protocol}://`;
if (ud['domain-suffix']) {
url += ud['domain-suffix'];
}
if (data.urls.indexOf(url) == -1) {
data.urls.push(url);
}
}
});
}
// Deduplicate imports
const qmlImportMatch = [];
const qmlImports = [];
app.qmlImports.forEach((qmlImport) => {
const key = qmlImport.module + qmlImport.version;
if (!qmlImportMatch.includes(key)) {
qmlImportMatch.push(key);
qmlImports.push(qmlImport);
}
});
app.qmlImports = qmlImports;
});
delete data.iconpath;
delete data.manifest;
delete data.webappInject;
delete data.webappProperties;
resolve(data);
if (callback) {
callback(null, data);
}
}
});
}
});
};