55 *
66 * Copyright Oxide Computer Company
77 */
8+ import { skipToken , useQuery } from '@tanstack/react-query'
89import cn from 'classnames'
910import { filesize } from 'filesize'
1011import pMap from 'p-map'
@@ -22,6 +23,7 @@ import {
2223} from '@oxide/api'
2324import {
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'
4042import { anySignal } from '~/util/abort'
4143import { readBlobAsBase64 } from '~/util/file'
4244import { invariant } from '~/util/invariant'
45+ import { links } from '~/util/links'
4346import { pb } from '~/util/path-builder'
4447import { 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+ }
0 commit comments