Initial commit
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
|||||||
|
.nogit/
|
||||||
|
|
||||||
|
# artifacts
|
||||||
|
coverage/
|
||||||
|
public/
|
||||||
|
|
||||||
|
# installs
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.yarn/
|
||||||
|
.cache/
|
||||||
|
.rpt2_cache
|
||||||
|
|
||||||
|
# builds
|
||||||
|
dist/
|
||||||
|
dist_*/
|
||||||
|
rust/target/
|
||||||
|
|
||||||
|
# custom
|
||||||
|
.claude/*
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"projectType": "npm",
|
||||||
|
"module": {
|
||||||
|
"githost": "code.foss.global",
|
||||||
|
"gitscope": "push.rocks",
|
||||||
|
"gitrepo": "smartsamba",
|
||||||
|
"description": "A TypeScript Samba/SMB client and server module backed by an embedded Rust SMB engine.",
|
||||||
|
"npmPackagename": "@push.rocks/smartsamba",
|
||||||
|
"license": "MIT",
|
||||||
|
"projectDomain": "push.rocks",
|
||||||
|
"keywords": [
|
||||||
|
"samba",
|
||||||
|
"smb",
|
||||||
|
"cifs",
|
||||||
|
"network share",
|
||||||
|
"file sharing",
|
||||||
|
"typescript",
|
||||||
|
"rust",
|
||||||
|
"client",
|
||||||
|
"server"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-03 - 0.1.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-05-03 - 0.1.0 - project
|
||||||
|
Initial project setup and repository cleanup.
|
||||||
|
|
||||||
|
- Added the initial project structure
|
||||||
|
- Removed committed Rust build artifacts
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"gitzone": {
|
||||||
|
"projectType": "npm",
|
||||||
|
"module": {
|
||||||
|
"githost": "code.foss.global",
|
||||||
|
"gitscope": "push.rocks",
|
||||||
|
"gitrepo": "smartsamba",
|
||||||
|
"description": "A TypeScript Samba/SMB client and server module backed by an embedded Rust SMB engine.",
|
||||||
|
"npmPackagename": "@push.rocks/smartsamba",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"samba",
|
||||||
|
"smb",
|
||||||
|
"cifs",
|
||||||
|
"network share",
|
||||||
|
"file sharing",
|
||||||
|
"typescript",
|
||||||
|
"rust",
|
||||||
|
"client",
|
||||||
|
"server"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "@push.rocks/smartsamba",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"private": false,
|
||||||
|
"description": "A TypeScript Samba/SMB client and server module backed by an embedded Rust SMB engine.",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist_ts/index.js"
|
||||||
|
},
|
||||||
|
"main": "dist_ts/index.js",
|
||||||
|
"typings": "dist_ts/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"author": "Task Venture Capital GmbH <hello@task.vc>",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"test:before": "tsrust",
|
||||||
|
"pretest": "tsrust",
|
||||||
|
"test": "tstest test/ --verbose --timeout 60 --logfile",
|
||||||
|
"build": "tsbuild tsfolders && tsrust",
|
||||||
|
"buildDocs": "tsdoc"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
|
"@git.zone/tsrust": "^1.3.3",
|
||||||
|
"@git.zone/tstest": "^3.6.3",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
|
"@push.rocks/smartrust": "^1.4.0"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://code.foss.global/push.rocks/smartsamba.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://code.foss.global/push.rocks/smartsamba/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://code.foss.global/push.rocks/smartsamba",
|
||||||
|
"files": [
|
||||||
|
"ts/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"dist_*/**/*",
|
||||||
|
"dist_ts/**/*",
|
||||||
|
"dist_rust/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"cli.js",
|
||||||
|
".smartconfig.json",
|
||||||
|
"license",
|
||||||
|
"npmextra.json",
|
||||||
|
"readme.md"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"samba",
|
||||||
|
"smb",
|
||||||
|
"cifs",
|
||||||
|
"network share",
|
||||||
|
"file sharing",
|
||||||
|
"typescript",
|
||||||
|
"rust",
|
||||||
|
"client",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"browserslist": [
|
||||||
|
"last 1 chrome versions"
|
||||||
|
],
|
||||||
|
"packageManager": "pnpm@10.28.2"
|
||||||
|
}
|
||||||
Generated
+7516
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
# @push.rocks/smartsamba
|
||||||
|
|
||||||
|
A TypeScript Samba/SMB client and server module backed by a bundled Rust SMB engine.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm add @push.rocks/smartsamba
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SambaServer, SambaClient } from '@push.rocks/smartsamba';
|
||||||
|
|
||||||
|
const server = new SambaServer({
|
||||||
|
port: 0,
|
||||||
|
users: [{ username: 'alice', password: 'secret' }],
|
||||||
|
shares: [{ name: 'files', path: './shared' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await server.start();
|
||||||
|
|
||||||
|
const client = new SambaClient({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: started.port,
|
||||||
|
auth: { username: 'alice', password: 'secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.writeFile('files', 'hello.txt', 'hello samba');
|
||||||
|
const data = await client.readFileAsString('files', 'hello.txt');
|
||||||
|
|
||||||
|
await client.stop();
|
||||||
|
await server.stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
The TypeScript API uses `@push.rocks/smartrust` to communicate with the bundled `rustsamba` binary built by `@git.zone/tsrust`. It does not wrap system `smbd` or `smbclient`.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
Generated
+1662
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "rustsamba"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "rustsamba"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
smolder-smb-core = "0.3.0"
|
||||||
|
smb-server = "0.4.0"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
mod management;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "rustsamba", about = "Embedded SMB client/server engine for smartsamba")]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(long)]
|
||||||
|
management: bool,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_new(&cli.log_level)
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
if cli.management {
|
||||||
|
management::management_loop().await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("rustsamba: use --management for IPC mode");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IpcRequest {
|
||||||
|
id: String,
|
||||||
|
method: String,
|
||||||
|
#[serde(default)]
|
||||||
|
params: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct IpcResponse {
|
||||||
|
id: String,
|
||||||
|
success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
result: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct IpcEvent {
|
||||||
|
event: String,
|
||||||
|
data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StartServerParams {
|
||||||
|
config: SambaServerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SambaServerConfig {
|
||||||
|
host: Option<String>,
|
||||||
|
port: Option<u16>,
|
||||||
|
netbios_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
users: Vec<SambaServerUser>,
|
||||||
|
shares: Vec<SambaServerShare>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SambaServerUser {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SambaServerShare {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
read_only: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
public: bool,
|
||||||
|
users: Option<Vec<SambaServerShareUser>>,
|
||||||
|
create_if_missing: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SambaServerShareUser {
|
||||||
|
username: String,
|
||||||
|
access: Option<SambaShareAccess>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
enum SambaShareAccess {
|
||||||
|
Read,
|
||||||
|
ReadWrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SambaConnectionConfig {
|
||||||
|
host: String,
|
||||||
|
port: Option<u16>,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
domain: Option<String>,
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
|
compression: Option<bool>,
|
||||||
|
dfs_enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ConnectionParams {
|
||||||
|
connection: SambaConnectionConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SharePathParams {
|
||||||
|
connection: SambaConnectionConfig,
|
||||||
|
share: String,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WriteFileParams {
|
||||||
|
connection: SambaConnectionConfig,
|
||||||
|
share: String,
|
||||||
|
path: String,
|
||||||
|
data_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RenameParams {
|
||||||
|
connection: SambaConnectionConfig,
|
||||||
|
share: String,
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RunningServer {
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
shares: Vec<String>,
|
||||||
|
join: tokio::task::JoinHandle<std::io::Result<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunningServer {
|
||||||
|
fn status(&self) -> Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"running": true,
|
||||||
|
"host": self.local_addr.ip().to_string(),
|
||||||
|
"port": self.local_addr.port(),
|
||||||
|
"address": self.local_addr.to_string(),
|
||||||
|
"shares": self.shares,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(self) {
|
||||||
|
self.join.abort();
|
||||||
|
let _ = self.join.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn management_loop() -> Result<()> {
|
||||||
|
send_event("ready", serde_json::json!({}));
|
||||||
|
|
||||||
|
let stdin = BufReader::new(tokio::io::stdin());
|
||||||
|
let mut lines = stdin.lines();
|
||||||
|
let mut server: Option<RunningServer> = None;
|
||||||
|
|
||||||
|
while let Some(line) = lines.next_line().await? {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request: IpcRequest = match serde_json::from_str(line) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => {
|
||||||
|
send_response(IpcResponse::err(
|
||||||
|
"unknown".to_string(),
|
||||||
|
format!("Invalid IPC request: {error}"),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = request.id.clone();
|
||||||
|
let response = handle_request(request, &mut server).await;
|
||||||
|
send_response(match response {
|
||||||
|
Ok(value) => IpcResponse::ok(id, value),
|
||||||
|
Err(error) => IpcResponse::err(id, error.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(server) = server.take() {
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_request(request: IpcRequest, server: &mut Option<RunningServer>) -> Result<Value> {
|
||||||
|
match request.method.as_str() {
|
||||||
|
"startServer" => {
|
||||||
|
let params: StartServerParams = serde_json::from_value(request.params)?;
|
||||||
|
start_server(params.config, server).await
|
||||||
|
}
|
||||||
|
"stopServer" => {
|
||||||
|
if let Some(running) = server.take() {
|
||||||
|
running.stop().await;
|
||||||
|
}
|
||||||
|
Ok(serde_json::json!({}))
|
||||||
|
}
|
||||||
|
"getServerStatus" => Ok(match server.as_ref() {
|
||||||
|
Some(running) => running.status(),
|
||||||
|
None => serde_json::json!({ "running": false, "shares": [] }),
|
||||||
|
}),
|
||||||
|
"listShares" => {
|
||||||
|
let params: ConnectionParams = serde_json::from_value(request.params)?;
|
||||||
|
list_shares(params.connection).await
|
||||||
|
}
|
||||||
|
"listDirectory" => {
|
||||||
|
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||||
|
list_directory(params).await
|
||||||
|
}
|
||||||
|
"readFile" => {
|
||||||
|
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||||
|
read_file(params).await
|
||||||
|
}
|
||||||
|
"writeFile" => {
|
||||||
|
let params: WriteFileParams = serde_json::from_value(request.params)?;
|
||||||
|
write_file(params).await
|
||||||
|
}
|
||||||
|
"deleteFile" => {
|
||||||
|
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||||
|
delete_file(params).await
|
||||||
|
}
|
||||||
|
"createDirectory" => {
|
||||||
|
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||||
|
create_directory(params).await
|
||||||
|
}
|
||||||
|
"rename" => {
|
||||||
|
let params: RenameParams = serde_json::from_value(request.params)?;
|
||||||
|
rename_path(params).await
|
||||||
|
}
|
||||||
|
"stat" => {
|
||||||
|
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||||
|
stat_path(params).await
|
||||||
|
}
|
||||||
|
unknown => Err(anyhow!("Unknown method: {unknown}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_server(config: SambaServerConfig, server: &mut Option<RunningServer>) -> Result<Value> {
|
||||||
|
if server.is_some() {
|
||||||
|
return Err(anyhow!("Samba server is already running"));
|
||||||
|
}
|
||||||
|
if config.shares.is_empty() {
|
||||||
|
return Err(anyhow!("At least one share must be configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = config.host.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
|
let port = config.port.unwrap_or(445);
|
||||||
|
let ip: IpAddr = host.parse().with_context(|| format!("Invalid server host: {host}"))?;
|
||||||
|
let listen_addr = SocketAddr::new(ip, port);
|
||||||
|
|
||||||
|
let mut builder = smb_server::SmbServer::builder().listen(listen_addr);
|
||||||
|
if let Some(netbios_name) = config.netbios_name {
|
||||||
|
builder = builder.netbios_name(netbios_name);
|
||||||
|
}
|
||||||
|
for user in &config.users {
|
||||||
|
builder = builder.user(user.username.clone(), user.password.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut share_names = Vec::new();
|
||||||
|
for share_config in &config.shares {
|
||||||
|
if share_config.create_if_missing.unwrap_or(true) {
|
||||||
|
std::fs::create_dir_all(&share_config.path)
|
||||||
|
.with_context(|| format!("Failed to create share path {}", share_config.path))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backend = smb_server::LocalFsBackend::new(Path::new(&share_config.path))
|
||||||
|
.with_context(|| format!("Failed to open share path {}", share_config.path))?;
|
||||||
|
let backend = if share_config.read_only {
|
||||||
|
backend.read_only()
|
||||||
|
} else {
|
||||||
|
backend
|
||||||
|
};
|
||||||
|
let mut share = smb_server::Share::new(share_config.name.clone(), backend);
|
||||||
|
|
||||||
|
if share_config.public {
|
||||||
|
share = if share_config.read_only {
|
||||||
|
share.public_read_only()
|
||||||
|
} else {
|
||||||
|
share.public()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let configured_users = share_config.users.clone().unwrap_or_else(|| {
|
||||||
|
config
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.map(|user| SambaServerShareUser {
|
||||||
|
username: user.username.clone(),
|
||||||
|
access: Some(SambaShareAccess::ReadWrite),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
if configured_users.is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Share {} needs either public=true or at least one configured user",
|
||||||
|
share_config.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for share_user in configured_users {
|
||||||
|
share = share.user(share_user.username, access_to_smb(share_user.access));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
share_names.push(share_config.name.clone());
|
||||||
|
builder = builder.share(share);
|
||||||
|
}
|
||||||
|
|
||||||
|
let smb_server = builder.build().map_err(|error| anyhow!(error.to_string()))?;
|
||||||
|
let local_addr = smb_server.bind().await?;
|
||||||
|
let join = tokio::spawn(async move { smb_server.serve().await });
|
||||||
|
|
||||||
|
let running = RunningServer {
|
||||||
|
local_addr,
|
||||||
|
shares: share_names.clone(),
|
||||||
|
join,
|
||||||
|
};
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"host": local_addr.ip().to_string(),
|
||||||
|
"port": local_addr.port(),
|
||||||
|
"address": local_addr.to_string(),
|
||||||
|
"shares": share_names,
|
||||||
|
});
|
||||||
|
*server = Some(running);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_to_smb(access: Option<SambaShareAccess>) -> smb_server::Access {
|
||||||
|
match access.unwrap_or(SambaShareAccess::ReadWrite) {
|
||||||
|
SambaShareAccess::Read => smb_server::Access::Read,
|
||||||
|
SambaShareAccess::ReadWrite => smb_server::Access::ReadWrite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_smb_path(path: &str) -> String {
|
||||||
|
path.trim_matches(['/', '\\']).replace('/', "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_smb_path(path: &str) -> String {
|
||||||
|
let normalized = normalize_smb_path(path);
|
||||||
|
if normalized.is_empty() {
|
||||||
|
"\\".to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smolder_error(error: smolder_core::error::CoreError) -> anyhow::Error {
|
||||||
|
anyhow!(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_smolder<T>(
|
||||||
|
operation_result: std::result::Result<T, smolder_core::error::CoreError>,
|
||||||
|
logoff_result: std::result::Result<(), smolder_core::error::CoreError>,
|
||||||
|
) -> Result<T> {
|
||||||
|
match (operation_result, logoff_result) {
|
||||||
|
(Ok(value), Ok(())) => Ok(value),
|
||||||
|
(Err(error), _) => Err(smolder_error(error)),
|
||||||
|
(Ok(_), Err(error)) => Err(smolder_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_time_to_filetime(time: Option<SystemTime>) -> u64 {
|
||||||
|
const WINDOWS_TICK: u64 = 10_000_000;
|
||||||
|
const SEC_TO_UNIX_EPOCH: u64 = 11_644_473_600;
|
||||||
|
|
||||||
|
match time.and_then(|value| value.duration_since(UNIX_EPOCH).ok()) {
|
||||||
|
Some(duration) => {
|
||||||
|
(duration.as_secs() + SEC_TO_UNIX_EPOCH) * WINDOWS_TICK
|
||||||
|
+ u64::from(duration.subsec_nanos()) / 100
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_to_json(metadata: &smolder_core::facade::FileMetadata) -> Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"size": metadata.size,
|
||||||
|
"isDirectory": metadata.is_directory(),
|
||||||
|
"createdFiletime": system_time_to_filetime(metadata.created),
|
||||||
|
"modifiedFiletime": system_time_to_filetime(metadata.written),
|
||||||
|
"accessedFiletime": system_time_to_filetime(metadata.accessed),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_entry_to_json(entry: smolder_core::facade::DirectoryEntry) -> Value {
|
||||||
|
let smolder_core::facade::DirectoryEntry { name, metadata, .. } = entry;
|
||||||
|
serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"size": metadata.size,
|
||||||
|
"isDirectory": metadata.is_directory(),
|
||||||
|
"createdFiletime": system_time_to_filetime(metadata.created),
|
||||||
|
"modifiedFiletime": system_time_to_filetime(metadata.written),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_smb_client(config: &SambaConnectionConfig) -> Result<smolder_core::facade::Client> {
|
||||||
|
let _unsupported_options = (config.timeout_ms, config.compression, config.dfs_enabled);
|
||||||
|
let mut credentials = smolder_core::auth::NtlmCredentials::new(
|
||||||
|
config.username.clone().unwrap_or_default(),
|
||||||
|
config.password.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
if let Some(domain) = &config.domain {
|
||||||
|
if !domain.is_empty() {
|
||||||
|
credentials = credentials.with_domain(domain.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = smolder_core::facade::Client::builder(config.host.clone())
|
||||||
|
.with_ntlm_credentials(credentials);
|
||||||
|
if let Some(port) = config.port {
|
||||||
|
builder = builder.with_port(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.build().map_err(smolder_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_share_client(
|
||||||
|
config: &SambaConnectionConfig,
|
||||||
|
share: &str,
|
||||||
|
) -> Result<smolder_core::facade::Share> {
|
||||||
|
create_smb_client(config)?
|
||||||
|
.connect_share(share)
|
||||||
|
.await
|
||||||
|
.map_err(smolder_error)
|
||||||
|
.with_context(|| format!("Failed to connect to share {share}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_shares(connection: SambaConnectionConfig) -> Result<Value> {
|
||||||
|
let mut srvsvc = create_smb_client(&connection)?
|
||||||
|
.connect_srvsvc()
|
||||||
|
.await
|
||||||
|
.map_err(smolder_error)?;
|
||||||
|
let shares = srvsvc
|
||||||
|
.share_enum_level1()
|
||||||
|
.await
|
||||||
|
.map_err(smolder_error)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|share| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": share.name,
|
||||||
|
"shareType": share.share_type,
|
||||||
|
"comment": share.remark.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(serde_json::json!({ "shares": shares }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_directory(params: SharePathParams) -> Result<Value> {
|
||||||
|
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||||
|
let path = directory_smb_path(¶ms.path);
|
||||||
|
let entries_result = share.list(&path).await;
|
||||||
|
let logoff_result = share.logoff().await;
|
||||||
|
let entries = finish_smolder(entries_result, logoff_result)?
|
||||||
|
.into_iter()
|
||||||
|
.map(directory_entry_to_json)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(serde_json::json!({ "entries": entries }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_file(params: SharePathParams) -> Result<Value> {
|
||||||
|
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||||
|
let path = normalize_smb_path(¶ms.path);
|
||||||
|
let data_result = share.get(&path).await;
|
||||||
|
let logoff_result = share.logoff().await;
|
||||||
|
let data = finish_smolder(data_result, logoff_result)?;
|
||||||
|
let size = data.len();
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"dataBase64": general_purpose::STANDARD.encode(data),
|
||||||
|
"size": size,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_file(params: WriteFileParams) -> Result<Value> {
|
||||||
|
let data = general_purpose::STANDARD
|
||||||
|
.decode(params.data_base64.as_bytes())
|
||||||
|
.context("Invalid base64 file data")?;
|
||||||
|
let bytes_written = data.len();
|
||||||
|
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||||
|
let path = normalize_smb_path(¶ms.path);
|
||||||
|
let write_result = share.put(&path, &data).await;
|
||||||
|
let logoff_result = share.logoff().await;
|
||||||
|
finish_smolder(write_result, logoff_result)?;
|
||||||
|
Ok(serde_json::json!({ "bytesWritten": bytes_written }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(params: SharePathParams) -> Result<Value> {
|
||||||
|
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||||
|
let path = normalize_smb_path(¶ms.path);
|
||||||
|
let remove_result = share.remove(&path).await;
|
||||||
|
let logoff_result = share.logoff().await;
|
||||||
|
finish_smolder(remove_result, logoff_result)?;
|
||||||
|
Ok(serde_json::json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_directory(params: SharePathParams) -> Result<Value> {
|
||||||
|
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||||
|
let path = normalize_smb_path(¶ms.path);
|
||||||
|
let create_result = share.create_dir(&path).await;
|
||||||
|
let logoff_result = share.logoff().await;
|
||||||
|
finish_smolder(create_result, logoff_result)?;
|
||||||
|
Ok(serde_json::json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_path(params: RenameParams) -> Result<Value> {
|
||||||
|
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||||
|
let from_path = normalize_smb_path(¶ms.from);
|
||||||
|
let to_path = normalize_smb_path(¶ms.to);
|
||||||
|
let rename_result = share.rename(&from_path, &to_path).await;
|
||||||
|
let logoff_result = share.logoff().await;
|
||||||
|
finish_smolder(rename_result, logoff_result)?;
|
||||||
|
Ok(serde_json::json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat_path(params: SharePathParams) -> Result<Value> {
|
||||||
|
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||||
|
let path = normalize_smb_path(¶ms.path);
|
||||||
|
let stat_result = share.stat(&path).await;
|
||||||
|
let logoff_result = share.logoff().await;
|
||||||
|
let metadata = finish_smolder(stat_result, logoff_result)?;
|
||||||
|
Ok(metadata_to_json(&metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_response(response: IpcResponse) {
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
if serde_json::to_writer(&mut stdout, &response).is_ok() {
|
||||||
|
let _ = stdout.write_all(b"\n");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_event(event: &str, data: Value) {
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
let payload = IpcEvent {
|
||||||
|
event: event.to_string(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
if serde_json::to_writer(&mut stdout, &payload).is_ok() {
|
||||||
|
let _ = stdout.write_all(b"\n");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IpcResponse {
|
||||||
|
fn ok(id: String, result: Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
success: true,
|
||||||
|
result: Some(result),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(id: String, message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { SambaClient, SambaServer } from '../ts/index.js';
|
||||||
|
|
||||||
|
tap.test('should serve and access a local SMB share', async () => {
|
||||||
|
const sharePath = path.join(process.cwd(), '.nogit', `smartsamba-${Date.now()}`);
|
||||||
|
await fs.mkdir(sharePath, { recursive: true });
|
||||||
|
|
||||||
|
const server = new SambaServer({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0,
|
||||||
|
users: [{ username: 'alice', password: 'secret' }],
|
||||||
|
shares: [{ name: 'files', path: sharePath }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await server.start();
|
||||||
|
const client = new SambaClient({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: started.port,
|
||||||
|
auth: { username: 'alice', password: 'secret' },
|
||||||
|
timeoutMs: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.writeFile('files', 'hello.txt', 'hello samba');
|
||||||
|
const content = await client.readFileAsString('files', 'hello.txt');
|
||||||
|
expect(content).toEqual('hello samba');
|
||||||
|
|
||||||
|
const entries = await client.listDirectory('files');
|
||||||
|
expect(entries.some((entry) => entry.name === 'hello.txt')).toBeTrue();
|
||||||
|
|
||||||
|
const info = await client.stat('files', 'hello.txt');
|
||||||
|
expect(info.size).toEqual('hello samba'.length);
|
||||||
|
expect(info.isDirectory).toBeFalse();
|
||||||
|
|
||||||
|
await client.createDirectory('files', 'nested');
|
||||||
|
await client.rename('files', 'hello.txt', 'nested/renamed.txt');
|
||||||
|
const renamed = await client.readFileAsString('files', 'nested/renamed.txt');
|
||||||
|
expect(renamed).toEqual('hello samba');
|
||||||
|
|
||||||
|
await client.deleteFile('files', 'nested/renamed.txt');
|
||||||
|
} finally {
|
||||||
|
await client.stop();
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: '@push.rocks/smartsamba',
|
||||||
|
version: '0.1.1',
|
||||||
|
description: 'A TypeScript Samba/SMB client and server module backed by an embedded Rust SMB engine.'
|
||||||
|
}
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as paths from './paths.js';
|
||||||
|
|
||||||
|
export type TSambaShareAccess = 'read' | 'readWrite';
|
||||||
|
|
||||||
|
export interface ISambaAuthOptions {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaClientOptions {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
auth?: Partial<ISambaAuthOptions>;
|
||||||
|
timeoutMs?: number;
|
||||||
|
compression?: boolean;
|
||||||
|
dfsEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaServerUser {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaServerShareUser {
|
||||||
|
username: string;
|
||||||
|
access?: TSambaShareAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaServerShare {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
public?: boolean;
|
||||||
|
users?: ISambaServerShareUser[];
|
||||||
|
createIfMissing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaServerOptions {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
netbiosName?: string;
|
||||||
|
users?: ISambaServerUser[];
|
||||||
|
shares: ISambaServerShare[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaServerStartResult {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
address: string;
|
||||||
|
shares: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaServerStatus {
|
||||||
|
running: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
address?: string;
|
||||||
|
shares: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaDirectoryEntry {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
isDirectory: boolean;
|
||||||
|
createdFiletime: number;
|
||||||
|
modifiedFiletime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaFileInfo {
|
||||||
|
size: number;
|
||||||
|
isDirectory: boolean;
|
||||||
|
createdFiletime: number;
|
||||||
|
modifiedFiletime: number;
|
||||||
|
accessedFiletime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISambaShareInfo {
|
||||||
|
name: string;
|
||||||
|
shareType: number;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRustSambaConnectionConfig {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
domain?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
compression?: boolean;
|
||||||
|
dfsEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TRustSambaCommands = {
|
||||||
|
startServer: { params: { config: ISambaServerOptions }; result: ISambaServerStartResult };
|
||||||
|
stopServer: { params: Record<string, never>; result: Record<string, never> };
|
||||||
|
getServerStatus: { params: Record<string, never>; result: ISambaServerStatus };
|
||||||
|
listShares: { params: { connection: IRustSambaConnectionConfig }; result: { shares: ISambaShareInfo[] } };
|
||||||
|
listDirectory: {
|
||||||
|
params: { connection: IRustSambaConnectionConfig; share: string; path: string };
|
||||||
|
result: { entries: ISambaDirectoryEntry[] };
|
||||||
|
};
|
||||||
|
readFile: {
|
||||||
|
params: { connection: IRustSambaConnectionConfig; share: string; path: string };
|
||||||
|
result: { dataBase64: string; size: number };
|
||||||
|
};
|
||||||
|
writeFile: {
|
||||||
|
params: { connection: IRustSambaConnectionConfig; share: string; path: string; dataBase64: string };
|
||||||
|
result: { bytesWritten: number };
|
||||||
|
};
|
||||||
|
deleteFile: {
|
||||||
|
params: { connection: IRustSambaConnectionConfig; share: string; path: string };
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
createDirectory: {
|
||||||
|
params: { connection: IRustSambaConnectionConfig; share: string; path: string };
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
rename: {
|
||||||
|
params: { connection: IRustSambaConnectionConfig; share: string; from: string; to: string };
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
stat: {
|
||||||
|
params: { connection: IRustSambaConnectionConfig; share: string; path: string };
|
||||||
|
result: ISambaFileInfo;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTsrustPlatformSuffix(): string | null {
|
||||||
|
const archMap: Record<string, string> = { x64: 'amd64', arm64: 'arm64' };
|
||||||
|
const osMap: Record<string, string> = { linux: 'linux', darwin: 'macos' };
|
||||||
|
const os = osMap[process.platform];
|
||||||
|
const arch = archMap[process.arch];
|
||||||
|
return os && arch ? `${os}_${arch}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalRustPaths(): string[] {
|
||||||
|
const suffix = getTsrustPlatformSuffix();
|
||||||
|
const localPaths: string[] = [];
|
||||||
|
if (suffix) {
|
||||||
|
localPaths.push(plugins.path.join(paths.packageDir, 'dist_rust', `rustsamba_${suffix}`));
|
||||||
|
}
|
||||||
|
localPaths.push(plugins.path.join(paths.packageDir, 'dist_rust', 'rustsamba'));
|
||||||
|
localPaths.push(plugins.path.join(paths.packageDir, 'rust', 'target', 'release', 'rustsamba'));
|
||||||
|
localPaths.push(plugins.path.join(paths.packageDir, 'rust', 'target', 'debug', 'rustsamba'));
|
||||||
|
return localPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SambaBridge {
|
||||||
|
private bridge = new plugins.smartrust.RustBridge<TRustSambaCommands>({
|
||||||
|
binaryName: 'rustsamba',
|
||||||
|
envVarName: 'SMARTSAMBA_RUST_BINARY',
|
||||||
|
platformPackagePrefix: '@push.rocks/smartsamba',
|
||||||
|
localPaths: buildLocalRustPaths(),
|
||||||
|
readyTimeoutMs: 30000,
|
||||||
|
requestTimeoutMs: 300000,
|
||||||
|
maxPayloadSize: 128 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
public async ensureRunning(): Promise<void> {
|
||||||
|
if (this.bridge.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spawned = await this.bridge.spawn();
|
||||||
|
if (!spawned) {
|
||||||
|
throw new Error('Failed to spawn rustsamba binary. Run pnpm build or pnpm run test:before first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand<K extends string & keyof TRustSambaCommands>(
|
||||||
|
method: K,
|
||||||
|
params: TRustSambaCommands[K]['params'],
|
||||||
|
): Promise<TRustSambaCommands[K]['result']> {
|
||||||
|
await this.ensureRunning();
|
||||||
|
return this.bridge.sendCommand(method, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public kill(): void {
|
||||||
|
this.bridge.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClientOptions(optionsArg: ISambaClientOptions): IRustSambaConnectionConfig {
|
||||||
|
return {
|
||||||
|
host: optionsArg.host,
|
||||||
|
...(optionsArg.port ? { port: optionsArg.port } : {}),
|
||||||
|
...(optionsArg.auth?.username ? { username: optionsArg.auth.username } : {}),
|
||||||
|
...(optionsArg.auth?.password ? { password: optionsArg.auth.password } : {}),
|
||||||
|
...(optionsArg.auth?.domain ? { domain: optionsArg.auth.domain } : {}),
|
||||||
|
...(optionsArg.timeoutMs ? { timeoutMs: optionsArg.timeoutMs } : {}),
|
||||||
|
...(typeof optionsArg.compression === 'boolean' ? { compression: optionsArg.compression } : {}),
|
||||||
|
...(typeof optionsArg.dfsEnabled === 'boolean' ? { dfsEnabled: optionsArg.dfsEnabled } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SambaClient {
|
||||||
|
private bridge = new SambaBridge();
|
||||||
|
private connection: IRustSambaConnectionConfig;
|
||||||
|
|
||||||
|
constructor(optionsArg: ISambaClientOptions) {
|
||||||
|
this.connection = normalizeClientOptions(optionsArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
await this.bridge.ensureRunning();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.bridge.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listShares(): Promise<ISambaShareInfo[]> {
|
||||||
|
const result = await this.bridge.sendCommand('listShares', { connection: this.connection });
|
||||||
|
return result.shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listDirectory(shareArg: string, pathArg = ''): Promise<ISambaDirectoryEntry[]> {
|
||||||
|
const result = await this.bridge.sendCommand('listDirectory', {
|
||||||
|
connection: this.connection,
|
||||||
|
share: shareArg,
|
||||||
|
path: pathArg,
|
||||||
|
});
|
||||||
|
return result.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readFile(shareArg: string, pathArg: string): Promise<Buffer> {
|
||||||
|
const result = await this.bridge.sendCommand('readFile', {
|
||||||
|
connection: this.connection,
|
||||||
|
share: shareArg,
|
||||||
|
path: pathArg,
|
||||||
|
});
|
||||||
|
return plugins.buffer.Buffer.from(result.dataBase64, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readFileAsString(shareArg: string, pathArg: string, encoding: BufferEncoding = 'utf8') {
|
||||||
|
const buffer = await this.readFile(shareArg, pathArg);
|
||||||
|
return buffer.toString(encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeFile(shareArg: string, pathArg: string, dataArg: Buffer | string): Promise<number> {
|
||||||
|
const buffer = typeof dataArg === 'string' ? plugins.buffer.Buffer.from(dataArg) : dataArg;
|
||||||
|
const result = await this.bridge.sendCommand('writeFile', {
|
||||||
|
connection: this.connection,
|
||||||
|
share: shareArg,
|
||||||
|
path: pathArg,
|
||||||
|
dataBase64: buffer.toString('base64'),
|
||||||
|
});
|
||||||
|
return result.bytesWritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteFile(shareArg: string, pathArg: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('deleteFile', {
|
||||||
|
connection: this.connection,
|
||||||
|
share: shareArg,
|
||||||
|
path: pathArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createDirectory(shareArg: string, pathArg: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('createDirectory', {
|
||||||
|
connection: this.connection,
|
||||||
|
share: shareArg,
|
||||||
|
path: pathArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rename(shareArg: string, fromArg: string, toArg: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('rename', {
|
||||||
|
connection: this.connection,
|
||||||
|
share: shareArg,
|
||||||
|
from: fromArg,
|
||||||
|
to: toArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stat(shareArg: string, pathArg: string): Promise<ISambaFileInfo> {
|
||||||
|
return this.bridge.sendCommand('stat', {
|
||||||
|
connection: this.connection,
|
||||||
|
share: shareArg,
|
||||||
|
path: pathArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SambaServer {
|
||||||
|
private bridge = new SambaBridge();
|
||||||
|
private config: ISambaServerOptions;
|
||||||
|
private startResult?: ISambaServerStartResult;
|
||||||
|
|
||||||
|
constructor(optionsArg: ISambaServerOptions) {
|
||||||
|
this.config = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 445,
|
||||||
|
...optionsArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<ISambaServerStartResult> {
|
||||||
|
this.startResult = await this.bridge.sendCommand('startServer', { config: this.config });
|
||||||
|
return this.startResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.bridge.sendCommand('stopServer', {} as Record<string, never>);
|
||||||
|
} finally {
|
||||||
|
this.bridge.kill();
|
||||||
|
this.startResult = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async status(): Promise<ISambaServerStatus> {
|
||||||
|
return this.bridge.sendCommand('getServerStatus', {} as Record<string, never>);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getConnectionOptions(authArg?: Partial<ISambaAuthOptions>): ISambaClientOptions {
|
||||||
|
if (!this.startResult) {
|
||||||
|
throw new Error('SambaServer is not started');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
host: this.startResult.host,
|
||||||
|
port: this.startResult.port,
|
||||||
|
auth: authArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
export const packageDir = plugins.path.join(
|
||||||
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
|
'../',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const nogitDir = plugins.path.join(packageDir, '.nogit');
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// node native scope
|
||||||
|
import * as buffer from 'node:buffer';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
export { buffer, path };
|
||||||
|
|
||||||
|
// @push.rocks scope
|
||||||
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
|
|
||||||
|
export { smartpath, smartrust };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"dist_*/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user