A Browser-Based File Archiver with Encryption
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>
