Skip to content

Commit dcf09ec

Browse files
benjaminleonarddavid-crespoiliana
authored
Soft validating image upload (#2217)
* Soft validate image block size, compression and bootable markers * Handle bootable CD block size * move file notice component definitions out of render * extract inner functions from useValidateImage * rename function * use useQuery to do the image validation * non-nullable props, rename efiOffset * clean up stuff more * Remove tertiary styling * tweak read file at offset logic * correct logic around bootable CDs --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com> Co-authored-by: iliana etaoin <iliana@oxide.computer>
1 parent c36b3d6 commit dcf09ec

File tree

2 files changed

+172
-19
lines changed

2 files changed

+172
-19
lines changed

app/forms/image-upload.tsx

Lines changed: 170 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { skipToken, useQuery } from '@tanstack/react-query'
89
import cn from 'classnames'
910
import { filesize } from 'filesize'
1011
import pMap from 'p-map'
@@ -22,6 +23,7 @@ import {
2223
} from '@oxide/api'
2324
import {
2425
Error12Icon,
26+
OpenLink12Icon,
2527
Success12Icon,
2628
Unauthorized12Icon,
2729
} from '@oxide/design-system/icons/react'
@@ -40,6 +42,7 @@ import { Spinner } from '~/ui/lib/Spinner'
4042
import { anySignal } from '~/util/abort'
4143
import { readBlobAsBase64 } from '~/util/file'
4244
import { invariant } from '~/util/invariant'
45+
import { links } from '~/util/links'
4346
import { pb } from '~/util/path-builder'
4447
import { GiB, KiB } from '~/util/units'
4548

@@ -477,6 +480,12 @@ export function CreateImageSideModalForm() {
477480

478481
const form = useForm({ defaultValues })
479482
const file = form.watch('imageFile')
483+
const blockSize = form.watch('blockSize')
484+
485+
const { data: imageValidation } = useQuery({
486+
queryKey: ['validateImage', ...(file ? [file.name, file.size, file.lastModified] : [])],
487+
queryFn: file ? () => validateImage(file) : skipToken,
488+
})
480489

481490
return (
482491
<SideModalForm
@@ -545,25 +554,31 @@ export function CreateImageSideModalForm() {
545554
*/}
546555
<TextField name="os" label="OS" control={form.control} required />
547556
<TextField name="version" control={form.control} required />
548-
<RadioField
549-
name="blockSize"
550-
label="Block size"
551-
units="Bytes"
552-
control={form.control}
553-
parseValue={(val) => parseInt(val, 10) as BlockSize}
554-
items={[
555-
{ label: '512', value: 512 },
556-
{ label: '2048', value: 2048 },
557-
{ label: '4096', value: 4096 },
558-
]}
559-
/>
560-
<FileField
561-
id="image-file-input"
562-
name="imageFile"
563-
label="Image file"
564-
required
565-
control={form.control}
566-
/>
557+
<div className="flex w-full flex-col flex-wrap space-y-4">
558+
<RadioField
559+
name="blockSize"
560+
label="Block size"
561+
units="Bytes"
562+
control={form.control}
563+
parseValue={(val) => parseInt(val, 10) as BlockSize}
564+
items={[
565+
{ label: '512', value: 512 },
566+
{ label: '2048', value: 2048 },
567+
{ label: '4096', value: 4096 },
568+
]}
569+
/>
570+
{imageValidation && <BlockSizeNotice {...imageValidation} blockSize={blockSize} />}
571+
</div>
572+
<div className="flex w-full flex-col flex-wrap space-y-4">
573+
<FileField
574+
id="image-file-input"
575+
name="imageFile"
576+
label="Image file"
577+
required
578+
control={form.control}
579+
/>
580+
{imageValidation && <BootableNotice {...imageValidation} />}
581+
</div>
567582
{file && modalOpen && (
568583
<Modal isOpen onDismiss={closeModal} title="Image upload progress">
569584
<Modal.Body className="!p-0">
@@ -640,3 +655,139 @@ export function CreateImageSideModalForm() {
640655
</SideModalForm>
641656
)
642657
}
658+
659+
function BlockSizeNotice({
660+
blockSize,
661+
efiPartOffset,
662+
isBootableCd,
663+
}: {
664+
blockSize: number
665+
efiPartOffset: number
666+
isBootableCd: boolean
667+
}) {
668+
const isEfi = efiPartOffset !== -1
669+
670+
// If the image doesn't look bootable, return null (`BootableNotice` does the work).
671+
if (!isEfi && !isBootableCd) return null
672+
// If we detect `EFI BOOT` and the block size is set correctly return null.
673+
// (This includes hybrid GPT+ISO.)
674+
if (isEfi && blockSize === efiPartOffset) return null
675+
// If we detect only `CD001` and the block size is set correctly return null.
676+
if (!isEfi && isBootableCd && blockSize === 2048) return null
677+
678+
// Block size is set incorrectly. If we detect `EFI BOOT`, always show that warning.
679+
const content = isEfi
680+
? `Detected “EFI PART” marker at offset ${efiPartOffset}, but block size is set to ${blockSize}.`
681+
: 'Bootable CDs typically use a block size of 2048.'
682+
683+
return (
684+
<Message variant="info" title="Block size might be set incorrectly" content={content} />
685+
)
686+
}
687+
688+
function BootableNotice({
689+
efiPartOffset,
690+
isBootableCd,
691+
isCompressed,
692+
}: {
693+
efiPartOffset: number
694+
isBootableCd: boolean
695+
isCompressed: boolean
696+
}) {
697+
// this message should only appear if the image doesn't have a header
698+
// marker we are looking for and does not appear to be compressed
699+
const efiPartOrBootable = efiPartOffset !== -1 || isBootableCd
700+
if (efiPartOrBootable && !isCompressed) return null
701+
702+
const content = (
703+
<div className="flex flex-col space-y-2">
704+
<ul className="ml-4 list-disc">
705+
{!efiPartOrBootable && (
706+
<li>
707+
<div>Bootable markers not found at any block size.</div>
708+
<div>
709+
Expected either “EFI PART” marker at offsets 512 / 2048 / 4096 or “CD001” at
710+
offset 0x8001 (for a bootable CD).
711+
</div>
712+
</li>
713+
)}
714+
{isCompressed && (
715+
<li>
716+
<div>This might be a compressed image.</div>
717+
<div>
718+
Only raw, uncompressed images are supported. Files such as qcow2, vmdk,
719+
img.gz, iso.7z may not work.
720+
</div>
721+
</li>
722+
)}
723+
</ul>
724+
<div>
725+
Learn more about{' '}
726+
<a
727+
target="_blank"
728+
rel="noreferrer"
729+
href={links.preparingImagesDocs}
730+
className="inline-flex items-center underline"
731+
>
732+
preparing images for import
733+
<OpenLink12Icon className="ml-1" />
734+
</a>
735+
</div>
736+
</div>
737+
)
738+
739+
return (
740+
<Message
741+
variant="info"
742+
title="This image might not be bootable"
743+
className="[&>*]:space-y-2"
744+
content={content}
745+
/>
746+
)
747+
}
748+
749+
async function readAtOffset(file: File, offset: number, length: number) {
750+
const reader = new FileReader()
751+
752+
const promise = new Promise<string | undefined>((resolve, reject) => {
753+
reader.onloadend = (e) => {
754+
if (
755+
e.target?.readyState === FileReader.DONE &&
756+
// should always be true because we're using readAsArrayBuffer
757+
e.target.result instanceof ArrayBuffer
758+
) {
759+
resolve(String.fromCharCode(...new Uint8Array(e.target.result)))
760+
return
761+
}
762+
resolve(undefined)
763+
}
764+
765+
reader.onerror = (error) => {
766+
console.error(`Error reading file at offset ${offset}:`, error)
767+
reject(error)
768+
}
769+
})
770+
771+
reader.readAsArrayBuffer(file.slice(offset, offset + length))
772+
return promise
773+
}
774+
775+
async function getEfiPartOffset(file: File) {
776+
const offsets = [512, 2048, 4096]
777+
for (const offset of offsets) {
778+
const isMatch = (await readAtOffset(file, offset, 8)) === 'EFI PART'
779+
if (isMatch) return offset
780+
}
781+
return -1
782+
}
783+
784+
const compressedExts = ['.gz', '.7z', '.qcow2', '.vmdk']
785+
786+
const validateImage = async (file: File) => {
787+
const lowerFileName = file.name.toLowerCase()
788+
return {
789+
efiPartOffset: await getEfiPartOffset(file),
790+
isBootableCd: (await readAtOffset(file, 0x8001, 5)) === 'CD001',
791+
isCompressed: compressedExts.some((ext) => lowerFileName.endsWith(ext)),
792+
}
793+
}

app/util/links.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const links: Record<string, string> = {
1616
'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules',
1717
floatingIpsDocs: 'https://docs.oxide.computer/guides/managing-floating-ips',
1818
imagesDocs: 'https://docs.oxide.computer/guides/creating-and-sharing-images',
19+
preparingImagesDocs:
20+
'https://docs.oxide.computer/guides/creating-and-sharing-images#_preparing_images_for_import',
1921
instancesDocs: 'https://docs.oxide.computer/guides/managing-instances',
2022
keyConceptsIamPolicyDocs:
2123
'https://docs.oxide.computer/guides/key-entities-and-concepts#iam-policy',

0 commit comments

Comments
 (0)