Initial commit

This commit is contained in:
2026-05-03 10:44:02 +00:00
commit 09ddac4f3d
19 changed files with 10437 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
.nogit/
# artifacts
coverage/
public/
# installs
node_modules/
# caches
.yarn/
.cache/
.rpt2_cache
# builds
dist/
dist_*/
rust/target/
# custom
.claude/*
+32
View File
@@ -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"
}
}
}
+11
View File
@@ -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
+21
View File
@@ -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.
+24
View File
@@ -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"
]
}
}
}
+68
View File
@@ -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"
}
+7516
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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`.
+2
View File
@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
+1662
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"] }
+34
View File
@@ -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);
}
+568
View File
@@ -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(&params.connection, &params.share).await?;
let path = directory_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let from_path = normalize_smb_path(&params.from);
let to_path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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),
}
}
}
+49
View File
@@ -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();
+8
View File
@@ -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
View File
@@ -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,
};
}
}
+8
View File
@@ -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');
+11
View File
@@ -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 };
+16
View File
@@ -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"
]
}