A Browser-Based File Archiver with EncryptionsteemCreated with Sketch.

in #codingyesterday

Secure File Container (SFC) is a standalone browser-based archiving tool that creates and extracts .sfc archives entirely locally. It supports multiple files and directory structures, drag-and-drop input, empty folders, block-based processing, adaptive GZIP compression, optional AES-256-GCM encryption with PBKDF2-SHA256 key derivation, SHA-256 integrity verification, selective extraction, archive testing, and overwrite handling.

The .sfc format is a custom archive format that is not compatible with ZIP, RAR, or other standard archive utilities. SFC archives can only be opened and extracted using SFC Tool.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SFC Tool v1.1</title>
<style>
:root{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#202124;background:#f5f6f8}
*{box-sizing:border-box}body{margin:0}.app{max-width:980px;margin:32px auto;padding:0 18px}
h1{margin:0;font-size:30px}.sub{color:#6b7280;margin:4px 0 22px}.tabs{display:flex;gap:8px;margin-bottom:14px}
button,.btn{border:0;border-radius:9px;padding:10px 15px;font:inherit;cursor:pointer;background:#202124;color:#fff}
button.secondary{background:#70757a}button:disabled{opacity:.45;cursor:not-allowed}.tab{background:#fff;color:#202124;border:1px solid #d7d9dd}.tab.active{background:#202124;color:#fff}
.panel{background:#fff;border:1px solid #dddfe3;border-radius:14px;padding:20px;margin-bottom:18px}.hidden{display:none!important}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:700px){.grid{grid-template-columns:1fr}}
label{display:block;font-weight:650;margin:14px 0 6px}input[type=password],input[type=text],select{width:100%;padding:10px;border:1px solid #c9ccd1;border-radius:8px;font:inherit}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:16px}.check{display:flex;align-items:center;gap:8px;margin-top:15px}.check label{margin:0;font-weight:500}
.list{margin-top:14px;border:1px solid #e0e2e5;border-radius:9px;max-height:300px;overflow:auto}.item{padding:8px 10px;border-bottom:1px solid #eee;font-size:14px}.item:last-child{border-bottom:0}.tree-head{padding:10px 12px;font-weight:650;border-bottom:1px solid #eee}.tree-body{padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:14px;line-height:1.65;white-space:pre}.tree-summary{padding:9px 12px;border-top:1px solid #eee;color:#70757a;font-size:13px}
.status{white-space:pre-wrap;background:#f6f7f8;border-radius:9px;padding:12px;margin-top:14px;word-break:break-word}.warn{color:#9a5a00;font-size:13px;margin-top:6px}.dropzone{margin-top:16px;border:2px dashed #c5c9cf;border-radius:12px;padding:24px;text-align:center;background:#fafbfc;color:#5f6368;transition:.15s}.dropzone.dragover{border-color:#202124;background:#f0f1f3;color:#202124}.dropzone strong{display:block;color:#202124;margin-bottom:4px}.file-input-hidden{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}.file-picker-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-name{color:#5f6368;font-size:14px}
progress{width:100%;height:17px;margin-top:14px}.muted{font-size:13px;color:#70757a}.entry{display:flex;gap:9px;align-items:center;padding:8px 10px;border-bottom:1px solid #eee}.entry span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-row{display:flex;align-items:center;gap:7px;min-height:28px}.tree-row .tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-remove{background:transparent;color:#777;padding:2px 7px;border-radius:6px;font-size:18px}.tree-remove:hover{background:#eee;color:#111}.tree-row.delete-target{background:#f5f6f8;box-shadow:0 1px 5px rgba(0,0,0,.12);border-radius:7px}.pw-wrap{display:flex;gap:7px}.pw-wrap input{flex:1}.eye{background:#70757a;min-width:44px;padding:8px}
</style>
</head>
<body>
<div class="app">
<h1>SFC</h1>
<div class="sub">Secure File Container · Format v1.0 · Tool v1.1</div>
<div class="tabs"><button id="tabCreate" class="tab active">Create</button><button id="tabOpen" class="tab">Open / Extract</button></div>

<section id="createPanel" class="panel">
<h2>Create container</h2>
<div>Everything you add is placed in the root of the container.</div>
<div class="grid">
<div><label>📄 Add files</label><button id="files" type="button" class="secondary">Choose files</button></div>
<div><label>📁 Add folder</label><button id="folder" type="button" class="secondary">Choose folder</button></div>
</div>
<div id="dropZone" class="dropzone">
<strong>Drop files or folders here</strong>
Files and folders will be added to the container.
</div>
<div class="row"><button id="clear" class="secondary">Clear list</button></div>
<div id="createList" class="list hidden"></div>

<div class="grid">
<div><label>📦️ Compression</label><select id="compression"><option value="gzip">Adaptive GZIP</option><option value="none">None</option></select></div>
<div><label>🧊 Block size</label><select id="blockSize"><option value="1048576">1 MiB</option><option value="4194304" selected>4 MiB</option><option value="8388608">8 MiB</option><option value="16777216">16 MiB</option></select></div>
</div>

<div class="check"><input id="encrypt" type="checkbox"><label for="encrypt">Encrypt container with AES-256-GCM</label></div>
<div id="passwordBox" class="hidden">
<div class="grid"><div><label>🔑 Password</label><div class="pw-wrap"><input id="password" type="password"><button id="showPasswords" type="button" class="eye">👁</button></div></div><div><label>🔑 Confirm password</label><input id="confirm" type="password"></div></div>
<div id="pwWarn" class="warn"></div>
</div>
<div class="row"><button id="createBtn">Create .sfc</button><button id="cancelCreate" class="secondary" disabled>Cancel</button></div>
<progress id="createProgress" max="100" value="0"></progress>
<div id="createStatus" class="status">Ready.</div>
</section>

<section id="openPanel" class="panel hidden">
<h2>Open container</h2>
<label>🛡️ SFC file</label>
<div class="file-picker-row">
<input id="sfcFile" class="file-input-hidden" type="file" accept=".sfc,application/octet-stream">
<button id="chooseSfcBtn" type="button" class="secondary">Choose file</button>
<span id="sfcFileName" class="file-name">No file chosen</span>
</div>
<div id="sfcDropZone" class="dropzone">
<strong>Drop SFC file here</strong>
The container will be loaded for opening and extraction.
</div>
<div id="openPasswordBox" class="hidden"><label>🔑 Password</label><div class="pw-wrap"><input id="openPassword" type="password"><button id="showOpenPassword" type="button" class="eye">👁</button></div></div>
<div class="row"><button id="inspectBtn">Open container</button><button id="testBtn" class="secondary" disabled>Test container</button><button id="selectAll" class="secondary" disabled>Select all</button><button id="selectNone" class="secondary" disabled>Select none</button></div>
<div id="entryList" class="list hidden"></div>
<div class="grid"><div><label>Existing files</label><select id="overwritePolicy"><option value="ask">Ask before overwrite</option><option value="skip">Skip existing</option><option value="overwrite">Overwrite existing</option></select></div></div>
<div class="row"><button id="extractBtn" disabled>Extract selected</button><button id="cancelExtract" class="secondary" disabled>Cancel</button></div>
<progress id="extractProgress" max="100" value="0"></progress>
<div id="openStatus" class="status">Ready.</div>
</section>

<div class="muted">All processing is local. Direct streaming output requires File System Access API support.</div>
</div>

<script>
"use strict";

/*
SFC FORMAT v1.0
---------------
Fixed header, 96 bytes:
  0   4   ASCII "SFC1"
  4   2   major u16 LE (=1)
  6   2   minor u16 LE (=0)
  8   4   header size u32 LE (=96)
 12   4   flags u32 LE: bit0 encrypted, bit1 compression available
 16  16   container ID
 32   1   compression ID: 0 none, 1 gzip
 33   1   encryption ID: 0 none, 1 AES-256-GCM
 34   1   KDF ID: 0 none, 1 PBKDF2-SHA256
 35   1   reserved
 36   4   PBKDF2 iterations u32 LE
 40   8   block size u64 LE
 48  16   salt
 64  32   header verifier: SHA-256(header[0..63]) if plain;
          AES-GCM encryption of 16-byte verifier using deterministic header nonce
          when encrypted (32 bytes = ciphertext+tag).

Records:
  ENTRY  marker 0x10
    entryId u64
    type u8 (1 file, 2 directory)
    metadataFlags u8 (bit0 encrypted)
    reserved u16
    metadataLength u64
    metadata bytes
    for files: zero or more BLOCK records, then FILE_END 0x12
  BLOCK marker 0x11
    entryId u64
    blockIndex u64
    flags u8 (bit0 compressed, bit1 encrypted)
    reserved[7]
    originalSize u64
    storedSize u64
    nonce[12] (zeros when plain)
    payload[storedSize]
  FILE_END marker 0x12
    entryId u64
    blockCount u64
    originalSize u64
  INDEX marker 0x20
    flags u8 (bit0 encrypted)
    reserved[7]
    dataLength u64
    nonce[12]
    index payload (JSON UTF-8, optionally AES-GCM)
  END marker 0x7f

Footer, fixed 64 bytes:
  "SFCF" 4
  version major/minor 2+2
  indexOffset u64
  indexLength u64
  recordEndOffset u64
  containerId 16
  SHA-256(footer bytes 0..47) 16-byte prefix

All offsets/sizes in the format are unsigned 64-bit LE.
Metadata/index encryption uses AES-GCM. File blocks have unique random nonces.
*/

const te=new TextEncoder(), td=new TextDecoder();
const MAGIC=te.encode("SFC1"), FMAGIC=te.encode("SFCF");
const HEADER_SIZE=96, FOOTER_SIZE=64;
const ENTRY=0x10, BLOCK=0x11, FILE_END=0x12, INDEX=0x20, END=0x7f;
const TYPE_FILE=1, TYPE_DIR=2;
const KDF_ITER=600000;
let items=[], opened=null, droppedSfcFile=null, createAbort=false, extractAbort=false;

const $=id=>document.getElementById(id);
function u16(n){let b=new Uint8Array(2);new DataView(b.buffer).setUint16(0,n,true);return b}
function u32(n){let b=new Uint8Array(4);new DataView(b.buffer).setUint32(0,n,true);return b}
function u64(n){let b=new Uint8Array(8);new DataView(b.buffer).setBigUint64(0,BigInt(n),true);return b}
function r16(b,o=0){return new DataView(b.buffer,b.byteOffset+o,2).getUint16(0,true)}
function r32(b,o=0){return new DataView(b.buffer,b.byteOffset+o,4).getUint32(0,true)}
function r64(b,o=0){return new DataView(b.buffer,b.byteOffset+o,8).getBigUint64(0,true)}
function cat(...a){let n=a.reduce((s,x)=>s+x.length,0),r=new Uint8Array(n),o=0;for(let x of a){r.set(x,o);o+=x.length}return r}
function rnd(n){let b=new Uint8Array(n);crypto.getRandomValues(b);return b}
function eq(a,b){return a.length===b.length&&a.every((v,i)=>v===b[i])}
function fmt(n){n=Number(n);if(!n)return"0 B";let u=["B","KiB","MiB","GiB","TiB","PiB"],i=Math.min(Math.floor(Math.log(n)/Math.log(1024)),u.length-1);return(n/1024**i).toFixed(i?2:0)+" "+u[i]}
function safePath(p){p=p.replace(/\\/g,"/").replace(/^\/+/,"");let x=p.split("/").filter(Boolean);if(!x.length||x.some(s=>s==="."||s===".."||s.includes("\0")))throw Error("Unsafe path in container.");return x.join("/")}
async function sha256(d){return new Uint8Array(await crypto.subtle.digest("SHA-256",d))}
async function derive(password,salt,iters){let m=await crypto.subtle.importKey("raw",te.encode(password.normalize("NFC")),"PBKDF2",false,["deriveKey"]);return crypto.subtle.deriveKey({name:"PBKDF2",salt,iterations:iters,hash:"SHA-256"},m,{name:"AES-GCM",length:256},false,["encrypt","decrypt"])}
async function enc(d,key,iv,aad){return new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv,additionalData:aad||new Uint8Array()},key,d))}
async function dec(d,key,iv,aad){return new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv,additionalData:aad||new Uint8Array()},key,d))}
async function gzip(d){let s=new Blob([d]).stream().pipeThrough(new CompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())}
async function gunzip(d){let s=new Blob([d]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())}
function headerNonce(cid){return cat(cid.slice(0,8),new Uint8Array([0x48,0x44,0x52,0x31]))}
function metadataAAD(cid,id,type){return cat(MAGIC,cid,u64(id),new Uint8Array([type]))}
function blockAAD(cid,id,bi,orig,flags){return cat(MAGIC,cid,u64(id),u64(bi),u64(orig),new Uint8Array([flags]))}
function indexAAD(cid){return cat(MAGIC,cid,te.encode("INDEX1"))}

class Writer{constructor(w){this.w=w;this.pos=0n}async write(d){await this.w.write(d);this.pos+=BigInt(d.length)}async close(){await this.w.close()}async abort(){try{await this.w.abort()}catch{}}}
class Reader{constructor(f){this.f=f;this.pos=0n}async at(pos,len){let p=Number(pos);if(pos<0n||pos+BigInt(len)>BigInt(this.f.size))throw Error("Unexpected end of SFC container.");return new Uint8Array(await this.f.slice(p,p+len).arrayBuffer())}async read(len){let d=await this.at(this.pos,len);this.pos+=BigInt(len);return d}async byte(){return(await this.read(1))[0]}}

function switchTab(open){$("tabCreate").classList.toggle("active",!open);$("tabOpen").classList.toggle("active",open);$("createPanel").classList.toggle("hidden",open);$("openPanel").classList.toggle("hidden",!open)}
$("tabCreate").onclick=()=>switchTab(false);$("tabOpen").onclick=()=>switchTab(true);
$("encrypt").onchange=()=>$("passwordBox").classList.toggle("hidden",!$("encrypt").checked);
function passwordLength(v){return [...v.normalize("NFC")].length}
$("password").oninput=()=>{let n=passwordLength($("password").value);$("pwWarn").textContent=!n?"":n<4?"Password must contain at least 4 characters.":n<10?"Weak password. A longer password is strongly recommended.":""};
$("showPasswords").onclick=()=>{let t=$("password").type==="password"?"text":"password";$("password").type=t;$("confirm").type=t};
$("showOpenPassword").onclick=()=>{$("openPassword").type=$("openPassword").type==="password"?"text":"password"};

function addFiles(fs,folder=false){
 const existing=new Set(items.map(x=>safePath(x.path)));
 let added=0,skipped=0;
 for(let f of fs){
  let path=safePath(folder?(f.webkitRelativePath||f.name):f.name);
  if(existing.has(path)){skipped++;continue}
  items.push({type:TYPE_FILE,file:f,path,modified:f.lastModified||0});
  existing.add(path);
  added++;
 }
 renderCreate();
 if(skipped){
  $("createStatus").textContent=
   added+" file"+(added===1?"":"s")+" added.\n"+
   skipped+" duplicate file"+(skipped===1?"":"s")+" skipped.";
 }else if(added){
  $("createStatus").textContent=added+" file"+(added===1?"":"s")+" added.";
 }
}
async function chooseFiles(){
 try{
  if(!window.showOpenFilePicker)throw Error("File System Access API is unavailable in this browser.");
  const handles=await window.showOpenFilePicker({
   id:"sfc-add-files",
   multiple:true
  });
  const files=[];
  for(const handle of handles)files.push(await handle.getFile());
  addFiles(files,false);
 }catch(e){
  if(e.name!=="AbortError")$("createStatus").textContent="Error:\n"+e.message;
 }
}

async function collectDirectoryFiles(dirHandle,rootName,pathPrefix="",result=[]){
 const dirPath=pathPrefix?rootName+"/"+pathPrefix:rootName;result.push({type:TYPE_DIR,path:dirPath,modified:0});
 for await(const [name,handle] of dirHandle.entries()){const relative=pathPrefix?pathPrefix+"/"+name:name;if(handle.kind==="file"){const file=await handle.getFile();result.push({type:TYPE_FILE,file,path:rootName+"/"+relative,modified:file.lastModified||0})}else await collectDirectoryFiles(handle,rootName,relative,result)}
 return result;
}

async function chooseFolder(){
 try{
  if(!window.showDirectoryPicker)throw Error("File System Access API is unavailable in this browser.");
  const handle=await window.showDirectoryPicker({
   id:"sfc-add-folder",
   mode:"read"
  });
  const found=await collectDirectoryFiles(handle,handle.name);
  const existing=new Set(items.map(x=>safePath(x.path)));
  let added=0,skipped=0;
  for(const x of found){
   const path=safePath(x.path);
   if(existing.has(path)){skipped++;continue}
   items.push({type:x.type||TYPE_FILE,file:x.file||null,path,modified:x.modified||x.file?.lastModified||0});
   existing.add(path);
   added++;
  }
  renderCreate();
  if(skipped){
   $("createStatus").textContent=
    added+" file"+(added===1?"":"s")+" added.\n"+
    skipped+" duplicate file"+(skipped===1?"":"s")+" skipped.";
  }else if(added){
   $("createStatus").textContent=added+" file"+(added===1?"":"s")+" added.";
  }else{
   $("createStatus").textContent="The selected folder contains no files.";
  }
 }catch(e){
  if(e.name!=="AbortError")$("createStatus").textContent="Error:\n"+e.message;
 }
}

async function collectHandle(handle,prefix,result){
 const path=prefix?prefix+"/"+handle.name:handle.name;
 if(handle.kind==="file"){const file=await handle.getFile();result.push({type:TYPE_FILE,file,path,modified:file.lastModified||0});return}
 result.push({type:TYPE_DIR,path,modified:0});for await(const child of handle.values())await collectHandle(child,path,result);
}
async function collectLegacyEntry(entry,prefix,result){
 const path=prefix?prefix+"/"+entry.name:entry.name;
 if(entry.isFile){await new Promise((resolve,reject)=>entry.file(file=>{result.push({type:TYPE_FILE,file,path,modified:file.lastModified||0});resolve()},reject));return}
 result.push({type:TYPE_DIR,path,modified:0});const reader=entry.createReader();while(true){const batch=await new Promise((resolve,reject)=>reader.readEntries(resolve,reject));if(!batch.length)break;for(const child of batch)await collectLegacyEntry(child,path,result)}
}

async function addDroppedItems(dataTransfer){
 const found=[];
 const dtItems=[...dataTransfer.items].filter(item=>item.kind==="file");

 // Start all handle requests synchronously before awaiting any of them.
 // Otherwise Chromium may allow only the first item in a multi-file drop.
 for(const item of dtItems){
  if(typeof item.getAsFileSystemHandle==="function"){
   try{item._sfcHandle=item.getAsFileSystemHandle()}
   catch{item._sfcHandle=null}
  }
 }

 for(const item of dtItems){
  let handled=false;

  // Preferred Chromium path. Request every dropped handle first.
  // This is important for multi-file drops: getAsFileSystemHandle() must be
  // invoked while the original drop event still has user activation.
  if(typeof item.getAsFileSystemHandle==="function" && item._sfcHandle){
   try{
    const handle=await item._sfcHandle;
    if(handle){
     await collectHandle(handle,"",found);
     handled=true;
    }
   }catch(err){
    console.warn("getAsFileSystemHandle failed; trying fallback.",err);
   }
  }

  // Compatibility fallback for older Chromium implementations.
  if(!handled && typeof item.webkitGetAsEntry==="function"){
   const entry=item.webkitGetAsEntry();
   if(entry){
    await collectLegacyEntry(entry,"",found);
    handled=true;
   }
  }

  // Last fallback for ordinary files only.
  if(!handled){
   const file=item.getAsFile();
   if(file){
    found.push({type:TYPE_FILE,file,path:file.name,modified:file.lastModified||0});
    handled=true;
   }
  }
 }

 const existing=new Set(items.map(x=>safePath(x.path)));
 let added=0,skipped=0;

 for(const x of found){
  const path=safePath(x.path);
  if(existing.has(path)){
   skipped++;
   continue;
  }
  items.push({
   type:x.type||TYPE_FILE,
   file:x.file||null,
   path,
   modified:x.modified||x.file?.lastModified||0
  });
  existing.add(path);
  added++;
 }

 renderCreate();

 if(skipped){
  $("createStatus").textContent=
   added+" file"+(added===1?"":"s")+" added.\n"+
   skipped+" duplicate file"+(skipped===1?"":"s")+" skipped.";
 }else if(added){
  $("createStatus").textContent=
   added+" file"+(added===1?"":"s")+" added.";
 }else{
  $("createStatus").textContent="No files were added.";
 }
}

const dropZone=$("dropZone");

for(const eventName of ["dragenter","dragover"]){
 dropZone.addEventListener(eventName,e=>{
  e.preventDefault();
  e.stopPropagation();
  dropZone.classList.add("dragover");
  if(e.dataTransfer)e.dataTransfer.dropEffect="copy";
 });
}

dropZone.addEventListener("dragleave",e=>{
 e.preventDefault();
 e.stopPropagation();

 // Do not remove the highlight when moving between children of the drop zone.
 if(!dropZone.contains(e.relatedTarget)){
  dropZone.classList.remove("dragover");
 }
});

dropZone.addEventListener("drop",async e=>{
 e.preventDefault();
 e.stopPropagation();
 dropZone.classList.remove("dragover");

 try{
  await addDroppedItems(e.dataTransfer);
 }catch(err){
  console.error(err);
  $("createStatus").textContent="Error:\n"+err.message;
 }
});

// Dropping outside the dedicated zone must never navigate the page.
document.addEventListener("dragover",e=>{
 e.preventDefault();
});

document.addEventListener("drop",e=>{
 if(!dropZone.contains(e.target)){
  e.preventDefault();
 }
});

$("files").onclick=chooseFiles;
$("folder").onclick=chooseFolder;
$("clear").onclick=()=>{items=[];renderCreate();$("createStatus").textContent="Ready."};
function renderCreate(){
 let b=$("createList");b.innerHTML="";b.classList.toggle("hidden",!items.length);if(!items.length)return;
 const root={dirs:new Map(),files:[]};

 for(const x of items){
  const parts=safePath(x.path).split("/");
  let node=root,stop=x.type===TYPE_DIR?parts.length:parts.length-1;
  for(let i=0;i<stop;i++){
   if(!node.dirs.has(parts[i]))node.dirs.set(parts[i],{
    dirs:new Map(),
    files:[],
    full:parts.slice(0,i+1).join("/")
   });
   node=node.dirs.get(parts[i]);
  }
  if(x.type===TYPE_FILE)node.files.push({
   name:parts.at(-1),
   size:x.file.size,
   full:x.path
  });
 }

 let h=document.createElement("div");
 h.className="tree-head";
 h.textContent="Container contents";
 b.appendChild(h);

 let body=document.createElement("div");
 body.style.padding="8px 12px";

 function removePath(path,isDir){
  if(isDir){
   // Remove the directory entry and everything below it.
   items=items.filter(x=>!(x.path===path||x.path.startsWith(path+"/")));
  }else{
   // Remove only this file. Parent directory entries remain, so an
   // explicitly added folder can become an empty folder in the SFC.
   items=items.filter(x=>x.path!==path);
  }
  renderCreate();
  $("createStatus").textContent="Removed:\n"+path;
 }

 function addRemoveButton(row,path,isDir){
  let x=document.createElement("button");
  x.className="tree-remove";
  x.type="button";
  x.textContent="×";
  x.title=isDir?"Remove folder and its contents":"Remove file";
  x.onmouseenter=()=>row.classList.add("delete-target");
  x.onmouseleave=()=>row.classList.remove("delete-target");
  x.onfocus=()=>row.classList.add("delete-target");
  x.onblur=()=>row.classList.remove("delete-target");
  x.onclick=e=>{
   e.preventDefault();
   e.stopPropagation();
   removePath(path,isDir);
  };
  row.appendChild(x);
 }

 function walk(n,d=0){
  for(const [name,v] of [...n.dirs].sort((a,b)=>a[0].localeCompare(b[0]))){
   let r=document.createElement("div");
   r.className="tree-row";
   r.style.paddingLeft=d*20+"px";

   let q=document.createElement("span");
   q.className="tree-name";
   q.textContent="📁 "+name;
   r.appendChild(q);

   addRemoveButton(r,v.full,true);
   body.appendChild(r);
   walk(v,d+1);
  }

  for(const f of [...n.files].sort((a,b)=>a.name.localeCompare(b.name))){
   let r=document.createElement("div");
   r.className="tree-row";
   r.style.paddingLeft=d*20+"px";

   let q=document.createElement("span");
   q.className="tree-name";
   q.textContent="📄 "+f.name+" – "+fmt(f.size);
   r.appendChild(q);

   addRemoveButton(r,f.full,false);
   body.appendChild(r);
  }
 }

 walk(root);
 b.appendChild(body);

 let dirs=new Set(items.filter(x=>x.type===TYPE_DIR).map(x=>x.path));
 for(const x of items.filter(x=>x.type===TYPE_FILE)){
  let p=x.path.split("/");
  for(let i=1;i<p.length;i++)dirs.add(p.slice(0,i).join("/"));
 }

 let files=items.filter(x=>x.type===TYPE_FILE),
     total=files.reduce((a,x)=>a+x.file.size,0),
     sum=document.createElement("div");

 sum.className="tree-summary";
 sum.textContent=files.length+" file"+(files.length===1?"":"s")+" · "+
                 dirs.size+" folder"+(dirs.size===1?"":"s")+" · "+fmt(total);
 b.appendChild(sum);
}

async function makeHeader({encrypted,compression,blockSize,cid,salt,key}){
 let h=new Uint8Array(HEADER_SIZE);h.set(MAGIC,0);h.set(u16(1),4);h.set(u16(0),6);h.set(u32(HEADER_SIZE),8);
 let flags=(encrypted?1:0)|(compression?2:0);h.set(u32(flags),12);h.set(cid,16);h[32]=compression?1:0;h[33]=encrypted?1:0;h[34]=encrypted?1:0;h.set(u32(encrypted?KDF_ITER:0),36);h.set(u64(blockSize),40);h.set(salt,48);
 if(encrypted){let verifier=cat(te.encode("SFC-PASSWORD-OK!"));let v=await enc(verifier,key,headerNonce(cid),h.slice(0,64));h.set(v.slice(0,32),64)}
 else{let hash=await sha256(h.slice(0,64));h.set(hash,64)}
 return h;
}
async function verifyHeader(h,password){
 if(!eq(h.slice(0,4),MAGIC)||r16(h,4)!==1)throw Error("Not a supported SFC v1 container.");
 let flags=r32(h,12), encrypted=!!(flags&1), compression=!!(flags&2), cid=h.slice(16,32), salt=h.slice(48,64), it=r32(h,36), key=null;
 if(flags&~3)throw Error("Unsupported SFC header flags.");
 if(h[32]>1||h[33]>1||h[34]>1)throw Error("Unsupported SFC algorithm identifier.");
 if(encrypted&&(h[33]!==1||h[34]!==1||it<1))throw Error("Invalid encrypted SFC header.");
 if(!encrypted&&(h[33]!==0||h[34]!==0))throw Error("Invalid plain SFC header.");
 if(compression&&h[32]!==1)throw Error("Invalid compression header.");
 if(!compression&&h[32]!==0)throw Error("Invalid compression header.");
 if(encrypted){if(!password)throw Error("Password required.");key=await derive(password,salt,it);try{let p=await dec(h.slice(64,96),key,headerNonce(cid),h.slice(0,64));if(td.decode(p)!=="SFC-PASSWORD-OK!")throw 0}catch{throw Error("Incorrect password or damaged SFC header.")}}
 else{let hash=await sha256(h.slice(0,64));if(!eq(hash.slice(0,32),h.slice(64,96)))throw Error("Damaged SFC header.")}
 return{encrypted,compression,cid,salt,it,key,blockSize:r64(h,40),compressionId:h[32],encryptionId:h[33],kdfId:h[34]};
}

$("cancelCreate").onclick=()=>createAbort=true;
$("createBtn").onclick=async()=>{
 let st=$("createStatus"),pr=$("createProgress"),w=null;
 try{
  if(!items.length)throw Error("Select at least one file.");
  if(!window.showSaveFilePicker)throw Error("Direct streaming output is unavailable in this browser.");
  let encrypted=$("encrypt").checked, compression=$("compression").value==="gzip", blockSize=BigInt($("blockSize").value),password="";
  if(encrypted){password=$("password").value;if(passwordLength(password)<4)throw Error("Password must contain at least 4 characters.");if(password!==$("confirm").value)throw Error("Passwords do not match.")}
  let handle=await showSaveFilePicker({suggestedName:"container.sfc",types:[{description:"Secure File Container",accept:{"application/octet-stream":[".sfc"]}}]});
  w=new Writer(await handle.createWritable());createAbort=false;$("cancelCreate").disabled=false;$("createBtn").disabled=true;
  let cid=rnd(16),salt=encrypted?rnd(16):new Uint8Array(16),key=encrypted?await derive(password,salt,KDF_ITER):null;
  await w.write(await makeHeader({encrypted,compression,blockSize,cid,salt,key}));
  let fileItems=items.filter(x=>x.type===TYPE_FILE),total=fileItems.reduce((a,x)=>a+x.file.size,0),originalTotal=total,done=0,index=[],entryId=1n,seen=new Set(),dirs=new Set(items.filter(x=>x.type===TYPE_DIR).map(x=>safePath(x.path)));
  for(const x of fileItems){let p=safePath(x.path).split("/");for(let i=1;i<p.length;i++)dirs.add(p.slice(0,i).join("/"))}
  for(const path of [...dirs].sort()){let entryOffset=w.pos,meta=te.encode(JSON.stringify({path,modified:0,type:"directory"})),mf=0,nonce=new Uint8Array(12);if(encrypted){nonce=rnd(12);meta=cat(nonce,await enc(meta,key,nonce,metadataAAD(cid,entryId,TYPE_DIR)));mf=1}await w.write(new Uint8Array([ENTRY]));await w.write(u64(entryId));await w.write(new Uint8Array([TYPE_DIR,mf]));await w.write(new Uint8Array(2));await w.write(u64(meta.length));await w.write(meta);index.push({id:String(entryId),type:"directory",path,size:"0",modified:0,offset:String(entryOffset)});entryId++}
  for(let item of fileItems){
   if(createAbort)throw Error("Operation cancelled.");let path=safePath(item.path);if(seen.has(path))continue;seen.add(path);let entryOffset=w.pos,meta=te.encode(JSON.stringify({path,size:String(item.file.size),modified:item.modified,type:"file"})),mf=0,nonce=new Uint8Array(12);if(encrypted){nonce=rnd(12);meta=cat(nonce,await enc(meta,key,nonce,metadataAAD(cid,entryId,TYPE_FILE)));mf=1}
   await w.write(new Uint8Array([ENTRY]));await w.write(u64(entryId));await w.write(new Uint8Array([TYPE_FILE,mf]));await w.write(new Uint8Array(2));await w.write(u64(meta.length));await w.write(meta);
   let off=0,bi=0n,blocks=0n,hashes=[];st.textContent="Processing:\n"+path;
   while(off<item.file.size){if(createAbort)throw Error("Operation cancelled.");let end=Math.min(off+Number(blockSize),item.file.size),raw=new Uint8Array(await item.file.slice(off,end).arrayBuffer());hashes.push([...await sha256(raw)].map(x=>x.toString(16).padStart(2,"0")).join(""));let data=raw,bf=0;if(compression&&raw.length){let z=await gzip(raw);if(z.length<raw.length){data=z;bf|=1}}let bn=new Uint8Array(12);if(encrypted){bf|=2;bn=rnd(12);data=await enc(data,key,bn,blockAAD(cid,entryId,bi,raw.length,bf))}await w.write(new Uint8Array([BLOCK]));await w.write(u64(entryId));await w.write(u64(bi));await w.write(new Uint8Array([bf]));await w.write(new Uint8Array(7));await w.write(u64(raw.length));await w.write(u64(data.length));await w.write(bn);await w.write(data);off=end;done+=raw.length;bi++;blocks++;pr.value=total?done/total*100:100;await new Promise(requestAnimationFrame)}
   await w.write(new Uint8Array([FILE_END]));await w.write(u64(entryId));await w.write(u64(blocks));await w.write(u64(item.file.size));let mh=await sha256(te.encode(hashes.join(""))),hex=[...mh].map(x=>x.toString(16).padStart(2,"0")).join("");index.push({id:String(entryId),type:"file",path,size:String(item.file.size),modified:item.modified,offset:String(entryOffset),sha256:hex,hashMode:"sha256-block-manifest"});entryId++;
  }
  let indexOffset=w.pos,idx=te.encode(JSON.stringify({version:1,entries:index})),iflag=0,inonce=new Uint8Array(12);
  if(encrypted){iflag=1;inonce=rnd(12);idx=await enc(idx,key,inonce,indexAAD(cid))}
  await w.write(new Uint8Array([INDEX,iflag]));await w.write(new Uint8Array(7));await w.write(u64(idx.length));await w.write(inonce);await w.write(idx);
  let indexLength=w.pos-indexOffset;await w.write(new Uint8Array([END]));let recordEnd=w.pos;
  let f=new Uint8Array(FOOTER_SIZE);f.set(FMAGIC,0);f.set(u16(1),4);f.set(u16(0),6);f.set(u64(indexOffset),8);f.set(u64(indexLength),16);f.set(u64(recordEnd),24);f.set(cid,32);let fh=await sha256(f.slice(0,48));f.set(fh.slice(0,16),48);await w.write(f);let finalSize=w.pos;await w.close();w=null;
  let fs=index.filter(e=>e.type==="file").length,ds=index.filter(e=>e.type==="directory").length,cs=Number(finalSize),saved=originalTotal-cs,pct=originalTotal?(saved/originalTotal*100).toFixed(1):"0.0";
  pr.value=100;st.textContent="SFC v1.0 created successfully.\n\nFiles: "+fs+"\nFolders: "+ds+"\nOriginal size: "+fmt(originalTotal)+"\nContainer size: "+fmt(cs)+"\nSaved: "+(saved>=0?fmt(saved):"-"+fmt(-saved))+" ("+pct+"%)\nCompression: "+(compression?"Adaptive GZIP":"None")+"\nEncryption: "+(encrypted?"AES-256-GCM":"None")+"\n\nEntries: "+index.length+"\nContainer ID: "+[...cid].map(x=>x.toString(16).padStart(2,"0")).join("");
 }catch(e){if(w)await w.abort();st.textContent="Error:\n"+e.message}finally{$("cancelCreate").disabled=true;$("createBtn").disabled=false}
};

async function readFooter(file){
 if(file.size<HEADER_SIZE+FOOTER_SIZE)throw Error("File is too small to be an SFC container.");
 let r=new Reader(file),f=await r.at(BigInt(file.size-FOOTER_SIZE),FOOTER_SIZE);if(!eq(f.slice(0,4),FMAGIC))throw Error("SFC footer not found.");
 let hash=await sha256(f.slice(0,48));if(!eq(hash.slice(0,16),f.slice(48,64)))throw Error("Damaged SFC footer.");
 return{indexOffset:r64(f,8),indexLength:r64(f,16),recordEnd:r64(f,24),cid:f.slice(32,48)};
}
async function loadIndex(file,head,key){
 let foot=await readFooter(file);
 if(!eq(foot.cid,head.cid))throw Error("Container ID mismatch.");
 if(foot.indexOffset<BigInt(HEADER_SIZE)||foot.indexOffset>=foot.recordEnd)throw Error("Invalid SFC index offset.");
 if(foot.recordEnd+BigInt(FOOTER_SIZE)!==BigInt(file.size))throw Error("Invalid SFC record end.");

 let r=new Reader(file),pos=foot.indexOffset;
 let fixed=await r.at(pos,29);
 if(fixed[0]!==INDEX)throw Error("SFC index record not found.");

 let fl=fixed[1],len=r64(fixed,9),nonce=fixed.slice(17,29);
 if(fl&~1)throw Error("Unsupported SFC index flags.");
 if(len>BigInt(Number.MAX_SAFE_INTEGER))throw Error("Index is too large for this browser.");
 if(pos+29n+len!==pos+foot.indexLength)throw Error("Invalid SFC index length.");

 let data=await r.at(pos+29n,Number(len));
 if(fl&1){
  if(!key)throw Error("Encrypted index has no decryption key.");
  try{data=await dec(data,key,nonce,indexAAD(head.cid))}
  catch{throw Error("Unable to decrypt SFC index.")}
 }

 let idx;
 try{idx=JSON.parse(td.decode(data))}
 catch{throw Error("Damaged SFC index.");}

 if(!idx||idx.version!==1||!Array.isArray(idx.entries))throw Error("Unsupported or damaged SFC index.");
 if(await r.at(foot.recordEnd-1n,1).then(x=>x[0])!==END)throw Error("SFC end marker not found.");

 for(const e of idx.entries){
  if(!e||!e.id||!e.path||!e.offset)throw Error("Damaged SFC index entry.");
  safePath(e.path);
  if(e.type!=="file"&&e.type!=="directory")throw Error("Unsupported SFC entry type.");
  const off=BigInt(e.offset);
  if(off<BigInt(HEADER_SIZE)||off>=foot.indexOffset)throw Error("Invalid SFC entry offset.");
 }

 return{foot,idx};
}

function setSfcFile(file){
 droppedSfcFile=file||null;
 $("sfcFileName").textContent=file?file.name:"No file chosen";
 opened=null;
 $("extractBtn").disabled=true;
 $("testBtn").disabled=true;
 $("selectAll").disabled=true;
 $("selectNone").disabled=true;
 $("entryList").classList.add("hidden");
 $("openPasswordBox").classList.add("hidden");
 $("openPassword").value="";
 $("openStatus").textContent=file?"Selected:\n"+file.name:"Ready.";
}

$("chooseSfcBtn").onclick=()=>$("sfcFile").click();
$("sfcFile").onchange=e=>{
 const file=e.target.files[0]||null;
 setSfcFile(file);
};

const sfcDropZone=$("sfcDropZone");
for(const eventName of ["dragenter","dragover"]){
 sfcDropZone.addEventListener(eventName,e=>{
  e.preventDefault();
  e.stopPropagation();
  sfcDropZone.classList.add("dragover");
  if(e.dataTransfer)e.dataTransfer.dropEffect="copy";
 });
}
sfcDropZone.addEventListener("dragleave",e=>{
 e.preventDefault();
 e.stopPropagation();
 if(!sfcDropZone.contains(e.relatedTarget))sfcDropZone.classList.remove("dragover");
});
sfcDropZone.addEventListener("drop",e=>{
 e.preventDefault();
 e.stopPropagation();
 sfcDropZone.classList.remove("dragover");
 const files=[...e.dataTransfer.files];
 if(files.length!==1){
  $("openStatus").textContent="Error:\nDrop exactly one SFC file.";
  return;
 }
 const file=files[0];
 if(!file.name.toLowerCase().endsWith(".sfc")){
  $("openStatus").textContent="Error:\nThe dropped file must have the .sfc extension.";
  return;
 }
 $("sfcFile").value="";
 setSfcFile(file);
});

$("inspectBtn").onclick=async()=>{
 let st=$("openStatus");try{
  let file=droppedSfcFile||$("sfcFile").files[0];if(!file)throw Error("Select an SFC container.");
  let r=new Reader(file),h=await r.at(0n,HEADER_SIZE),encrypted=!!(r32(h,12)&1);$("openPasswordBox").classList.toggle("hidden",!encrypted);
  let head=await verifyHeader(h,encrypted?$("openPassword").value:""),x=await loadIndex(file,head,head.key);
  opened={file,head,...x};renderEntries(x.idx.entries||[]);$("extractBtn").disabled=false;$("testBtn").disabled=false;$("selectAll").disabled=false;$("selectNone").disabled=false;
  st.textContent="SFC v1.0 opened.\n\nEntries: "+(x.idx.entries||[]).length+"\nEncryption: "+(head.encrypted?"AES-256-GCM":"None")+"\nCompression: "+(head.compression?"Adaptive GZIP":"None")+"\nSize: "+fmt(file.size);
 }catch(e){opened=null;$("extractBtn").disabled=true;$("testBtn").disabled=true;$("selectAll").disabled=true;$("selectNone").disabled=true;$("entryList").classList.add("hidden");st.textContent="Error:\n"+e.message}
};
function renderEntries(es){
 const b=$("entryList");
 b.innerHTML="";
 b.classList.toggle("hidden",!es.length);
 if(!es.length)return;

 const root={path:"",dirs:new Map(),files:[]};

 for(const e of es){
  const parts=safePath(e.path).split("/");
  let node=root;
  const stop=e.type==="directory"?parts.length:parts.length-1;

  for(let i=0;i<stop;i++){
   const name=parts[i];
   const full=parts.slice(0,i+1).join("/");
   if(!node.dirs.has(name))node.dirs.set(name,{
    path:full,
    dirs:new Map(),
    files:[],
    entry:null
   });
   node=node.dirs.get(name);
  }

  if(e.type==="directory")node.entry=e;
  else node.files.push(e);
 }

 function addRow(name,depth,entry,isDir,fullPath){
  const row=document.createElement("label");
  row.className="entry";
  row.style.paddingLeft=(10+depth*20)+"px";

  const cb=document.createElement("input");
  cb.type="checkbox";
  cb.checked=true;
  cb.dataset.path=fullPath;
  if(entry)cb.dataset.id=entry.id;

  const text=document.createElement("span");
  text.textContent=(isDir?"📁 ":"📄 ")+name+
   (entry&&!isDir?" – "+fmt(entry.size):"");

  row.append(cb,text);
  b.appendChild(row);

  if(isDir){
   cb.onchange=()=>{
    const prefix=fullPath+"/";
    document.querySelectorAll("#entryList input").forEach(x=>{
     if(x.dataset.path===fullPath||x.dataset.path.startsWith(prefix)){
      x.checked=cb.checked;
     }
    });
   };
  }
 }

 function walk(node,depth=0){
  for(const [name,dir] of [...node.dirs].sort((a,b)=>a[0].localeCompare(b[0]))){
   addRow(name,depth,dir.entry,true,dir.path);
   walk(dir,depth+1);
  }

  for(const e of [...node.files].sort((a,b)=>a.path.localeCompare(b.path))){
   addRow(e.path.split("/").at(-1),depth,e,false,safePath(e.path));
  }
 }

 walk(root);
}

$("selectAll").onclick=()=>document.querySelectorAll("#entryList input").forEach(x=>x.checked=true);
$("selectNone").onclick=()=>document.querySelectorAll("#entryList input").forEach(x=>x.checked=false);
$("cancelExtract").onclick=()=>extractAbort=true;

async function getDir(root,parts){
 let d=root;
 for(const p of parts)d=await d.getDirectoryHandle(p,{create:true});
 return d;
}

function selectedEntryIds(){
 return new Set(
  [...document.querySelectorAll("#entryList input:checked")]
   .map(x=>x.dataset.id)
   .filter(Boolean)
 );
}

async function readEntryRecord(e){
 const r=new Reader(opened.file);
 r.pos=BigInt(e.offset);

 if(await r.byte()!==ENTRY)throw Error("Entry marker mismatch: "+e.path);

 const id=r64(await r.read(8));
 const type=await r.byte();
 const mf=await r.byte();
 await r.read(2);
 const ml=r64(await r.read(8));

 if(id!==BigInt(e.id))throw Error("Entry ID mismatch: "+e.path);
 if(type!==TYPE_FILE&&type!==TYPE_DIR)throw Error("Unsupported entry type: "+e.path);
 if(e.type==="file"&&type!==TYPE_FILE)throw Error("Index/record type mismatch: "+e.path);
 if(e.type==="directory"&&type!==TYPE_DIR)throw Error("Index/record type mismatch: "+e.path);
 if(ml>BigInt(Number.MAX_SAFE_INTEGER))throw Error("Metadata is too large: "+e.path);

 let meta=await r.read(Number(ml));

 if(mf&1){
  if(!opened.head.key)throw Error("Encrypted metadata has no decryption key.");
  if(meta.length<12+16)throw Error("Encrypted metadata is truncated: "+e.path);
  const nonce=meta.slice(0,12);
  try{
   meta=await dec(meta.slice(12),opened.head.key,nonce,metadataAAD(opened.head.cid,id,type));
  }catch{
   throw Error("Damaged or unauthenticated metadata: "+e.path);
  }
 }

 let info;
 try{info=JSON.parse(td.decode(meta))}
 catch{throw Error("Damaged metadata: "+e.path)}

 if(safePath(info.path)!==safePath(e.path))throw Error("Index/metadata path mismatch: "+e.path);

 return {r,id,type,info};
}

async function verifyFileData(e,progress=()=>{}){
 const {r,id,type}=await readEntryRecord(e);
 if(type!==TYPE_FILE)throw Error("Not a file entry: "+e.path);

 let bi=0n,written=0n,hashes=[];

 while(true){
  const marker=await r.byte();

  if(marker===FILE_END){
   const eid=r64(await r.read(8));
   const blockCount=r64(await r.read(8));
   const originalSize=r64(await r.read(8));

   if(eid!==id||blockCount!==bi||originalSize!==written)
    throw Error("File end verification failed: "+e.path);

   if(originalSize!==BigInt(e.size))
    throw Error("Index/file size mismatch: "+e.path);

   break;
  }

  if(marker!==BLOCK)throw Error("Invalid block marker: "+e.path);

  const eid=r64(await r.read(8));
  const bix=r64(await r.read(8));
  const flags=await r.byte();
  await r.read(7);
  const originalSize=r64(await r.read(8));
  const storedSize=r64(await r.read(8));
  const nonce=await r.read(12);

  if(eid!==id||bix!==bi)throw Error("Block sequence mismatch: "+e.path);
  if(originalSize>opened.head.blockSize)
   throw Error("Invalid original block size: "+e.path);
  if(storedSize>opened.head.blockSize*2n+1024n*1024n)
   throw Error("Invalid stored block size: "+e.path);
  if(storedSize>BigInt(Number.MAX_SAFE_INTEGER))
   throw Error("Block is too large for this browser: "+e.path);

  let data=await r.read(Number(storedSize));

  if(flags&2){
   if(!opened.head.key)throw Error("Encrypted block has no decryption key.");
   try{
    data=await dec(data,opened.head.key,nonce,blockAAD(opened.head.cid,id,bi,originalSize,flags));
   }catch{
    throw Error("Damaged or unauthenticated data block: "+e.path);
   }
  }

  if(flags&1){
   try{data=await gunzip(data)}
   catch{throw Error("Damaged compressed block: "+e.path)}
  }

  if(BigInt(data.length)!==originalSize)
   throw Error("Decompressed block size mismatch: "+e.path);

  if(e.sha256){
   const bh=await sha256(data);
   hashes.push([...bh].map(x=>x.toString(16).padStart(2,"0")).join(""));
  }

  written+=originalSize;
  bi++;
  progress(data);
 }

 if(e.sha256){
  const manifestHash=await sha256(te.encode(hashes.join("")));
  const hex=[...manifestHash].map(x=>x.toString(16).padStart(2,"0")).join("");
  if(hex!==e.sha256)throw Error("SHA-256 integrity check failed: "+e.path);
 }

 return {blocks:bi,size:written};
}

async function verifyDirectoryEntry(e){
 const {type}=await readEntryRecord(e);
 if(type!==TYPE_DIR)throw Error("Not a directory entry: "+e.path);
}

$("testBtn").onclick=async()=>{
 const st=$("openStatus"),pr=$("extractProgress");
 try{
  if(!opened)throw Error("Open a container first.");

  const entries=opened.idx.entries||[];
  const files=entries.filter(e=>e.type==="file");
  const dirs=entries.filter(e=>e.type==="directory");
  const total=files.reduce((n,e)=>n+Number(e.size),0);
  let done=0,verified=0;

  $("testBtn").disabled=true;
  $("extractBtn").disabled=true;
  pr.value=0;

  for(const e of dirs){
   st.textContent="Testing directory:\n"+e.path;
   await verifyDirectoryEntry(e);
   verified++;
   await new Promise(requestAnimationFrame);
  }

  for(const e of files){
   st.textContent="Testing file:\n"+e.path;
   await verifyFileData(e,data=>{
    done+=data.length;
    pr.value=total?done/total*100:100;
   });
   verified++;
   await new Promise(requestAnimationFrame);
  }

  pr.value=100;
  st.textContent=
   "Container test completed.\n\n"+
   "Verified entries: "+verified+" / "+entries.length+"\n"+
   "Files: "+files.length+"\n"+
   "Folders: "+dirs.length+"\n"+
   "Errors: 0\n\n"+
   "Container is healthy.";
 }catch(e){
  st.textContent="Test failed:\n"+e.message;
 }finally{
  $("testBtn").disabled=!opened;
  $("extractBtn").disabled=!opened;
 }
};

async function fileExists(dir,name){
 try{
  const h=await dir.getFileHandle(name);
  return !!h;
 }catch(e){
  if(e.name==="NotFoundError")return false;
  throw e;
 }
}

$("extractBtn").onclick=async()=>{
 const st=$("openStatus"),pr=$("extractProgress");

 try{
  if(!opened)throw Error("Open a container first.");
  if(!window.showDirectoryPicker)throw Error("Directory output is unavailable in this browser.");

  const selected=selectedEntryIds();
  if(!selected.size)throw Error("Select at least one entry.");

  const root=await showDirectoryPicker({id:"sfc-extract-folder",mode:"readwrite"});
  extractAbort=false;
  $("cancelExtract").disabled=false;
  $("extractBtn").disabled=true;
  $("testBtn").disabled=true;

  const entries=(opened.idx.entries||[]).filter(e=>selected.has(e.id));
  const directories=entries
   .filter(e=>e.type==="directory")
   .sort((a,b)=>safePath(a.path).split("/").length-safePath(b.path).split("/").length);
  const files=entries.filter(e=>e.type==="file");

  const total=files.reduce((n,e)=>n+Number(e.size),0);
  let done=0,extractedFiles=0,createdFolders=0,skippedFiles=0;

  // Explicitly restore directory entries, including empty folders.
  for(const e of directories){
   if(extractAbort)throw Error("Operation cancelled.");
   await verifyDirectoryEntry(e);
   await getDir(root,safePath(e.path).split("/"));
   createdFolders++;
  }

  for(const e of files){
   if(extractAbort)throw Error("Operation cancelled.");

   const path=safePath(e.path);
   const parts=path.split("/");
   const dir=await getDir(root,parts.slice(0,-1));
   const name=parts.at(-1);

   if(await fileExists(dir,name)){
    const policy=$("overwritePolicy").value;

    if(policy==="skip"){
     skippedFiles++;
     done+=Number(e.size);
     pr.value=total?done/total*100:100;
     continue;
    }

    if(policy==="ask"&&!confirm("Overwrite existing file?\n\n"+path)){
     skippedFiles++;
     done+=Number(e.size);
     pr.value=total?done/total*100:100;
     continue;
    }
   }

   const fh=await dir.getFileHandle(name,{create:true});
   const out=await fh.createWritable();

   try{
    const {r,id,type,info}=await readEntryRecord(e);
    if(type!==TYPE_FILE)throw Error("Entry is not a file: "+path);

    let bi=0n,written=0n,hashes=[];

    while(true){
     if(extractAbort)throw Error("Operation cancelled.");

     const marker=await r.byte();

     if(marker===FILE_END){
      const eid=r64(await r.read(8));
      const blockCount=r64(await r.read(8));
      const originalSize=r64(await r.read(8));

      if(eid!==id||blockCount!==bi||originalSize!==written)
       throw Error("File end verification failed: "+path);
      if(originalSize!==BigInt(e.size))
       throw Error("Index/file size mismatch: "+path);
      break;
     }

     if(marker!==BLOCK)throw Error("Invalid block marker: "+path);

     const eid=r64(await r.read(8));
     const bix=r64(await r.read(8));
     const flags=await r.byte();
     await r.read(7);
     const originalSize=r64(await r.read(8));
     const storedSize=r64(await r.read(8));
     const nonce=await r.read(12);

     if(eid!==id||bix!==bi)throw Error("Block sequence mismatch: "+path);
     if(originalSize>opened.head.blockSize)
      throw Error("Invalid original block size: "+path);
     if(storedSize>opened.head.blockSize*2n+1024n*1024n)
      throw Error("Invalid stored block size: "+path);
     if(storedSize>BigInt(Number.MAX_SAFE_INTEGER))
      throw Error("Block is too large for this browser: "+path);

     let data=await r.read(Number(storedSize));

     if(flags&2){
      if(!opened.head.key)throw Error("Encrypted block has no decryption key.");
      try{
       data=await dec(data,opened.head.key,nonce,blockAAD(opened.head.cid,id,bi,originalSize,flags));
      }catch{
       throw Error("Damaged or unauthenticated data block: "+path);
      }
     }

     if(flags&1){
      try{data=await gunzip(data)}
      catch{throw Error("Damaged compressed block: "+path)}
     }

     if(BigInt(data.length)!==originalSize)
      throw Error("Decompressed block size mismatch: "+path);

     if(e.sha256){
      const bh=await sha256(data);
      hashes.push([...bh].map(x=>x.toString(16).padStart(2,"0")).join(""));
     }

     await out.write(data);
     written+=originalSize;
     bi++;
     done+=data.length;

     pr.value=total?done/total*100:100;
     st.textContent="Extracting:\n"+path;
     await new Promise(requestAnimationFrame);
    }

    if(e.sha256){
     const manifestHash=await sha256(te.encode(hashes.join("")));
     const hex=[...manifestHash].map(x=>x.toString(16).padStart(2,"0")).join("");
     if(hex!==e.sha256)throw Error("SHA-256 integrity check failed: "+path);
    }

    await out.close();
    extractedFiles++;
   }catch(err){
    try{await out.abort()}catch{}
    throw err;
   }
  }

  pr.value=100;
  st.textContent=
   "Extraction completed.\n\n"+
   "Files extracted: "+extractedFiles+"\n"+
   "Folders restored: "+createdFolders+
   (skippedFiles?"\nFiles skipped: "+skippedFiles:"");
 }catch(e){
  st.textContent="Error:\n"+e.message;
 }finally{
  $("cancelExtract").disabled=true;
  $("extractBtn").disabled=!opened;
  $("testBtn").disabled=!opened;
 }
};
</script>
</body>
</html>