Update
This commit is contained in:
@@ -18,6 +18,16 @@ import {
|
||||
import { resolveStdio } from './lib/resolve-stdio.js'
|
||||
import { createKernelReplSession } from './lib/repl-session.js'
|
||||
import { createBootSplash } from './lib/boot-splash.js'
|
||||
import {
|
||||
applyGuestEnv,
|
||||
ensureGuestHome,
|
||||
registerIdentity,
|
||||
unlockIdentity,
|
||||
logoutIdentity,
|
||||
saveVaultToDrive,
|
||||
applyLoginKeys
|
||||
} from './lib/identity-session.js'
|
||||
import { HdmsController, runHdmsCli } from './lib/hdms-manager.js'
|
||||
|
||||
const _pkg = packageRootDir(import.meta.url)
|
||||
|
||||
@@ -191,18 +201,25 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
} = await createReadLine()
|
||||
|
||||
const shellEnv = {
|
||||
HOME: '/home/user',
|
||||
HOME: '/home/guest',
|
||||
PATH: '/bin',
|
||||
USER: 'user',
|
||||
PWD: '/home/user',
|
||||
USER: 'guest',
|
||||
LOGNAME: 'guest',
|
||||
PWD: '/home/guest',
|
||||
SHELL: 'bare-sh',
|
||||
HOSTNAME: globalThis.process?.env?.HOSTNAME || 'bare-os',
|
||||
UID: '1000',
|
||||
GID: '1000',
|
||||
GROUP: 'user',
|
||||
UID: '65534',
|
||||
GID: '65534',
|
||||
GROUP: 'guest',
|
||||
BARE_OS_IDENTITY: 'guest',
|
||||
0: 'bare-os'
|
||||
}
|
||||
const vfs = createVfs(disk.drive, disk.personalDrive, shellEnv)
|
||||
/** @type {{ getMounts: () => Map<string, { drive: import('hyperdrive').default, writable: boolean }> }} */
|
||||
const vfsMountRef = { getMounts: () => new Map() }
|
||||
const vfs = createVfs(disk.drive, disk.personalDrive, shellEnv, vfsMountRef)
|
||||
|
||||
const hdmsController = new HdmsController()
|
||||
disk.hdmsController = hdmsController
|
||||
|
||||
let sessionExitCode = 0
|
||||
let forceSessionEnd = false
|
||||
@@ -218,6 +235,34 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
topic: topicKey(),
|
||||
readLine: async () => null,
|
||||
writeScreen: () => {},
|
||||
/**
|
||||
* @param {string[]} argv
|
||||
*/
|
||||
runHdms(argv) {
|
||||
return runHdmsCli(hdmsController, argv, this)
|
||||
},
|
||||
async onIdentityUnlocked() {
|
||||
const raw = globalThis.process?.env?.HYPERSWARM_BOOTSTRAP
|
||||
const bootstrap =
|
||||
typeof raw === 'string' && raw.length
|
||||
? raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null
|
||||
await hdmsController.activate({
|
||||
store,
|
||||
swarm,
|
||||
Hyperdrive,
|
||||
personalDrive: disk.personalDrive,
|
||||
disk,
|
||||
vfsMountRef,
|
||||
bootstrap
|
||||
})
|
||||
},
|
||||
async onIdentityGuest() {
|
||||
await hdmsController.deactivate()
|
||||
},
|
||||
/**
|
||||
* Builtin `exit`, `/bin/exit`, or session end: set exit code and stop readLine (kernel loop).
|
||||
* @param {number} [code=0]
|
||||
@@ -228,9 +273,41 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
forceSessionEnd = true
|
||||
},
|
||||
/** Replaced after createKernelReplSession (see readLine shim). */
|
||||
execLine: async () => 'ok'
|
||||
execLine: async () => 'ok',
|
||||
/**
|
||||
* Unlock persisted account (passphrase). Bins and builtins call this.
|
||||
* @param {string} passphrase
|
||||
*/
|
||||
async applyUnlock(passphrase) {
|
||||
await unlockIdentity(this, passphrase)
|
||||
},
|
||||
/**
|
||||
* Create account and unlock session.
|
||||
* @param {string} passphrase
|
||||
*/
|
||||
async applyRegister(passphrase) {
|
||||
await registerIdentity(this, passphrase)
|
||||
},
|
||||
/**
|
||||
* @param {{ publicKey: Uint8Array, secretKey: Uint8Array }} keys
|
||||
*/
|
||||
async applyLogin(keys) {
|
||||
await applyLoginKeys(this, keys)
|
||||
},
|
||||
/**
|
||||
* @param {{ save?: boolean }} [opts]
|
||||
*/
|
||||
async applyLogout(opts) {
|
||||
await logoutIdentity(this, opts || {})
|
||||
},
|
||||
async saveVault() {
|
||||
await saveVaultToDrive(this)
|
||||
}
|
||||
}
|
||||
|
||||
await applyGuestEnv(ctx)
|
||||
await ensureGuestHome(ctx)
|
||||
|
||||
const session = await createKernelReplSession({
|
||||
stdin: sessionStdin,
|
||||
stdout: sessionStdout,
|
||||
@@ -277,12 +354,11 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SwarmDisk} disk
|
||||
* @param {import('corestore').default} store
|
||||
* @param {import('hyperswarm').default} swarm
|
||||
* @param {ReturnType<typeof createBootSplash>} splash
|
||||
* Network + drive setup only (must finish within boot timeout). Does not run the
|
||||
* interactive kernel — that can take arbitrarily long.
|
||||
* @returns {Promise<Uint8Array>}
|
||||
*/
|
||||
async function bootFromPeers(disk, store, swarm, splash) {
|
||||
async function loadOsFromPeers(disk, store, swarm, splash) {
|
||||
splash.setPhase('Reading MBR from swarm…')
|
||||
splash.log('Loading block 0 (MBR)')
|
||||
const mbr = await disk.read(0)
|
||||
@@ -326,7 +402,7 @@ async function bootFromPeers(disk, store, swarm, splash) {
|
||||
splash.log('Personal drive ready')
|
||||
splash.setPhase('Starting shell…')
|
||||
splash.prepareForKernel()
|
||||
return await executeKernel(disk, store, swarm, initSource)
|
||||
return initSource
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -368,8 +444,8 @@ async function main() {
|
||||
let exitCode = 0
|
||||
try {
|
||||
const msLeft = Math.max(1, deadline - Date.now())
|
||||
const kernelExit = await Promise.race([
|
||||
bootFromPeers(disk, store, swarm, splash),
|
||||
const initSource = await Promise.race([
|
||||
loadOsFromPeers(disk, store, swarm, splash),
|
||||
new Promise((_, rej) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
@@ -382,9 +458,7 @@ async function main() {
|
||||
)
|
||||
)
|
||||
])
|
||||
if (typeof kernelExit === 'number' && Number.isFinite(kernelExit)) {
|
||||
exitCode = kernelExit
|
||||
}
|
||||
exitCode = await executeKernel(disk, store, swarm, initSource)
|
||||
} catch (err) {
|
||||
const msg = err?.message ?? String(err)
|
||||
splash.fail(msg)
|
||||
@@ -395,6 +469,9 @@ async function main() {
|
||||
} finally {
|
||||
/* Stop replication before closing drives — closing Hyperdrive while Protomux streams
|
||||
* are still live can corrupt native heaps under Pear. */
|
||||
try {
|
||||
if (disk.hdmsController) await disk.hdmsController.deactivate()
|
||||
} catch (_) {}
|
||||
try {
|
||||
await swarm.destroy()
|
||||
} catch (_) {}
|
||||
|
||||
@@ -14,6 +14,38 @@ const MAGENTA = '\x1b[35m'
|
||||
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
/** Seconds for one left→right shimmer pass (iPhone-style slide-to-unlock pacing). */
|
||||
const TITLE_SHIMMER_PERIOD_SEC = 7.5
|
||||
/** How wide the bright band is in character positions (Gaussian sigma). */
|
||||
const TITLE_SHIMMER_SIGMA = 2.8
|
||||
const TITLE_BASE_RGB = [58, 62, 72]
|
||||
const TITLE_PEAK_RGB = [238, 240, 245]
|
||||
|
||||
/**
|
||||
* Truecolor title line with a slow moving highlight (no fast hue stepping).
|
||||
* @param {number} tSec
|
||||
*/
|
||||
function formatTitleShimmer(tSec) {
|
||||
const label = ' ◆ BARE-OS ◆'
|
||||
const n = label.length
|
||||
const u = (tSec / TITLE_SHIMMER_PERIOD_SEC) % 1
|
||||
const center = u * (n + 2) - 1
|
||||
|
||||
let out = ''
|
||||
const [br, bg, bb] = TITLE_BASE_RGB
|
||||
const [pr, pg, pb] = TITLE_PEAK_RGB
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dist = i - center
|
||||
const w = Math.exp(-(dist * dist) / (2 * TITLE_SHIMMER_SIGMA * TITLE_SHIMMER_SIGMA))
|
||||
const r = Math.round(br + (pr - br) * w)
|
||||
const g = Math.round(bg + (pg - bg) * w)
|
||||
const b = Math.round(bb + (pb - bb) * w)
|
||||
const ch = label[i]
|
||||
out += `\x1b[38;2;${r};${g};${b}m${BOLD}${ch}`
|
||||
}
|
||||
return out + RESET
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function splashDisabled() {
|
||||
return globalThis.process?.env?.BARE_OS_NO_SPLASH === '1'
|
||||
@@ -67,8 +99,7 @@ export function createBootSplash(stdout, opts = {}) {
|
||||
const bar =
|
||||
GREEN + '█'.repeat(filled) + DIM + '░'.repeat(Math.max(0, barW - filled)) + RESET
|
||||
|
||||
const hue = [36, 35, 34, 33, 32][frame % 5]
|
||||
const title = `\x1b[${hue}m${BOLD} ◆ BARE-OS ◆${RESET}`
|
||||
const title = formatTitleShimmer(el)
|
||||
const sub = `${DIM} network boot · ${(bootLimitMs / 1000).toFixed(0)}s limit${RESET}`
|
||||
|
||||
const logBlock = lines.length
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
*/
|
||||
|
||||
/** @type {readonly string[]} */
|
||||
export const SHELL_BUILTINS = ['cd', 'export', 'exit']
|
||||
export const SHELL_BUILTINS = ['cd', 'export', 'exit', 'login', 'logout']
|
||||
|
||||
const HISTORY_PATH = '/.bare_nsh_history'
|
||||
/** @param {Record<string, unknown>} ctx */
|
||||
function replHistoryDrivePath(ctx) {
|
||||
const u = String(
|
||||
ctx.vfs?.env?.USER ?? (ctx.env && ctx.env.USER) ?? 'guest'
|
||||
).replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
return `/.bare/repl_history_${u}`
|
||||
}
|
||||
const HISTORY_CAP = 1000
|
||||
|
||||
const FLAG_MAP = {
|
||||
@@ -190,11 +196,13 @@ export async function createFishReadLine(ctx, { stdin, stdout, writeScreen }) {
|
||||
return null
|
||||
}
|
||||
|
||||
const historyPath = replHistoryDrivePath(ctx)
|
||||
|
||||
/** @type {Array<{ timestamp: number, command: string }>} */
|
||||
let history = []
|
||||
try {
|
||||
const buf = personalDrive && typeof personalDrive.get === 'function'
|
||||
? await personalDrive.get(HISTORY_PATH)
|
||||
? await personalDrive.get(historyPath)
|
||||
: null
|
||||
if (buf && b4a) {
|
||||
const text = b4a.toString(buf)
|
||||
@@ -215,7 +223,7 @@ export async function createFishReadLine(ctx, { stdin, stdout, writeScreen }) {
|
||||
try {
|
||||
if (personalDrive && typeof personalDrive.put === 'function' && b4a) {
|
||||
await personalDrive.put(
|
||||
HISTORY_PATH,
|
||||
replHistoryDrivePath(ctx),
|
||||
b4a.from(formatHistoryFile(history))
|
||||
)
|
||||
}
|
||||
@@ -251,7 +259,7 @@ export async function createFishReadLine(ctx, { stdin, stdout, writeScreen }) {
|
||||
|
||||
function getPromptBase(isContinuation = false) {
|
||||
let displayPath = vfs.getcwd()
|
||||
const home = env.HOME || '/home/user'
|
||||
const home = env.HOME || '/home/guest'
|
||||
if (displayPath === home) {
|
||||
displayPath = '~'
|
||||
} else if (displayPath.startsWith(home + '/')) {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Hyperdrive Management System: registry on personal drive, extra drives + swarm,
|
||||
* Autopass for invite/pair. Use a static ESM import (not createRequire) so Pear can
|
||||
* trace and stage `autopass`; bare-module require() from pear:// URLs does not resolve node_modules.
|
||||
*/
|
||||
|
||||
import Autopass from 'autopass'
|
||||
import b4a from 'b4a'
|
||||
import idEnc from 'hypercore-id-encoding'
|
||||
|
||||
export const HDMS_REGISTRY_PATH = '/.bare/hdms/registry.json'
|
||||
|
||||
/** @param {string} label */
|
||||
export function assertValidHdmsLabel(label) {
|
||||
if (!label || typeof label !== 'string') throw new Error('Invalid label')
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/.test(label)) {
|
||||
throw new Error(
|
||||
'Label must start with alphanumeric; allowed: . _ - (max 63 chars)'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function randomNsSuffix() {
|
||||
return b4a.toString(b4a.alloc(8), 'hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: string, label: string, mode: 'writable' | 'readonly', key?: string, ns?: string }} HdmsRegistryEntry
|
||||
* @typedef {{ version: 1, drives: HdmsRegistryEntry[] }} HdmsRegistryFile
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {import('hyperdrive').default} personalDrive
|
||||
* @returns {Promise<HdmsRegistryFile>}
|
||||
*/
|
||||
export async function loadHdmsRegistry(personalDrive) {
|
||||
const buf = await personalDrive.get(HDMS_REGISTRY_PATH, { follow: true })
|
||||
if (!buf || buf.length === 0) {
|
||||
return { version: 1, drives: [] }
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(b4a.toString(buf))
|
||||
if (j && j.version === 1 && Array.isArray(j.drives)) return j
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
return { version: 1, drives: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('hyperdrive').default} personalDrive
|
||||
* @param {HdmsRegistryFile} data
|
||||
*/
|
||||
export async function saveHdmsRegistry(personalDrive, data) {
|
||||
const json = JSON.stringify(data, null, 0)
|
||||
await personalDrive.put(HDMS_REGISTRY_PATH, b4a.from(json))
|
||||
}
|
||||
|
||||
export class HdmsController {
|
||||
constructor() {
|
||||
/** @type {import('corestore').default | null} */
|
||||
this.store = null
|
||||
/** @type {import('hyperswarm').default | null} */
|
||||
this.swarm = null
|
||||
/** @type {typeof import('hyperdrive').default | null} */
|
||||
this.Hyperdrive = null
|
||||
/** @type {import('hyperdrive').default | null} */
|
||||
this.personalDrive = null
|
||||
/** @type {import('./swarm-disk.js').SwarmDisk | null} */
|
||||
this.disk = null
|
||||
/** @type {{ getMounts: () => Map<string, { drive: import('hyperdrive').default, writable: boolean }> } | null} */
|
||||
this.vfsMountRef = null
|
||||
/** @type {string[] | null} */
|
||||
this.bootstrap = null
|
||||
|
||||
/** @type {HdmsRegistryFile | null} */
|
||||
this.registry = null
|
||||
/** @type {Map<string, { drive: import('hyperdrive').default, entry: HdmsRegistryEntry, writable: boolean }>} */
|
||||
this.byLabel = new Map()
|
||||
/** @type {import('autopass') | null} */
|
||||
this.autopass = null
|
||||
this.active = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* store: import('corestore').default,
|
||||
* swarm: import('hyperswarm').default,
|
||||
* Hyperdrive: typeof import('hyperdrive').default,
|
||||
* personalDrive: import('hyperdrive').default,
|
||||
* disk: import('./swarm-disk.js').SwarmDisk,
|
||||
* vfsMountRef: { getMounts: () => Map<string, { drive: import('hyperdrive').default, writable: boolean }> },
|
||||
* bootstrap?: string[] | null
|
||||
* }} opts
|
||||
*/
|
||||
async activate(opts) {
|
||||
if (this.active) await this.deactivate()
|
||||
this.store = opts.store
|
||||
this.swarm = opts.swarm
|
||||
this.Hyperdrive = opts.Hyperdrive
|
||||
this.personalDrive = opts.personalDrive
|
||||
this.disk = opts.disk
|
||||
this.vfsMountRef = opts.vfsMountRef
|
||||
const b = opts.bootstrap
|
||||
this.bootstrap =
|
||||
Array.isArray(b) && b.length ? b : parseBootstrapEnv() ?? null
|
||||
|
||||
this.registry = await loadHdmsRegistry(this.personalDrive)
|
||||
this.byLabel.clear()
|
||||
this.disk.auxiliaryDrives = []
|
||||
|
||||
for (const entry of this.registry.drives) {
|
||||
try {
|
||||
await this._openEntry(entry)
|
||||
} catch (err) {
|
||||
console.warn('[hdms] skip drive ' + entry.label + ':', err?.message || err)
|
||||
}
|
||||
}
|
||||
|
||||
this.vfsMountRef.getMounts = () => this.getMountMap()
|
||||
this.active = true
|
||||
}
|
||||
|
||||
async deactivate() {
|
||||
if (this.autopass) {
|
||||
try {
|
||||
await this.autopass.close()
|
||||
} catch (_) {}
|
||||
this.autopass = null
|
||||
}
|
||||
|
||||
for (const { drive, entry } of this.byLabel.values()) {
|
||||
try {
|
||||
if (this.swarm && drive.discoveryKey) {
|
||||
this.swarm.leave(drive.discoveryKey)
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
await drive.close()
|
||||
} catch (_) {}
|
||||
}
|
||||
this.byLabel.clear()
|
||||
if (this.disk) this.disk.auxiliaryDrives = []
|
||||
if (this.vfsMountRef) {
|
||||
this.vfsMountRef.getMounts = () => new Map()
|
||||
}
|
||||
this.registry = null
|
||||
this.active = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HdmsRegistryEntry} entry
|
||||
*/
|
||||
async _openEntry(entry) {
|
||||
const Hyperdrive = this.Hyperdrive
|
||||
const store = this.store
|
||||
const swarm = this.swarm
|
||||
if (!Hyperdrive || !store || !swarm) throw new Error('HDMS not configured')
|
||||
|
||||
let drive
|
||||
if (entry.mode === 'writable' && entry.ns) {
|
||||
const ns = store.namespace(entry.ns, { writable: true })
|
||||
drive = new Hyperdrive(ns)
|
||||
} else if (entry.mode === 'readonly' && entry.key) {
|
||||
const key = idEnc.decode(entry.key)
|
||||
drive = new Hyperdrive(store, key)
|
||||
} else {
|
||||
throw new Error('Bad registry entry')
|
||||
}
|
||||
|
||||
await drive.ready()
|
||||
swarm.join(drive.discoveryKey)
|
||||
const done = drive.findingPeers()
|
||||
swarm.flush().then(done, done)
|
||||
|
||||
if (this.disk) {
|
||||
this.disk.auxiliaryDrives.push(drive)
|
||||
}
|
||||
|
||||
this.byLabel.set(entry.label, {
|
||||
drive,
|
||||
entry,
|
||||
writable: entry.mode === 'writable'
|
||||
})
|
||||
|
||||
for (const peer of this.disk?.peers || []) {
|
||||
try {
|
||||
drive.replicate(peer.mux.stream, { live: true, download: true })
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
getMountMap() {
|
||||
/** @type {Map<string, { drive: import('hyperdrive').default, writable: boolean }>} */
|
||||
const m = new Map()
|
||||
for (const [label, x] of this.byLabel) {
|
||||
m.set(label, { drive: x.drive, writable: x.writable })
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
assertLoggedIn(ctx) {
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
throw new Error('Log in to use Hyperdrive management (hdms)')
|
||||
}
|
||||
if (!this.active) throw new Error('HDMS inactive')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
async list(ctx) {
|
||||
this.assertLoggedIn(ctx)
|
||||
const reg = this.registry
|
||||
if (!reg || !reg.drives.length) {
|
||||
ctx.console.log('(no extra drives)')
|
||||
return
|
||||
}
|
||||
for (const d of reg.drives) {
|
||||
const k = d.key || '(local)'
|
||||
ctx.console.log(`${d.label}\t${d.mode}\t${k}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
*/
|
||||
async create(ctx, label) {
|
||||
this.assertLoggedIn(ctx)
|
||||
assertValidHdmsLabel(label)
|
||||
if (this.byLabel.has(label)) throw new Error('Label already exists')
|
||||
|
||||
const id = randomNsSuffix()
|
||||
const ns = `bare-os-hdms-w-${id}`
|
||||
const nsStore = this.store.namespace(ns, { writable: true })
|
||||
const drive = new this.Hyperdrive(nsStore)
|
||||
await drive.ready()
|
||||
|
||||
const keyStr = idEnc.encode(drive.key)
|
||||
/** @type {HdmsRegistryEntry} */
|
||||
const entry = {
|
||||
id,
|
||||
label,
|
||||
mode: 'writable',
|
||||
ns,
|
||||
key: keyStr
|
||||
}
|
||||
|
||||
this.registry.drives.push(entry)
|
||||
await saveHdmsRegistry(this.personalDrive, this.registry)
|
||||
await this._openEntry(entry)
|
||||
ctx.console.log(`Created ${label} key=${keyStr}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
* @param {string} keyZ32
|
||||
*/
|
||||
async addReadonly(ctx, label, keyZ32) {
|
||||
this.assertLoggedIn(ctx)
|
||||
assertValidHdmsLabel(label)
|
||||
if (this.byLabel.has(label)) throw new Error('Label already exists')
|
||||
idEnc.decode(keyZ32)
|
||||
|
||||
const id = randomNsSuffix()
|
||||
/** @type {HdmsRegistryEntry} */
|
||||
const entry = {
|
||||
id,
|
||||
label,
|
||||
mode: 'readonly',
|
||||
key: keyZ32
|
||||
}
|
||||
this.registry.drives.push(entry)
|
||||
await saveHdmsRegistry(this.personalDrive, this.registry)
|
||||
await this._openEntry(entry)
|
||||
ctx.console.log(`Added readonly ${label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
*/
|
||||
async remove(ctx, label) {
|
||||
this.assertLoggedIn(ctx)
|
||||
const idx = this.registry.drives.findIndex((d) => d.label === label)
|
||||
if (idx < 0) throw new Error('Unknown label: ' + label)
|
||||
|
||||
const entry = this.registry.drives[idx]
|
||||
const open = this.byLabel.get(label)
|
||||
if (open) {
|
||||
try {
|
||||
if (this.swarm && open.drive.discoveryKey) {
|
||||
this.swarm.leave(open.drive.discoveryKey)
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
await open.drive.close()
|
||||
} catch (_) {}
|
||||
if (this.disk?.auxiliaryDrives) {
|
||||
this.disk.auxiliaryDrives = this.disk.auxiliaryDrives.filter(
|
||||
(d) => d !== open.drive
|
||||
)
|
||||
}
|
||||
this.byLabel.delete(label)
|
||||
}
|
||||
|
||||
this.registry.drives.splice(idx, 1)
|
||||
await saveHdmsRegistry(this.personalDrive, this.registry)
|
||||
ctx.console.log('Removed ' + label)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
*/
|
||||
async show(ctx, label) {
|
||||
this.assertLoggedIn(ctx)
|
||||
const e = this.registry.drives.find((d) => d.label === label)
|
||||
if (!e) throw new Error('Unknown label: ' + label)
|
||||
ctx.console.log(JSON.stringify(e, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {boolean} readOnly
|
||||
*/
|
||||
async invite(ctx, readOnly) {
|
||||
this.assertLoggedIn(ctx)
|
||||
await this._ensureAutopass()
|
||||
const inv = await this.autopass.createInvite({ readOnly })
|
||||
ctx.console.log(inv)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} inviteZ32
|
||||
*/
|
||||
async pair(ctx, inviteZ32) {
|
||||
this.assertLoggedIn(ctx)
|
||||
if (!inviteZ32 || typeof inviteZ32 !== 'string') {
|
||||
throw new Error('Usage: hdms pair <invite>')
|
||||
}
|
||||
const pairNs = this.store.namespace('bare-os-hdms-pair-' + randomNsSuffix(), {
|
||||
writable: true
|
||||
})
|
||||
const pairer = Autopass.pair(pairNs, inviteZ32.trim(), {
|
||||
bootstrap: this.bootstrap
|
||||
})
|
||||
const pass = await pairer.finished()
|
||||
await pass.ready()
|
||||
try {
|
||||
const wk = pass.writerKey
|
||||
ctx.console.log(
|
||||
'Paired Autopass. writerKey=' +
|
||||
(wk ? b4a.toString(wk, 'hex').slice(0, 16) + '…' : '?')
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
await pass.close()
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async _ensureAutopass() {
|
||||
if (this.autopass) return
|
||||
const ns = this.store.namespace('bare-os-hdms-autopass', { writable: true })
|
||||
this.autopass = new Autopass(ns, {
|
||||
swarm: this.swarm,
|
||||
replicate: true,
|
||||
bootstrap: this.bootstrap
|
||||
})
|
||||
await this.autopass.ready()
|
||||
attachAutopassReplicateOnce(this.swarm, () => this.autopass?.base)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('hyperswarm').default} swarm
|
||||
* @param {() => unknown} getBase
|
||||
*/
|
||||
function attachAutopassReplicateOnce(swarm, getBase) {
|
||||
const s = /** @type {{ _bareOsHdmsAutopassRepl?: boolean }} */ (swarm)
|
||||
if (s._bareOsHdmsAutopassRepl) return
|
||||
s._bareOsHdmsAutopassRepl = true
|
||||
swarm.on('connection', (socket) => {
|
||||
const base = getBase()
|
||||
if (base && typeof base.replicate === 'function') {
|
||||
try {
|
||||
base.replicate(socket, { live: true })
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseBootstrapEnv() {
|
||||
const raw = globalThis.process?.env?.HYPERSWARM_BOOTSTRAP
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
const parts = raw
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
return parts.length ? parts : null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HdmsController} hdms
|
||||
* @param {string[]} argv
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function runHdmsCli(hdms, argv, ctx) {
|
||||
try {
|
||||
const sub = argv[1]
|
||||
const rest = argv.slice(2)
|
||||
|
||||
if (!sub || sub === 'help' || sub === '--help') {
|
||||
ctx.console.log(
|
||||
'hdms list | create <label> | add <label> <key> | remove <label> | show <label> | invite [--read-only] | pair <invite>'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
await hdms.list(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'create') {
|
||||
await hdms.create(ctx, rest[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
await hdms.addReadonly(ctx, rest[0], rest[1])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
await hdms.remove(ctx, rest[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'show') {
|
||||
await hdms.show(ctx, rest[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'invite') {
|
||||
const ro = rest.includes('--read-only')
|
||||
await hdms.invite(ctx, ro)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'pair') {
|
||||
await hdms.pair(ctx, rest[0])
|
||||
return
|
||||
}
|
||||
|
||||
ctx.console.error('hdms: unknown subcommand (try hdms help)')
|
||||
} catch (e) {
|
||||
ctx.console.error('hdms: ' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* On-disk account v2: Ed25519 (bare-crypto); secret key sealed with
|
||||
* PBKDF2-SHA256 + ChaCha20-Poly1305.
|
||||
* v1 used libsodium — use `login --new` to create a v2 account.
|
||||
*/
|
||||
|
||||
import b4a from 'b4a'
|
||||
import bareCrypto from 'bare-crypto'
|
||||
|
||||
const {
|
||||
generateKeyPair,
|
||||
pbkdf2Sync,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomFillSync,
|
||||
createHash
|
||||
} = bareCrypto
|
||||
|
||||
export const ACCOUNT_MAGIC = new Uint8Array([
|
||||
0x42, 0x41, 0x52, 0x45, 0x4f, 0x53, 0x30, 0x31
|
||||
]) // BAREOS01
|
||||
export const ACCOUNT_VERSION = 2
|
||||
export const ACCOUNT_PATH = '/.bare/account'
|
||||
|
||||
const LEGACY_ACCOUNT_VERSION = 1
|
||||
|
||||
export const ED25519_PUBLIC_KEY_LENGTH = 32
|
||||
export const ED25519_SECRET_KEY_LENGTH = 64
|
||||
|
||||
const SALT_LENGTH = 16
|
||||
const NONCE_LENGTH = 12
|
||||
const TAG_LENGTH = 16
|
||||
/** OWASP-style iteration count for PBKDF2-SHA256 */
|
||||
export const PBKDF2_ITERATIONS = 210_000
|
||||
|
||||
/** @param {ArrayBuffer | ArrayBufferView} view */
|
||||
function u8(view) {
|
||||
if (view instanceof Uint8Array) return view
|
||||
if (view instanceof ArrayBuffer) return new Uint8Array(view)
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
|
||||
export function secureZero(buf) {
|
||||
if (!buf) return
|
||||
u8(buf).fill(0)
|
||||
}
|
||||
|
||||
export function hashPublicKeyForUid(publicKey) {
|
||||
const h = createHash('sha256')
|
||||
h.update(u8(publicKey))
|
||||
const d = u8(h.digest())
|
||||
const n = (d[0] | (d[1] << 8) | (d[2] << 16) | (d[3] << 24)) >>> 0
|
||||
return String(10000 + (n % 50000))
|
||||
}
|
||||
|
||||
/** 32-byte subkey for vault crypto (SHA-256 of signing secret). */
|
||||
export function vaultKeyFromSecret(secretKey) {
|
||||
const h = createHash('sha256')
|
||||
h.update(u8(secretKey))
|
||||
return u8(h.digest())
|
||||
}
|
||||
|
||||
/** Unkeyed SHA-256 for vault blob names (path-stable). */
|
||||
export function hashUtf8Path(pathUtf8) {
|
||||
const h = createHash('sha256')
|
||||
h.update(u8(pathUtf8))
|
||||
return u8(h.digest())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBufferView} key32
|
||||
* @param {ArrayBufferView} plaintext
|
||||
* @returns {Uint8Array} nonce | ciphertext | tag
|
||||
*/
|
||||
export function sealBytes(key32, plaintext) {
|
||||
const nonce = new Uint8Array(NONCE_LENGTH)
|
||||
randomFillSync(nonce)
|
||||
const key = u8(key32)
|
||||
const pt = u8(plaintext)
|
||||
const cipher = createCipheriv('chacha20-poly1305', key, nonce)
|
||||
cipher.update(pt)
|
||||
const ct = u8(cipher.final())
|
||||
const tag = u8(cipher.getAuthTag())
|
||||
const out = new Uint8Array(NONCE_LENGTH + ct.length + TAG_LENGTH)
|
||||
out.set(nonce, 0)
|
||||
out.set(ct, NONCE_LENGTH)
|
||||
out.set(tag, NONCE_LENGTH + ct.length)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBufferView} key32
|
||||
* @param {ArrayBufferView} boxed
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function openBytes(key32, boxed) {
|
||||
const key = u8(key32)
|
||||
const b = u8(boxed)
|
||||
if (b.length < NONCE_LENGTH + TAG_LENGTH + 1) {
|
||||
throw new Error('Invalid sealed blob')
|
||||
}
|
||||
const nonce = b.subarray(0, NONCE_LENGTH)
|
||||
const tag = b.subarray(b.length - TAG_LENGTH)
|
||||
const ct = b.subarray(NONCE_LENGTH, b.length - TAG_LENGTH)
|
||||
const decipher = createDecipheriv('chacha20-poly1305', key, nonce)
|
||||
decipher.update(ct)
|
||||
decipher.setAuthTag(tag)
|
||||
return u8(decipher.final())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | Uint8Array} passphrase
|
||||
* @param {Uint8Array} salt
|
||||
* @param {number} iterations
|
||||
*/
|
||||
function deriveKey(passphrase, salt, iterations) {
|
||||
const pass =
|
||||
typeof passphrase === 'string' ? b4a.from(passphrase, 'utf8') : u8(passphrase)
|
||||
return u8(pbkdf2Sync(pass, u8(salt), iterations, 32, 'sha256'))
|
||||
}
|
||||
|
||||
export function generateEd25519Keypair() {
|
||||
const { publicKey, privateKey } = generateKeyPair('ed25519')
|
||||
return {
|
||||
publicKey: u8(publicKey._key),
|
||||
secretKey: u8(privateKey._key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} passphrase
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function encodeNewAccount(passphrase) {
|
||||
const { publicKey, secretKey } = generateEd25519Keypair()
|
||||
const buf = encodeAccount(passphrase, publicKey, secretKey)
|
||||
secureZero(secretKey)
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} passphrase
|
||||
* @param {Uint8Array} pk
|
||||
* @param {Uint8Array} sk
|
||||
*/
|
||||
export function encodeAccount(passphrase, pk, sk) {
|
||||
const salt = new Uint8Array(SALT_LENGTH)
|
||||
randomFillSync(salt)
|
||||
const subkey = deriveKey(passphrase, salt, PBKDF2_ITERATIONS)
|
||||
const sealed = sealBytes(subkey, u8(sk))
|
||||
secureZero(subkey)
|
||||
|
||||
const iters = PBKDF2_ITERATIONS
|
||||
const out = new Uint8Array(
|
||||
ACCOUNT_MAGIC.length +
|
||||
1 +
|
||||
ED25519_PUBLIC_KEY_LENGTH +
|
||||
SALT_LENGTH +
|
||||
4 +
|
||||
sealed.length
|
||||
)
|
||||
let o = 0
|
||||
out.set(ACCOUNT_MAGIC, o)
|
||||
o += ACCOUNT_MAGIC.length
|
||||
out[o++] = ACCOUNT_VERSION
|
||||
out.set(u8(pk), o)
|
||||
o += ED25519_PUBLIC_KEY_LENGTH
|
||||
out.set(salt, o)
|
||||
o += SALT_LENGTH
|
||||
out[o++] = (iters >>> 24) & 255
|
||||
out[o++] = (iters >>> 16) & 255
|
||||
out[o++] = (iters >>> 8) & 255
|
||||
out[o++] = iters & 255
|
||||
out.set(sealed, o)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} passphrase
|
||||
* @param {Uint8Array} buf
|
||||
* @returns {{ publicKey: Uint8Array, secretKey: Uint8Array }}
|
||||
*/
|
||||
export function decodeAccount(passphrase, buf) {
|
||||
if (
|
||||
!buf ||
|
||||
buf.length <
|
||||
ACCOUNT_MAGIC.length +
|
||||
1 +
|
||||
ED25519_PUBLIC_KEY_LENGTH +
|
||||
SALT_LENGTH +
|
||||
4 +
|
||||
NONCE_LENGTH +
|
||||
TAG_LENGTH +
|
||||
1
|
||||
) {
|
||||
throw new Error('Invalid account file')
|
||||
}
|
||||
for (let i = 0; i < ACCOUNT_MAGIC.length; i++) {
|
||||
if (buf[i] !== ACCOUNT_MAGIC[i]) throw new Error('Invalid account magic')
|
||||
}
|
||||
let o = ACCOUNT_MAGIC.length
|
||||
const ver = buf[o++]
|
||||
if (ver === LEGACY_ACCOUNT_VERSION) {
|
||||
throw new Error(
|
||||
'Account uses legacy crypto (v1). Use: login --new <passphrase> to create a new account'
|
||||
)
|
||||
}
|
||||
if (ver !== ACCOUNT_VERSION) throw new Error('Unsupported account version')
|
||||
|
||||
const pk = buf.subarray(o, o + ED25519_PUBLIC_KEY_LENGTH)
|
||||
o += ED25519_PUBLIC_KEY_LENGTH
|
||||
const salt = buf.subarray(o, o + SALT_LENGTH)
|
||||
o += SALT_LENGTH
|
||||
const iterations =
|
||||
(buf[o] << 24) | (buf[o + 1] << 16) | (buf[o + 2] << 8) | buf[o + 3]
|
||||
o += 4
|
||||
if (iterations < 10000 || iterations > 10_000_000) {
|
||||
throw new Error('Invalid account parameters')
|
||||
}
|
||||
const sealed = buf.subarray(o)
|
||||
|
||||
const subkey = deriveKey(passphrase, salt, iterations)
|
||||
let sk
|
||||
try {
|
||||
sk = openBytes(subkey, sealed)
|
||||
} catch {
|
||||
secureZero(subkey)
|
||||
throw new Error('Wrong passphrase')
|
||||
}
|
||||
secureZero(subkey)
|
||||
|
||||
if (sk.length !== ED25519_SECRET_KEY_LENGTH) {
|
||||
secureZero(sk)
|
||||
throw new Error('Invalid account file')
|
||||
}
|
||||
|
||||
return { publicKey: Uint8Array.from(pk), secretKey: sk }
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Session identity: guest vs unlocked keypair; env + vfs; vault save.
|
||||
*/
|
||||
|
||||
import b4a from 'b4a'
|
||||
import {
|
||||
ACCOUNT_PATH,
|
||||
decodeAccount,
|
||||
encodeAccount,
|
||||
generateEd25519Keypair,
|
||||
hashPublicKeyForUid,
|
||||
hashUtf8Path,
|
||||
secureZero,
|
||||
sealBytes,
|
||||
vaultKeyFromSecret
|
||||
} from './identity-account.js'
|
||||
|
||||
export const GUEST_USER = 'guest'
|
||||
export const GUEST_HOME = '/home/guest'
|
||||
const GUEST_UID = '65534'
|
||||
const GUEST_GID = '65534'
|
||||
|
||||
/** @param {Uint8Array} pk */
|
||||
function displayNameFromPublicKey(pk) {
|
||||
return b4a.toString(pk, 'hex').slice(0, 12)
|
||||
}
|
||||
|
||||
/** @param {Uint8Array} pk */
|
||||
function uidFromPublicKey(pk) {
|
||||
return hashPublicKeyForUid(pk)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function applyGuestEnv(ctx) {
|
||||
const env = ctx.vfs.env
|
||||
env.USER = GUEST_USER
|
||||
env.LOGNAME = GUEST_USER
|
||||
env.HOME = GUEST_HOME
|
||||
env.PWD = GUEST_HOME
|
||||
env.UID = GUEST_UID
|
||||
env.GID = GUEST_GID
|
||||
env.GROUP = GUEST_USER
|
||||
delete env.BARE_OS_PUBLIC_KEY
|
||||
env.BARE_OS_IDENTITY = 'guest'
|
||||
ctx.identity = {
|
||||
state: 'guest',
|
||||
publicKey: null,
|
||||
secretKey: null
|
||||
}
|
||||
try {
|
||||
await ctx.vfs.chdir(GUEST_HOME)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await ctx.onIdentityGuest?.()
|
||||
} catch (e) {
|
||||
ctx.console?.error?.('[bare-os] onIdentityGuest: ' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Uint8Array} publicKey
|
||||
* @param {Uint8Array} secretKey
|
||||
*/
|
||||
export async function applyUnlockedEnv(ctx, publicKey, secretKey) {
|
||||
const env = ctx.vfs.env
|
||||
const name = displayNameFromPublicKey(publicKey)
|
||||
const home = `/home/${name}`
|
||||
env.USER = name
|
||||
env.LOGNAME = name
|
||||
env.HOME = home
|
||||
env.PWD = home
|
||||
const uid = uidFromPublicKey(publicKey)
|
||||
env.UID = uid
|
||||
env.GID = uid
|
||||
env.GROUP = name
|
||||
env.BARE_OS_PUBLIC_KEY = b4a.toString(publicKey, 'hex')
|
||||
env.BARE_OS_IDENTITY = 'unlocked'
|
||||
ctx.identity = {
|
||||
state: 'unlocked',
|
||||
publicKey: Uint8Array.from(publicKey),
|
||||
secretKey: Uint8Array.from(secretKey)
|
||||
}
|
||||
try {
|
||||
await ctx.vfs.chdir(home)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await ctx.onIdentityUnlocked?.()
|
||||
} catch (e) {
|
||||
ctx.console?.error?.('[bare-os] onIdentityUnlocked: ' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export function wipeSecret(ctx) {
|
||||
const sk = ctx.identity?.secretKey
|
||||
if (sk && sk.length) secureZero(sk)
|
||||
if (ctx.identity) ctx.identity.secretKey = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ publicKey: Uint8Array, secretKey: Uint8Array }} keys
|
||||
*/
|
||||
export async function applyLoginKeys(ctx, keys) {
|
||||
wipeSecret(ctx)
|
||||
await applyUnlockedEnv(ctx, keys.publicKey, keys.secretKey)
|
||||
await ensureBareDir(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function ensureBareDir(ctx) {
|
||||
const drive = ctx.personalDrive
|
||||
if (!drive || typeof drive.put !== 'function') return
|
||||
try {
|
||||
await drive.put('/.bare/.keep', b4a.from(''))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function ensureGuestHome(ctx) {
|
||||
await ensureBareDir(ctx)
|
||||
const drive = ctx.personalDrive
|
||||
if (!drive || typeof drive.put !== 'function') return
|
||||
const keep = '/.keep_guest'
|
||||
try {
|
||||
const existing = await drive.get(keep)
|
||||
if (existing) return
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
try {
|
||||
await drive.put(keep, b4a.from(''))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} passphrase
|
||||
*/
|
||||
export async function registerIdentity(ctx, passphrase) {
|
||||
const drive = ctx.personalDrive
|
||||
if (!drive) throw new Error('No personal drive')
|
||||
const existing = await drive.get(ACCOUNT_PATH)
|
||||
if (existing && b4a.from(existing).length > 0) {
|
||||
throw new Error('Account already exists — use: login <passphrase>')
|
||||
}
|
||||
const { publicKey, secretKey } = generateEd25519Keypair()
|
||||
const blob = encodeAccount(passphrase, publicKey, secretKey)
|
||||
await drive.put(ACCOUNT_PATH, blob)
|
||||
await applyUnlockedEnv(ctx, publicKey, secretKey)
|
||||
secureZero(secretKey)
|
||||
await ensureBareDir(ctx)
|
||||
ctx.console.log(`Registered identity ${ctx.vfs.env.USER} (Ed25519)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} passphrase
|
||||
*/
|
||||
export async function unlockIdentity(ctx, passphrase) {
|
||||
const buf = await ctx.personalDrive.get(ACCOUNT_PATH)
|
||||
if (!buf) throw new Error('No account — use: login --new <passphrase>')
|
||||
const { publicKey, secretKey } = decodeAccount(passphrase, b4a.from(buf))
|
||||
wipeSecret(ctx)
|
||||
await applyUnlockedEnv(ctx, publicKey, secretKey)
|
||||
await ensureBareDir(ctx)
|
||||
ctx.console.log(`Unlocked as ${ctx.vfs.env.USER}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ save?: boolean }} [opts]
|
||||
*/
|
||||
export async function logoutIdentity(ctx, opts = {}) {
|
||||
if (
|
||||
opts.save &&
|
||||
ctx.identity?.state === 'unlocked' &&
|
||||
ctx.identity?.secretKey
|
||||
) {
|
||||
await saveVaultToDrive(ctx)
|
||||
}
|
||||
wipeSecret(ctx)
|
||||
await applyGuestEnv(ctx)
|
||||
ctx.console.log('Logged out (guest)')
|
||||
}
|
||||
|
||||
const VAULT_EXCLUDE_PREFIXES = [
|
||||
'bare/',
|
||||
'bare',
|
||||
'.bare/',
|
||||
'.bare',
|
||||
'.vault/',
|
||||
'.vault',
|
||||
'bin/',
|
||||
'bin',
|
||||
'boot/',
|
||||
'boot'
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {string} short path without leading slash
|
||||
*/
|
||||
function shouldVaultSkip(short) {
|
||||
const n = short.replace(/^\//, '')
|
||||
for (const ex of VAULT_EXCLUDE_PREFIXES) {
|
||||
if (n === ex || n.startsWith(ex + '/') || n.startsWith(ex + '\\'))
|
||||
return true
|
||||
}
|
||||
if (n.includes('bare_repl_history') || n.includes('nsh_history')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('hyperdrive').default} drive
|
||||
* @param {string} dir
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function driveReaddirNames(drive, dir) {
|
||||
const names = []
|
||||
try {
|
||||
const stream = drive.readdir(dir)
|
||||
for await (const name of stream) names.push(name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function saveVaultToDrive(ctx) {
|
||||
const id = ctx.identity
|
||||
if (!id?.secretKey || id.state !== 'unlocked') {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
const vaultKey = vaultKeyFromSecret(id.secretKey)
|
||||
|
||||
const drive = ctx.personalDrive
|
||||
/** @type {Record<string, { blob: string }>} */
|
||||
const indexFiles = {}
|
||||
|
||||
/** @param {string} p absolute path on drive e.g. /foo */
|
||||
async function walk(dir) {
|
||||
const names = await driveReaddirNames(drive, dir)
|
||||
for (const name of names) {
|
||||
const p = dir === '/' ? `/${name}` : `${dir}/${name}`
|
||||
const short = p.startsWith('/') ? p.slice(1) : p
|
||||
if (shouldVaultSkip(short)) continue
|
||||
|
||||
const entry = await drive.entry(p, { follow: true })
|
||||
const hasBlob = !!(entry && entry.value && entry.value.blob)
|
||||
if (hasBlob) {
|
||||
const data = await drive.get(p, { follow: true })
|
||||
if (!data) continue
|
||||
const plain = b4a.from(data)
|
||||
const idHash = hashUtf8Path(b4a.from(p, 'utf8'))
|
||||
const blobPath = `/.bare/vault/blobs/${b4a.toString(idHash, 'hex')}`
|
||||
const sealed = sealBytes(vaultKey, plain)
|
||||
await drive.put(blobPath, sealed)
|
||||
indexFiles[p] = { blob: blobPath }
|
||||
} else {
|
||||
await walk(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk('/')
|
||||
|
||||
const indexJson = b4a.from(
|
||||
JSON.stringify({ v: 2, t: Date.now(), files: indexFiles })
|
||||
)
|
||||
const packed = sealBytes(vaultKey, indexJson)
|
||||
await drive.put('/.bare/vault/index.bin', packed)
|
||||
secureZero(vaultKey)
|
||||
ctx.console.log(`Vault saved (${Object.keys(indexFiles).length} files)`)
|
||||
}
|
||||
@@ -278,6 +278,40 @@ export async function execShellLine(ctx, line) {
|
||||
const eq = a.indexOf('=')
|
||||
if (eq > 0) env[a.slice(0, eq)] = expandWord(a.slice(eq + 1), env)
|
||||
}
|
||||
} else if (name === 'login') {
|
||||
const rest = argv.slice(1)
|
||||
let createNew = false
|
||||
if (rest[0] === '--new') {
|
||||
createNew = true
|
||||
rest.shift()
|
||||
}
|
||||
const passphrase = rest.map((w) => expandWord(w, env)).join(' ')
|
||||
if (!passphrase) {
|
||||
origErr.call(ctx.console, 'usage: login [--new] <passphrase>')
|
||||
} else if (
|
||||
typeof ctx.applyRegister === 'function' &&
|
||||
typeof ctx.applyUnlock === 'function'
|
||||
) {
|
||||
try {
|
||||
if (createNew) await ctx.applyRegister(passphrase)
|
||||
else await ctx.applyUnlock(passphrase)
|
||||
} catch (e) {
|
||||
origErr.call(ctx.console, (e && e.message) || String(e))
|
||||
}
|
||||
} else {
|
||||
origErr.call(ctx.console, 'login: not supported in this environment')
|
||||
}
|
||||
} else if (name === 'logout') {
|
||||
const save = argv.includes('--save')
|
||||
if (typeof ctx.applyLogout === 'function') {
|
||||
try {
|
||||
await ctx.applyLogout({ save })
|
||||
} catch (e) {
|
||||
origErr.call(ctx.console, (e && e.message) || String(e))
|
||||
}
|
||||
} else {
|
||||
origErr.call(ctx.console, 'logout: not supported in this environment')
|
||||
}
|
||||
} else if (name === 'exit') {
|
||||
code = 'exit'
|
||||
let ec = 0
|
||||
|
||||
@@ -14,6 +14,8 @@ export class SwarmDisk {
|
||||
this.drive = null
|
||||
this.personalDrive = null
|
||||
this.os = null
|
||||
/** @type {import('hyperdrive').default[]} */
|
||||
this.auxiliaryDrives = []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,13 +24,21 @@ export class SwarmDisk {
|
||||
* @param {import('hyperdrive').default} Hyperdrive
|
||||
*/
|
||||
async initPersonalDrive(store, swarm, Hyperdrive) {
|
||||
const localStore = store.namespace('bare-os-personal-v1')
|
||||
const localStore = store.namespace('bare-os-personal-v1', { writable: true })
|
||||
this.personalDrive = new Hyperdrive(localStore)
|
||||
await this.personalDrive.ready()
|
||||
if (!this.personalDrive.writable) {
|
||||
try {
|
||||
await this.personalDrive.close()
|
||||
} catch (_) {}
|
||||
this.personalDrive = new Hyperdrive(localStore)
|
||||
await this.personalDrive.ready()
|
||||
}
|
||||
if (!this.personalDrive.writable) {
|
||||
console.warn(
|
||||
'[bare-os-booter] Personal Hyperdrive is not writable — identity and $HOME writes will fail. Check Corestore path permissions and that no other process holds the store read-only.'
|
||||
)
|
||||
}
|
||||
console.log('Personal drive:', this.personalDrive.id.slice(0, 16) + '...')
|
||||
swarm.join(this.personalDrive.discoveryKey)
|
||||
}
|
||||
@@ -248,6 +258,11 @@ export class SwarmDisk {
|
||||
if (this.drive)
|
||||
this.drive.replicate(mux.stream, { live: true, download: true })
|
||||
if (this.personalDrive) this.personalDrive.replicate(mux.stream)
|
||||
for (const d of this.auxiliaryDrives || []) {
|
||||
try {
|
||||
d.replicate(mux.stream, { live: true, download: true })
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async read(index) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import unixPathResolve from 'unix-path-resolve'
|
||||
|
||||
/**
|
||||
* Unified path view: system Hyperdrive for OS paths, personal Hyperdrive under $HOME.
|
||||
* Unified path view: system Hyperdrive for OS paths, personal Hyperdrive under $HOME,
|
||||
* optional HDMS mounts under /mnt/<label>/…
|
||||
* @param {import('hyperdrive').default} systemDrive
|
||||
* @param {import('hyperdrive').default} personalDrive
|
||||
* @param {Record<string, string>} env
|
||||
* @param {{ getMounts?: () => Map<string, { drive: import('hyperdrive').default, writable: boolean }> } | null} [mntRef]
|
||||
*/
|
||||
export function createVfs(systemDrive, personalDrive, env) {
|
||||
const HOME = () => env.HOME || '/home/user'
|
||||
export function createVfs(systemDrive, personalDrive, env, mntRef = null) {
|
||||
const HOME = () => env.HOME || '/home/guest'
|
||||
let cwd = env.PWD || HOME()
|
||||
|
||||
function normalizeHome() {
|
||||
@@ -15,14 +17,90 @@ export function createVfs(systemDrive, personalDrive, env) {
|
||||
return h.length > 1 && h.endsWith('/') ? h.slice(0, -1) : h
|
||||
}
|
||||
|
||||
/** Logical absolute path from cwd + user path */
|
||||
function resolveLogical(userPath) {
|
||||
return unixPathResolve(cwd, userPath)
|
||||
/** First path segment under /home for $HOME (e.g. guest, eeb18de988e9); null if HOME is not /home/… */
|
||||
function activeHomeBasename() {
|
||||
const h = normalizeHome()
|
||||
if (!h.startsWith('/home/')) return null
|
||||
const seg = h.slice('/home/'.length).split('/')[0]
|
||||
return seg || null
|
||||
}
|
||||
|
||||
/** Map logical absolute path to { drive, path } for Hyperdrive ops */
|
||||
function route(absPath) {
|
||||
/**
|
||||
* `cd ~`, `touch ~/x`, etc. must map to $HOME — otherwise `unix-path-resolve(cwd, '~')`
|
||||
* becomes `/~` on the system drive (read-only).
|
||||
*/
|
||||
function expandTilde(userPath) {
|
||||
const h = normalizeHome()
|
||||
if (userPath === '~') return h
|
||||
if (userPath.startsWith('~/')) {
|
||||
const rest = userPath.slice(2)
|
||||
return rest ? unixPathResolve(h, rest) : h
|
||||
}
|
||||
return userPath
|
||||
}
|
||||
|
||||
/** Logical absolute path from cwd + user path */
|
||||
function resolveLogical(userPath) {
|
||||
const expanded = expandTilde(userPath)
|
||||
return unixPathResolve(cwd, expanded)
|
||||
}
|
||||
|
||||
function getMntMap() {
|
||||
if (mntRef && typeof mntRef.getMounts === 'function') return mntRef.getMounts()
|
||||
return new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {null | Record<string, unknown>}
|
||||
*/
|
||||
function routeMnt(absPath) {
|
||||
if (absPath === '/mnt' || absPath === '/mnt/') {
|
||||
return { virtualMntRoot: true }
|
||||
}
|
||||
if (!absPath.startsWith('/mnt/')) return null
|
||||
const rest = absPath.slice(5)
|
||||
const slash = rest.indexOf('/')
|
||||
const label = slash === -1 ? rest : rest.slice(0, slash)
|
||||
const tail = slash === -1 ? '' : rest.slice(slash + 1)
|
||||
if (!label) return { virtualMntRoot: true }
|
||||
const mounts = getMntMap()
|
||||
const ent = mounts.get(label)
|
||||
if (!ent) {
|
||||
return { drive: systemDrive, path: absPath }
|
||||
}
|
||||
const sub = tail ? '/' + tail.replace(/^\/+/, '') : '/'
|
||||
const p = unixPathResolve('/', sub)
|
||||
return { drive: ent.drive, path: p, mntReadOnly: !ent.writable }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map logical absolute path to { drive, path } for Hyperdrive ops.
|
||||
* Virtual /home lists only the active session dir; /home/<active>/… is the personal drive.
|
||||
*/
|
||||
function route(absPath) {
|
||||
const mntR = routeMnt(absPath)
|
||||
if (mntR) return mntR
|
||||
|
||||
const h = normalizeHome()
|
||||
const activeSeg = activeHomeBasename()
|
||||
|
||||
if (activeSeg && absPath === '/home') {
|
||||
return { virtualHomeDir: true }
|
||||
}
|
||||
|
||||
if (activeSeg && absPath.startsWith('/home/')) {
|
||||
const after = absPath.slice('/home/'.length)
|
||||
const slash = after.indexOf('/')
|
||||
const seg = slash === -1 ? after : after.slice(0, slash)
|
||||
const rest = slash === -1 ? '' : after.slice(slash + 1)
|
||||
if (seg === activeSeg) {
|
||||
const sub = rest ? '/' + rest.replace(/^\//, '') : '/'
|
||||
const p = unixPathResolve('/', sub)
|
||||
return { drive: personalDrive, path: p }
|
||||
}
|
||||
return { drive: systemDrive, path: absPath }
|
||||
}
|
||||
|
||||
if (absPath === h || absPath.startsWith(h + '/')) {
|
||||
const sub =
|
||||
absPath === h
|
||||
@@ -48,7 +126,9 @@ export function createVfs(systemDrive, personalDrive, env) {
|
||||
|
||||
async function isRegularFile(absPath) {
|
||||
if (absPath === '/') return false
|
||||
const { drive, path: p } = route(absPath)
|
||||
const r = route(absPath)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) return false
|
||||
const { drive, path: p } = r
|
||||
if (isHyperdriveRootPath(p)) return false
|
||||
const e = await entryOn(drive, p, { follow: true })
|
||||
return !!(e && e.value && e.value.blob)
|
||||
@@ -80,14 +160,29 @@ export function createVfs(systemDrive, personalDrive, env) {
|
||||
|
||||
async readFile(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const { drive, path: p } = route(abs)
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) return null
|
||||
const { drive, path: p } = r
|
||||
if (isHyperdriveRootPath(p)) return null
|
||||
return drive.get(p, { follow: true })
|
||||
},
|
||||
|
||||
async writeFile(userPath, buf, opts = {}) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const { drive, path: p } = route(abs)
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + userPath)
|
||||
}
|
||||
if (r.mntReadOnly === true) {
|
||||
throw new Error('Read-only mount: ' + userPath)
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
if (r.mntReadOnly === false) {
|
||||
if (isHyperdriveRootPath(p)) {
|
||||
throw new Error('Cannot write directory: ' + userPath)
|
||||
}
|
||||
return drive.put(p, buf, opts)
|
||||
}
|
||||
if (drive !== personalDrive) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + userPath)
|
||||
}
|
||||
@@ -99,7 +194,20 @@ export function createVfs(systemDrive, personalDrive, env) {
|
||||
|
||||
async unlink(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const { drive, path: p } = route(abs)
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + userPath)
|
||||
}
|
||||
if (r.mntReadOnly === true) {
|
||||
throw new Error('Read-only mount: ' + userPath)
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
if (r.mntReadOnly === false) {
|
||||
if (isHyperdriveRootPath(p)) {
|
||||
throw new Error('Cannot unlink directory root')
|
||||
}
|
||||
return drive.del(p)
|
||||
}
|
||||
if (drive !== personalDrive) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + userPath)
|
||||
}
|
||||
@@ -111,7 +219,9 @@ export function createVfs(systemDrive, personalDrive, env) {
|
||||
|
||||
async exists(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const { drive, path: p } = route(abs)
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) return true
|
||||
const { drive, path: p } = r
|
||||
if (isHyperdriveRootPath(p)) return true
|
||||
return drive.exists(p)
|
||||
},
|
||||
@@ -119,19 +229,47 @@ export function createVfs(systemDrive, personalDrive, env) {
|
||||
/** @returns {Promise<string[]>} */
|
||||
async readdir(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const { drive, path: p } = route(abs)
|
||||
if (abs === '/mnt' || abs === '/mnt/') {
|
||||
return [...getMntMap().keys()].sort()
|
||||
}
|
||||
const activeSeg = activeHomeBasename()
|
||||
if (activeSeg && abs === '/home') {
|
||||
return [activeSeg].sort()
|
||||
}
|
||||
const r = route(abs)
|
||||
if (r.virtualMntRoot) {
|
||||
return [...getMntMap().keys()].sort()
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
const folder = p === '/' ? '/' : p
|
||||
const names = []
|
||||
const stream = drive.readdir(folder)
|
||||
for await (const name of stream) {
|
||||
names.push(name)
|
||||
}
|
||||
if (activeSeg && abs === '/' && !names.includes('home')) {
|
||||
names.push('home')
|
||||
}
|
||||
if (abs === '/' && !names.includes('mnt')) {
|
||||
names.push('mnt')
|
||||
}
|
||||
return names.sort()
|
||||
},
|
||||
|
||||
async stat(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const { drive, path: p } = route(abs)
|
||||
if (abs === '/mnt' || abs === '/mnt/') {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
const activeSeg = activeHomeBasename()
|
||||
if (activeSeg && abs === '/home') {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
const r = route(abs)
|
||||
if (r.virtualMntRoot) {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
if (abs === '/' || isHyperdriveRootPath(p)) {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../node_modules
|
||||
@@ -5,18 +5,22 @@
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"start": "bare index.js",
|
||||
"dev": "bare index.js",
|
||||
"test": "brittle-node test.js"
|
||||
"start": "node ../../scripts/ensure-pear-node-modules.mjs packages/bare-os-booter && bare index.js",
|
||||
"dev": "node ../../scripts/ensure-pear-node-modules.mjs packages/bare-os-booter && bare index.js",
|
||||
"pear:dev": "node ../../scripts/ensure-pear-node-modules.mjs packages/bare-os-booter && pear run --dev .",
|
||||
"test": "../../node_modules/.bin/brittle-bare test.identity.js && ../../node_modules/.bin/brittle-node test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bare-readline": "^1.3.1",
|
||||
"bare-stdio": "^1.0.2",
|
||||
"autopass": "^3.4.0",
|
||||
"b4a": "^1.6.7",
|
||||
"bare-crypto": "^1.13.4",
|
||||
"bare-os": "^3.8.7",
|
||||
"bare-os-protocol": "*",
|
||||
"b4a": "^1.6.7",
|
||||
"bare-readline": "^1.3.1",
|
||||
"bare-stdio": "^1.0.2",
|
||||
"compact-encoding": "^2.18.0",
|
||||
"corestore": "^7.2.1",
|
||||
"hypercore-id-encoding": "^1.3.0",
|
||||
"hyperdrive": "^13.3.2",
|
||||
"hyperswarm": "^4.16.0",
|
||||
"protomux": "^3.10.1",
|
||||
@@ -32,6 +36,9 @@
|
||||
"pear": {
|
||||
"name": "bare-os-booter",
|
||||
"stage": {
|
||||
"includes": [
|
||||
"../../node_modules"
|
||||
],
|
||||
"ignore": [
|
||||
".git",
|
||||
"test",
|
||||
@@ -40,6 +47,7 @@
|
||||
"node_modules/.bin",
|
||||
"node_modules/.package-lock.json",
|
||||
"test.js",
|
||||
"test.identity.js",
|
||||
".test-data"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* bare-crypto loads only under Bare — run via brittle-bare (see package.json "test").
|
||||
*/
|
||||
import test from 'brittle'
|
||||
import {
|
||||
encodeAccount,
|
||||
decodeAccount,
|
||||
encodeNewAccount,
|
||||
ACCOUNT_MAGIC,
|
||||
ACCOUNT_VERSION
|
||||
} from './lib/identity-account.js'
|
||||
|
||||
test('identity account encode/decode roundtrip', async (t) => {
|
||||
const pass = 'unit-test-passphrase'
|
||||
const blob = encodeNewAccount(pass)
|
||||
t.ok(blob.length > 80)
|
||||
t.is(blob[0], ACCOUNT_MAGIC[0])
|
||||
t.is(blob[ACCOUNT_MAGIC.length], ACCOUNT_VERSION)
|
||||
const { publicKey, secretKey } = decodeAccount(pass, blob)
|
||||
t.is(publicKey.length, 32)
|
||||
t.is(secretKey.length, 64)
|
||||
const blob2 = encodeAccount(pass, publicKey, secretKey)
|
||||
const again = decodeAccount(pass, blob2)
|
||||
t.alike(publicKey, again.publicKey)
|
||||
t.alike(secretKey, again.secretKey)
|
||||
let bad = false
|
||||
try {
|
||||
decodeAccount('wrong-pass', blob)
|
||||
} catch {
|
||||
bad = true
|
||||
}
|
||||
t.ok(bad)
|
||||
})
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
dedupeConsecutiveHistory,
|
||||
searchHistoryEntries
|
||||
} from './lib/fish-readline.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function testCorestoreDir(name) {
|
||||
@@ -152,6 +151,76 @@ test('vfs chdir / and stat $HOME root (Hyperdrive rejects entry("/"))', async (t
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('vfs expands ~ and ~/ to $HOME (not read-only /~)', async (t) => {
|
||||
const dir = testCorestoreDir('vfstilde')
|
||||
const store = new Corestore(dir)
|
||||
const sys = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pv3'))
|
||||
await sys.ready()
|
||||
await personal.ready()
|
||||
const env = { HOME: '/home/zuser', PWD: '/home/zuser', PATH: '/bin' }
|
||||
const vfs = createVfs(sys, personal, env)
|
||||
await vfs.chdir('/')
|
||||
await vfs.chdir('~')
|
||||
t.is(vfs.getcwd(), '/home/zuser')
|
||||
await vfs.writeFile('~/tilde.txt', b4a.from('ok'))
|
||||
const buf = await personal.get('/tilde.txt')
|
||||
t.ok(buf)
|
||||
t.is(b4a.toString(buf), 'ok')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('vfs lists virtual /home at root; /home shows only active session dir', async (t) => {
|
||||
const dir = testCorestoreDir('vfshomevirt')
|
||||
const store = new Corestore(dir)
|
||||
const sys = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pvh'))
|
||||
await sys.ready()
|
||||
await personal.ready()
|
||||
const envGuest = { HOME: '/home/guest', PWD: '/home/guest', PATH: '/bin' }
|
||||
const vfsG = createVfs(sys, personal, envGuest)
|
||||
const rootG = await vfsG.readdir('/')
|
||||
t.ok(rootG.includes('home'))
|
||||
t.ok(rootG.includes('bin') || rootG.includes('boot') || rootG.length >= 1)
|
||||
t.alike(await vfsG.readdir('/home'), ['guest'])
|
||||
const envUser = { HOME: '/home/eeb18de988e9', PWD: '/home/eeb18de988e9', PATH: '/bin' }
|
||||
const vfsU = createVfs(sys, personal, envUser)
|
||||
t.alike(await vfsU.readdir('/home'), ['eeb18de988e9'])
|
||||
t.is((await vfsU.stat('/home/guest')), null)
|
||||
await vfsU.writeFile('/home/eeb18de988e9/x', b4a.from('1'))
|
||||
const buf = await personal.get('/x')
|
||||
t.ok(buf)
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('vfs /mnt lists HDMS mounts and allows writable put', async (t) => {
|
||||
const dir = testCorestoreDir('vfsmnt')
|
||||
const store = new Corestore(dir)
|
||||
const sys = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pvm'))
|
||||
await sys.ready()
|
||||
await personal.ready()
|
||||
const env = { HOME: '/home/u', PWD: '/home/u', PATH: '/bin' }
|
||||
const extra = new Hyperdrive(store.namespace('exm', { writable: true }))
|
||||
await extra.ready()
|
||||
const mntRef = {
|
||||
getMounts: () =>
|
||||
new Map([['vault', { drive: extra, writable: true }]])
|
||||
}
|
||||
const vfs = createVfs(sys, personal, env, mntRef)
|
||||
const root = await vfs.readdir('/')
|
||||
t.ok(root.includes('mnt'))
|
||||
t.alike(await vfs.readdir('/mnt'), ['vault'])
|
||||
await vfs.writeFile('/mnt/vault/x.txt', b4a.from('ok'))
|
||||
const buf = await extra.get('/x.txt')
|
||||
t.ok(buf)
|
||||
t.is(b4a.toString(buf), 'ok')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('tokenize handles quotes and ops', async (t) => {
|
||||
const tok = tokenize('ls -la | cat > out')
|
||||
t.ok(tok.some((x) => x.type === 'op' && x.value === '|'))
|
||||
|
||||
@@ -18,15 +18,19 @@ const commands = [
|
||||
'exit',
|
||||
'false',
|
||||
'head',
|
||||
'hdms',
|
||||
'help',
|
||||
'hostname',
|
||||
'id',
|
||||
'login',
|
||||
'logout',
|
||||
'ls',
|
||||
'nl',
|
||||
'pathchk',
|
||||
'printenv',
|
||||
'pwd',
|
||||
'rm',
|
||||
'savevault',
|
||||
'seq',
|
||||
'sleep',
|
||||
'sort',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
async function run(ctx, argv) {
|
||||
if (typeof ctx.runHdms === 'function') {
|
||||
await ctx.runHdms(argv)
|
||||
return
|
||||
}
|
||||
ctx.console.error('hdms: unavailable')
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'Bare OS — builtins: cd, export, exit | /bin: basename cat clear date dirname echo env exit false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
)
|
||||
ctx.console.log(
|
||||
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
async function run(ctx, argv) {
|
||||
const u = ctx.vfs.env.USER || 'user'
|
||||
const uid = ctx.vfs.env.UID || '1000'
|
||||
const gid = ctx.vfs.env.GID || '1000'
|
||||
const g = ctx.vfs.env.GROUP || u
|
||||
const e = ctx.vfs.env
|
||||
const u = e.USER || e.LOGNAME || 'guest'
|
||||
const uid = e.UID || (e.BARE_OS_IDENTITY === 'guest' ? '65534' : '1000')
|
||||
const gid = e.GID || (e.BARE_OS_IDENTITY === 'guest' ? '65534' : '1000')
|
||||
const g = e.GROUP || u
|
||||
const rest = argv.slice(1)
|
||||
const wantG = rest.includes('-g') || rest.includes('--group')
|
||||
const wantU = rest.includes('-u') || rest.includes('--user')
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
async function run(ctx, argv) {
|
||||
const rest = argv.slice(1)
|
||||
let createNew = false
|
||||
if (rest[0] === '--new') {
|
||||
createNew = true
|
||||
rest.shift()
|
||||
}
|
||||
const passphrase = rest.join(' ')
|
||||
if (!passphrase) {
|
||||
ctx.console.error('usage: login [--new] <passphrase>')
|
||||
return
|
||||
}
|
||||
const reg = ctx.applyRegister
|
||||
const unlock = ctx.applyUnlock
|
||||
if (typeof reg !== 'function' || typeof unlock !== 'function') {
|
||||
ctx.console.error('login: not supported in this environment')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (createNew) await reg.call(ctx, passphrase)
|
||||
else await unlock.call(ctx, passphrase)
|
||||
} catch (err) {
|
||||
ctx.console.error(err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
async function run(ctx, argv) {
|
||||
const save = argv.includes('--save')
|
||||
const logout = ctx.applyLogout
|
||||
if (typeof logout !== 'function') {
|
||||
ctx.console.error('logout: not supported in this environment')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await logout.call(ctx, { save })
|
||||
} catch (err) {
|
||||
ctx.console.error(err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
async function run(ctx, argv) {
|
||||
const save = ctx.saveVault
|
||||
if (typeof save !== 'function') {
|
||||
ctx.console.error('savevault: not supported in this environment')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await save.call(ctx)
|
||||
} catch (err) {
|
||||
ctx.console.error(err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
async function run(ctx, argv) {
|
||||
ctx.console.log(ctx.vfs.env.USER || ctx.vfs.env.LOGNAME || 'user')
|
||||
ctx.console.log(ctx.vfs.env.USER || ctx.vfs.env.LOGNAME || 'guest')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
if (typeof ctx.runHdms === 'function') {
|
||||
await ctx.runHdms(argv)
|
||||
return
|
||||
}
|
||||
ctx.console.error('hdms: unavailable')
|
||||
}
|
||||
@@ -5,6 +5,9 @@ function bareStdin(ctx) {
|
||||
|
||||
async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'Bare OS — builtins: cd, export, exit | /bin: basename cat clear date dirname echo env exit false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
)
|
||||
ctx.console.log(
|
||||
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ function bareStdin(ctx) {
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const u = ctx.vfs.env.USER || 'user'
|
||||
const uid = ctx.vfs.env.UID || '1000'
|
||||
const gid = ctx.vfs.env.GID || '1000'
|
||||
const g = ctx.vfs.env.GROUP || u
|
||||
const e = ctx.vfs.env
|
||||
const u = e.USER || e.LOGNAME || 'guest'
|
||||
const uid = e.UID || (e.BARE_OS_IDENTITY === 'guest' ? '65534' : '1000')
|
||||
const gid = e.GID || (e.BARE_OS_IDENTITY === 'guest' ? '65534' : '1000')
|
||||
const g = e.GROUP || u
|
||||
const rest = argv.slice(1)
|
||||
const wantG = rest.includes('-g') || rest.includes('--group')
|
||||
const wantU = rest.includes('-u') || rest.includes('--user')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const rest = argv.slice(1)
|
||||
let createNew = false
|
||||
if (rest[0] === '--new') {
|
||||
createNew = true
|
||||
rest.shift()
|
||||
}
|
||||
const passphrase = rest.join(' ')
|
||||
if (!passphrase) {
|
||||
ctx.console.error('usage: login [--new] <passphrase>')
|
||||
return
|
||||
}
|
||||
const reg = ctx.applyRegister
|
||||
const unlock = ctx.applyUnlock
|
||||
if (typeof reg !== 'function' || typeof unlock !== 'function') {
|
||||
ctx.console.error('login: not supported in this environment')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (createNew) await reg.call(ctx, passphrase)
|
||||
else await unlock.call(ctx, passphrase)
|
||||
} catch (err) {
|
||||
ctx.console.error(err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const save = argv.includes('--save')
|
||||
const logout = ctx.applyLogout
|
||||
if (typeof logout !== 'function') {
|
||||
ctx.console.error('logout: not supported in this environment')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await logout.call(ctx, { save })
|
||||
} catch (err) {
|
||||
ctx.console.error(err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const save = ctx.saveVault
|
||||
if (typeof save !== 'function') {
|
||||
ctx.console.error('savevault: not supported in this environment')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await save.call(ctx)
|
||||
} catch (err) {
|
||||
ctx.console.error(err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@ function bareStdin(ctx) {
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
ctx.console.log(ctx.vfs.env.USER || ctx.vfs.env.LOGNAME || 'user')
|
||||
ctx.console.log(ctx.vfs.env.USER || ctx.vfs.env.LOGNAME || 'guest')
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ async function start(ctx) {
|
||||
const rel = await drive.get('/etc/os-release')
|
||||
if (rel) console.log(b4a.toString(rel))
|
||||
console.log(
|
||||
'Bare operating system — POSIX-ish shell: cd, export, exit | try: help, ls /bin, pwd'
|
||||
'Bare operating system — session: guest (login [--new] <passphrase> to unlock identity) | shell: cd, export, exit, login, logout | try: help, ls /bin, pwd'
|
||||
)
|
||||
while (true) {
|
||||
const line = await readLine('')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../node_modules
|
||||
@@ -5,8 +5,9 @@
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"start": "bare index.js",
|
||||
"dev": "bare index.js"
|
||||
"start": "node ../../scripts/ensure-pear-node-modules.mjs packages/bare-os-seeder && bare index.js",
|
||||
"dev": "node ../../scripts/ensure-pear-node-modules.mjs packages/bare-os-seeder && bare index.js",
|
||||
"pear:dev": "node ../../scripts/ensure-pear-node-modules.mjs packages/bare-os-seeder && pear run --dev ."
|
||||
},
|
||||
"dependencies": {
|
||||
"bare-os": "^3.8.7",
|
||||
@@ -25,6 +26,9 @@
|
||||
"pear": {
|
||||
"name": "bare-os-seeder",
|
||||
"stage": {
|
||||
"includes": [
|
||||
"../../node_modules"
|
||||
],
|
||||
"ignore": [
|
||||
".git",
|
||||
"test",
|
||||
|
||||
Reference in New Issue
Block a user