"use strict"; function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) { class UnraidAccessMethods { serverGitRemote(repository, profile) { const candidates = [ repository.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl, ] .map((value) => String(value || "").trim()) .filter(Boolean); const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate)); if (!value) { const error = new Error("Server pull requires the repository SSH clone URL from Gitea."); error.code = "SERVER_GIT_SSH_URL_REQUIRED"; throw error; } return value; } serverGitHost(repository, profile) { const remote = this.serverGitRemote(repository, profile); if (/^ssh:\/\//i.test(remote)) { const parsed = new URL(remote); return { host: parsed.hostname, port: Number(parsed.port || 22) }; } const match = remote.match(/^[^@\s]+@([^:\s]+):/); if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL."); return { host: match[1], port: 22 }; } serverGitCredentialPaths(repository, server) { const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId); return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), }; } serverGitEnvironment(repository, profile, server) { const credentials = this.serverGitCredentialPaths(repository, server); return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`; } async configureServerGitAccess({ repository, profileId }) { const { profile, server } = this.resolve(repository, profileId); const remote = this.serverGitRemote(repository, profile); const { host, port } = this.serverGitHost(repository, profile); const credentials = this.serverGitCredentialPaths(repository, server); const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); const marker = "__FORGEFLOW_DEPLOY_KEY__"; const setupScript = ` command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; } command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; } command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; } credential_dir=${shellQuote(credentials.directory)} private_key=${shellQuote(credentials.privateKey)} public_key=${shellQuote(credentials.publicKey)} known_hosts=${shellQuote(credentials.knownHosts)} expected_host_fingerprint=${shellQuote(trustedHostFingerprint)} mkdir -p "$credential_dir" chmod 700 "$credential_dir" if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then rm -f "$private_key" "$public_key" ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key" fi chmod 600 "$private_key" chmod 644 "$public_key" scan_tmp="$known_hosts.$$.tmp" scan_ok=false for attempt in 1 2 3; do ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi sleep $((attempt * 2)) done [ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; } scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then rm -f "$scan_tmp" echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2 exit 46 fi mv "$scan_tmp" "$known_hosts" chmod 600 "$known_hosts" printf '%s\n' ${shellQuote(marker)} printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')" printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')" printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint" `; const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 }); const output = String(setup.stdout || ""); const markerIndex = output.lastIndexOf(marker); if (markerIndex < 0) throw new Error("The server did not return the generated deploy key."); const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; })); if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) { const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust."); error.code = "GITEA_SSH_HOST_KEY_MISMATCH"; throw error; } const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim(); const [owner, repo] = String(repository.fullName || "").split("/"); if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull."); const deployKey = await this.gitea.ensureReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · read-only`, publicKey, }); const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; const probe = await this.ssh.exec( server.id, bash(`probe_error='' for attempt in 1 2 3; do if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi probe_error=$probe_output sleep $((attempt * 2)) done printf '%s\n' "$probe_error" >&2 exit 45`), { timeout: 45_000, maxOutput: 256 * 1024 }, ); const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null; const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, deploymentMode: "server-git", cloneUrl: remote, serverGitAccess: { configured: true, deployKeyId: deployKey.id || null, keyFingerprint: fields.fingerprint || null, hostFingerprint: fields.hostFingerprint || null, configuredAt: new Date().toISOString(), }, }); return { profile: updated, created: deployKey.created === true, remoteSha, keyFingerprint: fields.fingerprint || null, hostFingerprint: fields.hostFingerprint || null, }; } async probeServerGitAccess({ repository, profile, server }) { try { const remote = this.serverGitRemote(repository, profile); const credentials = this.serverGitCredentialPaths(repository, server); const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim(); const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`; const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 }); const output = String(result.stdout || ""); const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__"); if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence."); const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; })); return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null }; } catch (error) { return { ready: false, error: error.message }; } } async verifyServerGitProfile({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const checks = []; const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence }); if (profile.deploymentMode === "monitor-only") { add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy."); return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; } if (profile.deploymentMode !== "server-git") { add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles."); return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; } let branchSha = null; try { const [owner, repo] = String(repository.fullName || "").split("/"); const branch = await this.gitea.getBranch(owner, repo, profile.branch); branchSha = branch?.commit?.id || branch?.commit?.sha || null; add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha }); const keys = await this.gitea.listDeployKeys(owner, repo); const keyId = Number(profile.serverGitAccess?.deployKeyId); const key = keys.find((item) => Number(item.id) === keyId); add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true }); } catch (error) { add("gitea-access", "Gitea verification", "fail", error.message); } const access = await this.probeServerGitAccess({ repository, profile, server }); add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access); let inspection = null; try { inspection = await this.inspect({ repository, profileId }); const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile); const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file)); add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists }); add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose }); add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] }); add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] }); } catch (error) { add("server-inspection", "Server inspection", "fail", error.message); } const state = this.store.getDeploymentState(profile.id) || {}; const liveSha = state.liveSha || inspection?.head || null; const running = state.containerRunning; const healthy = state.healthy; add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha }); add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha }); add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified."); add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available."); const deploymentCheckIds = new Set(["gitea-access", "remote-branch", "deploy-key-scope", "server-git-access", "server-inspection"]); const deploymentBlockers = checks.filter((item) => deploymentCheckIds.has(item.id) && item.status !== "pass"); const deployReady = Boolean(branchSha) && deploymentBlockers.length === 0; const failed = checks.some((item) => item.status === "fail"); const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status)); const readiness = deploymentBlockers.length ? "Access failed" : failed ? "Deploy-ready; runtime unhealthy" : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Deployable update available" : "Deploy-ready; runtime verification incomplete") : "Ready"; return { readiness, ready: deployReady, deployReady, deploymentBlockers, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks }; } permissionTargets(profile, server, remotePath) { const targets = [ { id: "server-base", label: "Configured deployment base", path: server.basePath, kind: "directory", required: false, }, { id: "project-root", label: "Project folder", path: remotePath, kind: "directory", required: true, }, { id: "forgeflow-state", label: "ForgeFlow upload and rollback storage", path: path.join(remotePath, ".forgeflow"), kind: "directory", required: true, }, { id: "forgeflow-incoming", label: "ForgeFlow incoming upload folder", path: path.join(remotePath, ".forgeflow", "incoming"), kind: "directory", required: true, }, ]; if (!profile.generatedCompose) { for (const file of this.deploymentComposeFiles(profile)) { targets.push({ id: `compose:${file}`, label: `Compose file ${file}`, path: path.join(remotePath, file), kind: "file", required: true, }); } } const unique = new Map(); for (const target of targets) unique.set(`${target.kind}:${target.path}`, target); return [...unique.values()]; } permissionInspectionScript(profile, server, remotePath) { const targetCalls = this.permissionTargets(profile, server, remotePath) .map( (target) => `probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`, ) .join("\n"); return ` encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; } can_elevate=false [ "$(id -u)" = 0 ] && can_elevate=true if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi has_acl=false command -v setfacl >/dev/null 2>&1 && has_acl=true printf '__FORGEFLOW_PERMISSIONS__\\n' printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ "$(encode "$(id -un 2>/dev/null || echo unknown)")" \ "$(id -u 2>/dev/null || echo -1)" \ "$(id -g 2>/dev/null || echo -1)" \ "$(encode "$(id -Gn 2>/dev/null || true)")" \ "$has_acl" "$can_elevate" probe() { target_id=$1 label=$2 target=$3 kind=$4 required=$5 exists=false; readable=false; writable=false; parent_writable=false; effective=false owner=''; group=''; mode=''; detail=''; nearest='' if [ -e "$target" ] || [ -L "$target" ]; then exists=true [ -r "$target" ] && readable=true [ -w "$target" ] && writable=true owner=$(stat -c '%U' "$target" 2>/dev/null || true) group=$(stat -c '%G' "$target" 2>/dev/null || true) mode=$(stat -c '%a' "$target" 2>/dev/null || true) fi parent=$(dirname "$target") ancestor=$parent while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done nearest=$ancestor marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}" if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then rm -f -- "$marker" >/dev/null 2>&1 || true parent_writable=true fi if [ "$kind" = directory ]; then if [ -d "$target" ]; then marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}" if (umask 077; : > "$marker") 2>/dev/null; then rm -f -- "$marker" >/dev/null 2>&1 || true effective=true fi elif [ "$parent_writable" = true ]; then effective=true fi else if [ "$exists" = true ] && [ ! -f "$target" ]; then detail='Path exists but is not a regular file.' elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then effective=true elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then effective=true detail='File is absent but can be created by the deployment user.' fi fi if [ -z "$detail" ]; then if [ "$effective" = true ]; then detail='Read/write probe passed.' else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi fi printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ "$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \ "$exists" "$readable" "$writable" "$parent_writable" "$effective" \ "$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")" } ${targetCalls} `; } async inspectWriteAccess({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const result = await this.ssh.exec( server.id, bash(this.permissionInspectionScript(profile, server, remotePath)), { timeout: 45_000, maxOutput: 2 * 1024 * 1024 }, ); const report = parsePermissionInspection(result.stdout); report.serverId = server.id; report.remotePath = remotePath; return report; } permissionRepairScript(profile, server, remotePath) { const preserve = [ ".git", "node_modules", ".venv", "venv", "__pycache__", ...(profile.preservePaths || []), ] .map((value) => safeRelativeRemoteFile(value)) .filter(Boolean); const pruneExpression = preserve.length ? preserve .map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`) .join(" -o ") : "-false"; const composePaths = this.deploymentComposeFiles(profile) .map((file) => shellQuote(path.join(remotePath, file))) .join(" "); return ` root=${shellQuote(remotePath)} base=${shellQuote(server.basePath)} case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac run_privileged() { if [ "$(id -u)" = 0 ]; then "$@"; elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@"; else "$@"; fi } mkdir_cmd=mkdir if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" fi share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) if command -v setfacl >/dev/null 2>&1; then run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true fi run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" if [ -d "$root" ]; then while IFS= read -r -d '' entry; do case "$entry" in "$root/.forgeflow"|"$root/.forgeflow"/*) continue ;; esac run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0) fi for compose_file in ${composePaths || ""}; do [ -e "$compose_file" ] || continue run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true run_privileged chmod u+rw,g+rw "$compose_file" done echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths." `; } async repairWriteAccess({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const before = await this.inspectWriteAccess({ repository, profileId }); await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), { timeout: 5 * 60_000, maxOutput: 4 * 1024 * 1024, }); const after = await this.inspectWriteAccess({ repository, profileId }); if (!after.ready) { const error = new Error( `Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`, ); error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE"; error.permissionReport = after; throw error; } await this.diagnostics?.info("unraid.write-access.repaired", { repository: repository.fullName, profileId, serverId: server.id, remotePath, user: after.identity.user, }); return { changed: true, normalized: true, before, after }; } } return UnraidAccessMethods.prototype; } module.exports = { createUnraidAccessMethods };