From 4aa8090a56f6ece4a3a0425a378ec438c38629df Mon Sep 17 00:00:00 2001 From: Felipe Lalanne Date: Tue, 5 Jan 2021 18:37:53 -0300 Subject: [PATCH] Add support for `BALENA_HOST_SPLASH_IMAGE` config Setting this this variable to a base64 encoded string will replace the splash image on the device by rewriting `/mnt/boot/splash/balena-logo.png`. This will also make a copy of the default balena logo so the splash can be restored if the variable is removed. Change-type: minor Signed-off-by: Felipe Lalanne --- src/config/backends/backend.ts | 2 +- src/config/backends/index.ts | 2 + src/config/backends/splash-image.ts | 206 +++++++++++++++++++ src/device-config.ts | 8 +- src/lib/fs-utils.ts | 20 +- test/12-device-config.spec.ts | 149 ++++++++++++++ test/16-config-utils.spec.ts | 13 +- test/43-splash-image.spec.ts | 306 ++++++++++++++++++++++++++++ 8 files changed, 696 insertions(+), 10 deletions(-) create mode 100644 src/config/backends/splash-image.ts create mode 100644 test/43-splash-image.spec.ts diff --git a/src/config/backends/backend.ts b/src/config/backends/backend.ts index 0abdd9bd..45aa0375 100644 --- a/src/config/backends/backend.ts +++ b/src/config/backends/backend.ts @@ -12,7 +12,7 @@ export const bootMountPoint = `${constants.rootMountPoint}${constants.bootMountP export async function remountAndWriteAtomic( file: string, - data: string, + data: string | Buffer, ): Promise { // Here's the dangerous part: await child_process.exec( diff --git a/src/config/backends/index.ts b/src/config/backends/index.ts index ddf46509..40436630 100644 --- a/src/config/backends/index.ts +++ b/src/config/backends/index.ts @@ -5,6 +5,7 @@ import { ExtraUEnv } from './extra-uEnv'; import { ConfigTxt } from './config-txt'; import { ConfigFs } from './config-fs'; import { Odmdata } from './odmdata'; +import { SplashImage } from './splash-image'; export const allBackends = [ new Extlinux(), @@ -12,6 +13,7 @@ export const allBackends = [ new ConfigTxt(), new ConfigFs(), new Odmdata(), + new SplashImage(), ]; export function matchesAnyBootConfig(envVar: string): boolean { diff --git a/src/config/backends/splash-image.ts b/src/config/backends/splash-image.ts new file mode 100644 index 00000000..6fa9a587 --- /dev/null +++ b/src/config/backends/splash-image.ts @@ -0,0 +1,206 @@ +import * as Bluebird from 'bluebird'; +import * as _ from 'lodash'; +import { fs } from 'mz'; +import * as path from 'path'; + +import * as constants from '../../lib/constants'; +import log from '../../lib/supervisor-console'; +import { + bootMountPoint, + ConfigBackend, + ConfigOptions, + remountAndWriteAtomic, +} from './backend'; + +export class SplashImage extends ConfigBackend { + private static readonly BASEPATH = path.join(bootMountPoint, 'splash'); + private static readonly DEFAULT = path.join( + SplashImage.BASEPATH, + 'balena-logo-default.png', + ); + private static readonly FILENAMES = ['balena-logo.png', 'resin-logo.png']; + private static readonly PREFIX = `${constants.hostConfigVarPrefix}SPLASH_`; + private static readonly CONFIGS = ['image']; + private static readonly DATA_URI_REGEX = /^data:(.+);base64,(.*)$/; + + // Check the first 8 bytes of a buffer for a PNG header + // Source: https://github.com/sindresorhus/is-png/blob/master/index.js + private isPng(buffer: Buffer) { + if (!buffer || buffer.length < 8) { + return false; + } + + return ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ); + } + + // Get the file path for the splash image for the underlying + // system + private async getSplashPath(): Promise { + // TODO: this is not perfect, if the file was supposed to be + // resin-logo.png and for some reason is not present on the folder + // the supervisor will try to write new images from the API into + // balena-logo.png to no effect. Ideally this should be configurable + // as a constant that is provided on supervisor container launch. + const [ + // If no logo is found, assume the file is `balena-logo.png` + splashFile = path.join(SplashImage.BASEPATH, 'balena-logo.png'), + ] = ( + await Bluebird.resolve(fs.readdir(SplashImage.BASEPATH)) + // Read the splash dir (will throw if the path does not exist) + // And filter valid filenames + .filter((filename) => SplashImage.FILENAMES.includes(filename)) + ) + // Sort by name, so in case both files are defined, balena-logo will + // be chosen + .sort() + // Convert to full path + .map((filename) => path.join(SplashImage.BASEPATH, filename)); + + return splashFile; + } + + // Returns the base64 contents of the splash image + private async readSplashImage(where?: string): Promise { + // Read from defaultPath unless where is defined + where = where ?? (await this.getSplashPath()); + + // read the image file... + return (await fs.readFile(where)).toString('base64'); + } + + // Write a splash image provided as a base64 string + private async writeSplashImage(image: string, where?: string): Promise { + // Write to defaultPath unless where is defined + where = where ?? (await this.getSplashPath()); + + const buffer = Buffer.from(image, 'base64'); + if (this.isPng(buffer)) { + // Write the buffer to the given location + await remountAndWriteAtomic(where, buffer); + } else { + throw new Error('Splash image should be a base64 encoded PNG image'); + } + } + + private stripPrefix(name: string): string { + if (!name.startsWith(SplashImage.PREFIX)) { + return name; + } + return name.substr(SplashImage.PREFIX.length); + } + + public createConfigVarName(name: string): string { + return `${SplashImage.PREFIX}${name}`; + } + + public async getBootConfig(): Promise { + try { + const defaultImg = await this.readSplashImage(SplashImage.DEFAULT); + const img = await this.readSplashImage(); + + // If the image is the same as the default image + // return nothing + if (img !== defaultImg) { + return { + image: `data:image/png;base64,${img}`, + }; + } + } catch (e) { + log.warn('Failed to read splash image:', e); + } + return {}; + } + + public async initialise(): Promise { + try { + await super.initialise(); + + // The default boot image file has already + // been created + if (await fs.exists(SplashImage.DEFAULT)) { + return this; + } + + // read the existing image file... + const image = await this.readSplashImage(); + + // write the image to the DEFAULT path + await this.writeSplashImage(image, SplashImage.DEFAULT); + + log.success('Initialised splash image backend'); + } catch (error) { + log.warn('Could not initialise splash image backend', error); + } + return this; + } + + public ensureRequiredConfig(_deviceType: string, conf: ConfigOptions) { + // If the value from the cloud is empty, it is the same as no definition + if ( + !_.isUndefined(conf.image) && + _.isEmpty((conf.image as string).trim()) + ) { + delete conf.image; + } + return conf; + } + + public isSupportedConfig(name: string): boolean { + return SplashImage.CONFIGS.includes(this.stripPrefix(name).toLowerCase()); + } + + public isBootConfigVar(name: string): boolean { + return SplashImage.CONFIGS.includes(this.stripPrefix(name).toLowerCase()); + } + + public async matches(_deviceType: string): Promise { + // all device types + return true; + } + + public processConfigVarName(name: string): string { + return this.stripPrefix(name).toLowerCase(); + } + + public processConfigVarValue( + _name: string, + value: string, + ): string | string[] { + // check data url regex + const matches = value.match(SplashImage.DATA_URI_REGEX); + if (!_.isNull(matches)) { + const [, media, data] = matches; + const [type] = media.split(';'); // discard mediatype parameters + + return `data:${type};base64,${data}`; + } + + if (!_.isEmpty(value.trim())) { + // Assume data is base64 encoded. If is not, setBootConfig will fail + return `data:image/png;base64,${value}`; + } + return value.trim(); + } + + public async setBootConfig(opts: ConfigOptions): Promise { + // If no splash image is defined, revert to the default splash image + const value = !_.isEmpty(opts.image) + ? (opts.image as string) + : await this.readSplashImage(SplashImage.DEFAULT); + + // If it is a data URI get only the data part + const [, image] = value.startsWith('data:') ? value.split(',') : [, value]; + + // Rewrite the splash image + await this.writeSplashImage(image); + } +} diff --git a/src/device-config.ts b/src/device-config.ts index 760992c0..b67d0822 100644 --- a/src/device-config.ts +++ b/src/device-config.ts @@ -321,9 +321,11 @@ export function formatConfigKeys(conf: { confFromLegacyNamespace, noNamespaceConf, ); - return _.pickBy(confWithoutNamespace, (_v, k) => { - return _.includes(validKeys, k) || matchesAnyBootConfig(k); - }); + + return _.pickBy( + confWithoutNamespace, + (_v, k) => _.includes(validKeys, k) || matchesAnyBootConfig(k), + ); } export function getDefaults() { diff --git a/src/lib/fs-utils.ts b/src/lib/fs-utils.ts index 523f9e4d..7194b706 100644 --- a/src/lib/fs-utils.ts +++ b/src/lib/fs-utils.ts @@ -1,18 +1,28 @@ import * as Bluebird from 'bluebird'; +import * as _ from 'lodash'; import { fs } from 'mz'; import * as Path from 'path'; import * as constants from './constants'; import { ENOENT } from './errors'; -export function writeAndSyncFile(path: string, data: string): Bluebird { +export function writeAndSyncFile( + path: string, + data: string | Buffer, +): Bluebird { return Bluebird.resolve(fs.open(path, 'w')).then((fd) => { - fs.write(fd, data, 0, 'utf8') - .then(() => fs.fsync(fd)) - .then(() => fs.close(fd)); + _.isString(data) + ? fs.write(fd, data, 0, 'utf8') + : fs + .write(fd, data, 0, data.length) + .then(() => fs.fsync(fd)) + .then(() => fs.close(fd)); }); } -export function writeFileAtomic(path: string, data: string): Bluebird { +export function writeFileAtomic( + path: string, + data: string | Buffer, +): Bluebird { return Bluebird.resolve(writeAndSyncFile(`${path}.new`, data)).then(() => fs.rename(`${path}.new`, path), ); diff --git a/test/12-device-config.spec.ts b/test/12-device-config.spec.ts index c5aa613f..472706e1 100644 --- a/test/12-device-config.spec.ts +++ b/test/12-device-config.spec.ts @@ -10,6 +10,7 @@ import { Extlinux } from '../src/config/backends/extlinux'; import { ConfigTxt } from '../src/config/backends/config-txt'; import { Odmdata } from '../src/config/backends/odmdata'; import { ConfigFs } from '../src/config/backends/config-fs'; +import { SplashImage } from '../src/config/backends/splash-image'; import * as constants from '../src/lib/constants'; import * as config from '../src/config'; @@ -19,6 +20,7 @@ const extlinuxBackend = new Extlinux(); const configTxtBackend = new ConfigTxt(); const odmdataBackend = new Odmdata(); const configFsBackend = new ConfigFs(); +const splashImageBackend = new SplashImage(); describe('Device Backend Config', () => { let logSpy: SinonSpy; @@ -524,4 +526,151 @@ describe('Device Backend Config', () => { ).to.equal(false); }); }); + + describe('Boot splash image', () => { + const defaultLogo = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/wQDLA+84AAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg=='; + const png = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII='; + const uri = `data:image/png;base64,${png}`; + + beforeEach(() => { + // Setup stubs + stub(fsUtils, 'writeFileAtomic').resolves(); + stub(child_process, 'exec').resolves(); + }); + + afterEach(() => { + // Restore stubs + (fsUtils.writeFileAtomic as SinonStub).restore(); + (child_process.exec as SinonStub).restore(); + }); + + it('should correctly write to resin-logo.png', async () => { + // Devices with balenaOS < 2.51 use resin-logo.png + stub(fs, 'readdir').resolves(['resin-logo.png']); + + const current = {}; + const target = { + HOST_SPLASH_image: png, + }; + + // This should work with every device type, but testing on a couple + // of options + expect( + deviceConfig.bootConfigChangeRequired( + splashImageBackend, + current, + target, + 'fincm3', + ), + ).to.equal(true); + + await deviceConfig.setBootConfig(splashImageBackend, target); + + expect(child_process.exec).to.be.calledOnce; + expect(logSpy).to.be.calledTwice; + expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success'); + expect(fsUtils.writeFileAtomic).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/resin-logo.png', + ); + + // restore the stub + (fs.readdir as SinonStub).restore(); + }); + + it('should correctly write to balena-logo.png', async () => { + // Devices with balenaOS >= 2.51 use balena-logo.png + stub(fs, 'readdir').resolves(['balena-logo.png']); + + const current = {}; + const target = { + HOST_SPLASH_image: png, + }; + + // This should work with every device type, but testing on a couple + // of options + expect( + deviceConfig.bootConfigChangeRequired( + splashImageBackend, + current, + target, + 'raspberrypi4-64', + ), + ).to.equal(true); + + await deviceConfig.setBootConfig(splashImageBackend, target); + + expect(child_process.exec).to.be.calledOnce; + expect(logSpy).to.be.calledTwice; + expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success'); + expect(fsUtils.writeFileAtomic).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + ); + + // restore the stub + (fs.readdir as SinonStub).restore(); + }); + + it('should correctly write to balena-logo.png if no default logo is found', async () => { + // Devices with balenaOS >= 2.51 use balena-logo.png + stub(fs, 'readdir').resolves([]); + + const current = {}; + const target = { + HOST_SPLASH_image: png, + }; + + // This should work with every device type, but testing on a couple + // of options + expect( + deviceConfig.bootConfigChangeRequired( + splashImageBackend, + current, + target, + 'raspberrypi3', + ), + ).to.equal(true); + + await deviceConfig.setBootConfig(splashImageBackend, target); + + expect(child_process.exec).to.be.calledOnce; + expect(logSpy).to.be.calledTwice; + expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success'); + expect(fsUtils.writeFileAtomic).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + ); + + // restore the stub + (fs.readdir as SinonStub).restore(); + }); + + it('should correctly read the splash logo if different from the default', async () => { + stub(fs, 'readdir').resolves(['balena-logo.png']); + + const readFileStub: SinonStub = stub(fs, 'readFile').resolves( + Buffer.from(png, 'base64') as any, + ); + readFileStub + .withArgs('test/data/mnt/boot/splash/balena-logo-default.png') + .resolves(Buffer.from(defaultLogo, 'base64') as any); + + expect( + await deviceConfig.getBootConfig(splashImageBackend), + ).to.deep.equal({ + HOST_SPLASH_image: uri, + }); + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo.png', + ); + + // Restore stubs + (fs.readdir as SinonStub).restore(); + (fs.readFile as SinonStub).restore(); + readFileStub.restore(); + }); + }); }); diff --git a/test/16-config-utils.spec.ts b/test/16-config-utils.spec.ts index 313c0979..bc7e56a8 100644 --- a/test/16-config-utils.spec.ts +++ b/test/16-config-utils.spec.ts @@ -8,6 +8,7 @@ import { ExtraUEnv } from '../src/config/backends/extra-uEnv'; import { Extlinux } from '../src/config/backends/extlinux'; import { ConfigTxt } from '../src/config/backends/config-txt'; import { ConfigFs } from '../src/config/backends/config-fs'; +import { SplashImage } from '../src/config/backends/splash-image'; import { ConfigBackend } from '../src/config/backends/backend'; describe('Config Utilities', () => { @@ -16,8 +17,9 @@ describe('Config Utilities', () => { const configStub = stub(config, 'get').resolves('raspberry'); // Get list of backends const devices = await configUtils.getSupportedBackends(); - expect(devices.length).to.equal(1); + expect(devices.length).to.equal(2); expect(devices[0].constructor.name).to.equal('ConfigTxt'); + expect(devices[1].constructor.name).to.equal('SplashImage'); // Restore stub configStub.restore(); // TO-DO: When we have a device that will match for multiple backends @@ -46,6 +48,7 @@ const BACKENDS: Record = { extlinux: new Extlinux(), configtxt: new ConfigTxt(), configfs: new ConfigFs(), + splashImage: new SplashImage(), }; const CONFIGS = { @@ -88,6 +91,14 @@ const CONFIGS = { foobar: 'baz', }, }, + splashImage: { + envVars: { + HOST_SPLASH_image: 'data:image/png;base64,aaa', + }, + bootConfig: { + image: 'data:image/png;base64,aaa', + }, + }, // TO-DO: Config-FS is commented out because it behaves differently and doesn't // add value to the Config Utilities if we make it work but would like to add it // configfs: { diff --git a/test/43-splash-image.spec.ts b/test/43-splash-image.spec.ts new file mode 100644 index 00000000..841c80d9 --- /dev/null +++ b/test/43-splash-image.spec.ts @@ -0,0 +1,306 @@ +import { fs, child_process } from 'mz'; +import { SinonStub, stub } from 'sinon'; + +import { expect } from './lib/chai-config'; +import * as fsUtils from '../src/lib/fs-utils'; +import { SplashImage } from '../src/config/backends/splash-image'; +import log from '../src/lib/supervisor-console'; + +describe('Splash image configuration', () => { + const backend = new SplashImage(); + const defaultLogo = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/wQDLA+84AAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg=='; + const logo = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII='; + const uri = `data:image/png;base64,${logo}`; + let readDirStub: SinonStub; + let readFileStub: SinonStub; + let writeFileAtomicStub: SinonStub; + + beforeEach(() => { + // Setup stubs + writeFileAtomicStub = stub(fsUtils, 'writeFileAtomic').resolves(); + stub(child_process, 'exec').resolves(); + readFileStub = stub(fs, 'readFile').resolves( + Buffer.from(logo, 'base64') as any, + ); + readFileStub + .withArgs('test/data/mnt/boot/splash/balena-logo-default.png') + .resolves(Buffer.from(defaultLogo, 'base64') as any); + readDirStub = stub(fs, 'readdir').resolves(['balena-logo.png']); + }); + + afterEach(() => { + // Restore stubs + writeFileAtomicStub.restore(); + (child_process.exec as SinonStub).restore(); + readFileStub.restore(); + readDirStub.restore(); + }); + + describe('initialise', () => { + it('should make a copy of the existing boot image on initialise if not yet created', async () => { + stub(fs, 'exists').resolves(false); + + // Do the initialization + await backend.initialise(); + + expect(fs.readFile).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + ); + + // Should make a copy + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + Buffer.from(logo, 'base64'), + ); + + (fs.exists as SinonStub).restore(); + }); + + it('should skip initialization if the default image already exists', async () => { + stub(fs, 'exists').resolves(true); + + // Do the initialization + await backend.initialise(); + + expect(fs.exists).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(fs.readFile).to.not.have.been.called; + + (fs.exists as SinonStub).restore(); + }); + + it('should fail initialization if there is no default image on the device', async () => { + stub(fs, 'exists').resolves(false); + readDirStub.resolves([]); + readFileStub.rejects(); + stub(log, 'warn'); + + // Do the initialization + await backend.initialise(); + + expect(readDirStub).to.be.calledOnce; + expect(fs.readFile).to.have.been.calledOnce; + expect(log.warn).to.be.calledOnce; + + (log.warn as SinonStub).restore(); + }); + }); + + describe('getBootConfig', () => { + it('should return an empty object if the current logo matches the default logo', async () => { + readDirStub.resolves(['resin-logo.png']); + + // The default logo resolves to the same value as the current logo + readFileStub + .withArgs('test/data/mnt/boot/splash/balena-logo-default.png') + .resolves(logo); + + expect(await backend.getBootConfig()).to.deep.equal({}); + expect(readDirStub).to.be.calledOnce; + expect(readFileStub).to.be.calledTwice; + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/resin-logo.png', + ); + }); + + it('should read the splash image from resin-logo.png if available', async () => { + readDirStub.resolves(['resin-logo.png']); + + expect(await backend.getBootConfig()).to.deep.equal({ + image: uri, + }); + expect(readDirStub).to.be.calledOnce; + expect(readFileStub).to.be.calledTwice; + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/resin-logo.png', + ); + }); + + it('should read the splash image from balena-logo.png if available', async () => { + readDirStub.resolves(['balena-logo.png']); + + expect(await backend.getBootConfig()).to.deep.equal({ + image: uri, + }); + expect(readDirStub).to.be.calledOnce; + expect(readFileStub).to.be.calledTwice; + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo.png', + ); + }); + + it('should read the splash image from balena-logo.png even if resin-logo.png exists', async () => { + readDirStub.resolves(['balena-logo.png', 'resin-logo.png']); + + expect(await backend.getBootConfig()).to.deep.equal({ + image: uri, + }); + expect(readFileStub).to.be.calledTwice; + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(readFileStub).to.be.calledWith( + 'test/data/mnt/boot/splash/balena-logo.png', + ); + }); + + it('should catch readDir errors', async () => { + stub(log, 'warn'); + readDirStub.rejects(); + + expect(await backend.getBootConfig()).to.deep.equal({}); + expect(readDirStub).to.be.calledOnce; + expect(log.warn).to.be.calledOnce; + + (log.warn as SinonStub).restore(); + }); + + it('should catch readFile errors', async () => { + stub(log, 'warn'); + readDirStub.resolves([]); + readFileStub.rejects(); + + expect(await backend.getBootConfig()).to.deep.equal({}); + expect(log.warn).to.be.calledOnce; + + (log.warn as SinonStub).restore(); + }); + }); + + describe('setBootConfig', () => { + it('should write the given image to resin-logo.png if set', async () => { + readDirStub.resolves(['resin-logo.png']); + + await backend.setBootConfig({ image: uri }); + + expect(readDirStub).to.be.calledOnce; + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/resin-logo.png', + Buffer.from(logo, 'base64'), + ); + }); + + it('should write the given image to balena-logo.png if set', async () => { + readDirStub.resolves(['balena-logo.png']); + + await backend.setBootConfig({ image: uri }); + + expect(readDirStub).to.be.calledOnce; + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + Buffer.from(logo, 'base64'), + ); + }); + + it('should write the given image to balena-logo.png by default', async () => { + readDirStub.resolves([]); + + await backend.setBootConfig({ image: uri }); + + expect(readDirStub).to.be.calledOnce; + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + Buffer.from(logo, 'base64'), + ); + }); + + it('should accept just a base64 as an image', async () => { + readDirStub.resolves(['balena-logo.png']); + + await backend.setBootConfig({ image: logo }); + + expect(readDirStub).to.be.calledOnce; + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + Buffer.from(logo, 'base64'), + ); + }); + + it('should restore balena-logo.png if image arg is unset', async () => { + readDirStub.resolves(['balena-logo.png']); + await backend.setBootConfig({}); + + expect(readDirStub).to.be.calledOnce; + expect(readFileStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + Buffer.from(defaultLogo, 'base64'), + ); + }); + + it('should restore resin-logo.png if image arg is unset', async () => { + readDirStub.resolves(['resin-logo.png']); + await backend.setBootConfig({}); + + expect(readDirStub).to.be.calledOnce; + expect(readFileStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/resin-logo.png', + Buffer.from(defaultLogo, 'base64'), + ); + }); + + it('should restore balena-logo.png if image arg is empty', async () => { + readDirStub.resolves(['balena-logo.png']); + await backend.setBootConfig({ image: '' }); + + expect(readDirStub).to.be.calledOnce; + expect(readFileStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo.png', + Buffer.from(defaultLogo, 'base64'), + ); + }); + + it('should restore resin-logo.png if image arg is empty', async () => { + readDirStub.resolves(['resin-logo.png']); + await backend.setBootConfig({ image: '' }); + + expect(readDirStub).to.be.calledOnce; + expect(readFileStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/balena-logo-default.png', + ); + expect(writeFileAtomicStub).to.be.calledOnceWith( + 'test/data/mnt/boot/splash/resin-logo.png', + Buffer.from(defaultLogo, 'base64'), + ); + }); + + it('should throw if arg is not a valid base64 string', async () => { + expect(backend.setBootConfig({ image: 'somestring' })).to.be.rejected; + expect(writeFileAtomicStub).to.not.be.called; + }); + + it('should throw if image is not a valid PNG file', async () => { + expect(backend.setBootConfig({ image: 'aGVsbG8=' })).to.be.rejected; + expect(writeFileAtomicStub).to.not.be.called; + }); + }); + + describe('isBootConfigVar', () => { + it('Accepts any case variable names', () => { + expect(backend.isBootConfigVar('HOST_SPLASH_IMAGE')).to.be.true; + expect(backend.isBootConfigVar('HOST_SPLASH_image')).to.be.true; + expect(backend.isBootConfigVar('HOST_SPLASH_Image')).to.be.true; + expect(backend.isBootConfigVar('HOST_SPLASH_ImAgE')).to.be.true; + }); + }); +});