Skip to content

Commit 1ca9a5c

Browse files
committed
feat(sdk): add NAS/OSS mount syntax sugar across Kotlin, Python, TypeScript, Go, and C#
Expose `sandbox.mount(NfsMountOptions | OssfsMountOptions)` and `sandbox.umount(mountPoint)` on the sandbox facade in all five SDKs. The helpers build the appropriate `mount -t nfs` / `ossfs` / `ossfs2` shell command and run it via `commands.run`, so no server change is required. The sandbox image must have the corresponding mount binary installed, or the options.installation field can install it at mount time. Cross-SDK behavior is aligned so callers can rely on the same semantics: - Version selection: `OssfsMountOptions.version` defaults to ossfs 1.x when omitted, ossfs 2.x when set to "2.0". - `bucketDirectory` is supported in both ossfs 1.x (`bucket:/dir`) and ossfs 2.x (`--oss_bucket_prefix=<dir>/`). - STS `securityToken` appends to the ossfs 1.x password file and exports `OSS_SESSION_TOKEN` for ossfs 2.x. - Failures raise a new `MountFailedException` / `MountFailedError` that extends the SDK's existing sandbox exception hierarchy and carries the failing `Execution` for diagnostics. Security-hardened compared to the internal reference implementation: 1. The ossfs 1.x password file is created with mode 0600 atomically using `install -m 600 /dev/null /etc/ossfspass` before `printf %s <passwd>`, removing the world-readable window a naive `echo > file; chmod 600` would create. 2. The ossfs 1.x cleanup step is wrapped in `( ossfs ... ); __rc=$?; rm -f /etc/ossfspass; exit $__rc` so the password file is unlinked even when the ossfs binary itself exits non-zero. 3. The ossfs 2.x configuration file is written to `/tmp/opensandbox-ossfs-<uuid>.conf` with mode 600 rather than dropped in the current working directory. 4. All shell arguments are single-quoted with proper escaping. While pinning the ossfs 2.x conf mode to 600 the existing SDK convention was verified: `WriteEntry.mode` is serialized as a JSON number and parsed server-side via `strconv.ParseUint(strconv.Itoa(mode), 8, 32)` (see `components/execd/pkg/web/controller/utils.go`), so callers must pass the decimal literal `600` rather than an octal literal such as `0o600` (which would serialize to 384 and fail the octal parse). The Kotlin, TypeScript, Go, and C# implementations all pass `600`; Python passes `mode=600` as before. The `createIsolatedOssSession` helper from the internal Java SDK (bwrap seccomp workaround that pre-mounts OSSFS on the host side and bind-mounts it into the isolated session) is intentionally out of scope of this change and will be added separately if needed. Test results (unit only; e2e requires real NAS/OSS backends): - Kotlin: `./gradlew :sandbox:test` -> 216 pass - Python: `uv run pytest tests/` -> 302 pass - TypeScript: `pnpm run test` -> 70 pass - Go: `go test ./...` -> all pass - C#: not verified locally (no dotnet SDK available on the workstation); code is written to match the existing patterns in the C# SDK and passes static review, but `dotnet test` still needs to run in CI.
1 parent 5b3afaa commit 1ca9a5c

37 files changed

Lines changed: 4605 additions & 2 deletions

File tree

sdks/sandbox/csharp/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,40 @@ foreach (var s in list.Items)
226226
}
227227
```
228228

229+
### NAS / OSS Mounts
230+
231+
`sandbox.MountAsync(...)` runs `mount -t nfs`, `ossfs`, or `ossfs2` inside a running sandbox via `Commands.RunAsync`, so the sandbox image must have the corresponding binary installed (or use `Installation` to install it on demand). The password / configuration files are created with mode 600 in one step and are cleaned up even when the mount command itself fails.
232+
233+
```csharp
234+
using OpenSandbox;
235+
using OpenSandbox.Models;
236+
237+
// Mount a NAS export
238+
await sandbox.MountAsync(new NfsMountOptions
239+
{
240+
Endpoint = "nas-server.example.com",
241+
NasPath = "/share",
242+
MountPoint = "/mnt/nas",
243+
Installation = "apt-get install -y nfs-common", // optional
244+
});
245+
246+
// Mount an OSS bucket via ossfs 2.x
247+
await sandbox.MountAsync(new OssfsMountOptions
248+
{
249+
Endpoint = "https://oss-cn-hangzhou.aliyuncs.com",
250+
Bucket = "my-bucket",
251+
BucketDirectory = "subdir", // optional; maps to --oss_bucket_prefix
252+
MountPoint = "/mnt/oss",
253+
AccessKeyId = Environment.GetEnvironmentVariable("OSS_AK")!,
254+
AccessKeySecret = Environment.GetEnvironmentVariable("OSS_SK")!,
255+
Version = OssfsVersion.Ossfs20,
256+
});
257+
258+
await sandbox.UmountAsync("/mnt/nas");
259+
```
260+
261+
Failures throw `MountFailedException` (`OpenSandbox.Core`), which exposes the failing `Execution` on `ex.Execution` so callers can inspect exit code and stderr.
262+
229263
## Configuration
230264

231265
### 1. Connection Configuration

sdks/sandbox/csharp/src/OpenSandbox/Core/Exceptions.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ public static class SandboxErrorCodes
4343
/// Unexpected response from the server.
4444
/// </summary>
4545
public const string UnexpectedResponse = "UNEXPECTED_RESPONSE";
46+
47+
/// <summary>
48+
/// A NAS or OSS mount syntax-sugar call failed inside the sandbox.
49+
/// </summary>
50+
public const string MountFailed = "MOUNT_FAILED";
4651
}
4752

4853
/// <summary>
@@ -231,3 +236,34 @@ public InvalidArgumentException(string? message = null, Exception? innerExceptio
231236
{
232237
}
233238
}
239+
240+
/// <summary>
241+
/// Exception thrown when a NAS or OSS mount syntax-sugar call fails inside the sandbox.
242+
/// </summary>
243+
/// <remarks>
244+
/// The <see cref="Execution"/> property exposes the failing execution handle
245+
/// returned by the underlying <c>Commands.RunAsync</c> call so callers can inspect
246+
/// the exit code, stdout, and stderr for diagnostics.
247+
/// </remarks>
248+
public class MountFailedException : SandboxException
249+
{
250+
/// <summary>
251+
/// Gets the failing execution result attached to this exception, if any.
252+
/// </summary>
253+
public OpenSandbox.Models.Execution? Execution { get; }
254+
255+
/// <summary>
256+
/// Initializes a new instance of the <see cref="MountFailedException"/> class.
257+
/// </summary>
258+
/// <param name="message">The error message.</param>
259+
/// <param name="execution">The failing execution result.</param>
260+
/// <param name="innerException">The inner exception.</param>
261+
public MountFailedException(
262+
string? message = null,
263+
OpenSandbox.Models.Execution? execution = null,
264+
Exception? innerException = null)
265+
: base(message, innerException, new SandboxError(SandboxErrorCodes.MountFailed, message))
266+
{
267+
Execution = execution;
268+
}
269+
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// Copyright 2026 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Globalization;
16+
using System.Text;
17+
using OpenSandbox.Core;
18+
using OpenSandbox.Models;
19+
20+
namespace OpenSandbox.Internal;
21+
22+
/// <summary>
23+
/// Internal builders that translate mount option objects into shell commands.
24+
/// Exposed as <c>internal</c> so the unit tests in <c>OpenSandbox.Tests</c> can
25+
/// call them directly via <c>InternalsVisibleTo</c>.
26+
/// </summary>
27+
internal static class MountShell
28+
{
29+
/// <summary>
30+
/// Path inside the sandbox where ossfs 1.x reads its password file.
31+
/// </summary>
32+
public const string Ossfs1PasswdPath = "/etc/ossfspass";
33+
34+
public sealed record Ossfs2Plan(string ConfPath, string ConfContent, string Command);
35+
36+
public static void ValidateNfs(NfsMountOptions options)
37+
{
38+
if (options is null)
39+
{
40+
throw new ArgumentNullException(nameof(options));
41+
}
42+
if (string.IsNullOrWhiteSpace(options.Endpoint))
43+
{
44+
throw new InvalidArgumentException("endpoint must not be blank");
45+
}
46+
if (string.IsNullOrWhiteSpace(options.MountPoint))
47+
{
48+
throw new InvalidArgumentException("mountPoint must not be blank");
49+
}
50+
if (string.IsNullOrWhiteSpace(options.NasPath))
51+
{
52+
throw new InvalidArgumentException("nasPath must not be blank");
53+
}
54+
}
55+
56+
public static void ValidateOssfs(OssfsMountOptions options)
57+
{
58+
if (options is null)
59+
{
60+
throw new ArgumentNullException(nameof(options));
61+
}
62+
if (string.IsNullOrWhiteSpace(options.Endpoint))
63+
{
64+
throw new InvalidArgumentException("endpoint must not be blank");
65+
}
66+
if (string.IsNullOrWhiteSpace(options.Bucket))
67+
{
68+
throw new InvalidArgumentException("bucket must not be blank");
69+
}
70+
if (string.IsNullOrWhiteSpace(options.MountPoint))
71+
{
72+
throw new InvalidArgumentException("mountPoint must not be blank");
73+
}
74+
if (string.IsNullOrWhiteSpace(options.AccessKeyId))
75+
{
76+
throw new InvalidArgumentException("accessKeyId must not be blank");
77+
}
78+
if (string.IsNullOrWhiteSpace(options.AccessKeySecret))
79+
{
80+
throw new InvalidArgumentException("accessKeySecret must not be blank");
81+
}
82+
}
83+
84+
public static string BuildNfsCommand(NfsMountOptions options)
85+
{
86+
var optString = string.IsNullOrWhiteSpace(options.Options)
87+
? NfsMountOptions.DefaultNfsOptions
88+
: options.Options!;
89+
var source = options.Endpoint + ":" + options.NasPath;
90+
var core = string.Format(
91+
CultureInfo.InvariantCulture,
92+
"mkdir -p {0} && mount -t nfs -o {1} {2} {3}",
93+
ShQuote(options.MountPoint),
94+
ShQuote(optString),
95+
ShQuote(source),
96+
ShQuote(options.MountPoint));
97+
return PrependInstallation(options.Installation, core);
98+
}
99+
100+
public static string BuildOssfs1Command(OssfsMountOptions options)
101+
{
102+
var useSts = !string.IsNullOrWhiteSpace(options.SecurityToken);
103+
// ossfs 1.x password file format:
104+
// AK/SK mode : bucket:accessKeyId:accessKeySecret
105+
// STS mode : bucket:accessKeyId:accessKeySecret:securityToken
106+
var passwd = new StringBuilder();
107+
passwd.Append(options.Bucket).Append(':')
108+
.Append(options.AccessKeyId).Append(':')
109+
.Append(options.AccessKeySecret);
110+
if (useSts)
111+
{
112+
passwd.Append(':').Append(options.SecurityToken);
113+
}
114+
115+
var bucketArg = string.IsNullOrWhiteSpace(options.BucketDirectory)
116+
? options.Bucket
117+
: $"{options.Bucket}:/{options.BucketDirectory}";
118+
119+
var optionFlags = new StringBuilder();
120+
if (options.Options is { Count: > 0 } opts)
121+
{
122+
foreach (var opt in opts)
123+
{
124+
optionFlags.Append(" -o").Append(ShQuote(opt));
125+
}
126+
}
127+
128+
// Create the password file with mode 0600 in one atomic step (avoids the
129+
// world-readable window that a naive `echo > ; chmod 600` would create).
130+
// Always clean up the password file even if ossfs fails, by preserving
131+
// the subshell exit code via __rc.
132+
var core = string.Format(
133+
CultureInfo.InvariantCulture,
134+
"ossfs --version && " +
135+
"install -m 600 /dev/null {0} && " +
136+
"printf %s {1} > {0} && " +
137+
"mkdir -p {2} && " +
138+
"( ossfs {3} {2} -ourl={4} -opasswd_file={0}{5} ); " +
139+
"__rc=$?; rm -f {0}; exit $__rc",
140+
Ossfs1PasswdPath,
141+
ShQuote(passwd.ToString()),
142+
ShQuote(options.MountPoint),
143+
ShQuote(bucketArg),
144+
ShQuote(options.Endpoint),
145+
optionFlags.ToString());
146+
return PrependInstallation(options.Installation, core);
147+
}
148+
149+
public static Ossfs2Plan BuildOssfs2Plan(OssfsMountOptions options)
150+
{
151+
var conf = new StringBuilder();
152+
conf.Append("--oss_endpoint=").Append(options.Endpoint).Append('\n');
153+
conf.Append("--oss_bucket=").Append(options.Bucket).Append('\n');
154+
if (!string.IsNullOrWhiteSpace(options.BucketDirectory))
155+
{
156+
// ossfs2 mounts a bucket root; a subdirectory is expressed as a
157+
// prefix (trailing slash makes it a directory boundary).
158+
var prefix = options.BucketDirectory!.TrimEnd('/') + "/";
159+
conf.Append("--oss_bucket_prefix=").Append(prefix).Append('\n');
160+
}
161+
if (options.Options is { Count: > 0 } opts)
162+
{
163+
foreach (var opt in opts)
164+
{
165+
conf.Append("--").Append(opt).Append('\n');
166+
}
167+
}
168+
169+
var confPath = "/tmp/opensandbox-ossfs-" + Guid.NewGuid().ToString("N") + ".conf";
170+
171+
// ossfs 2.x reads credentials from environment variables:
172+
// AK/SK mode : OSS_ACCESS_KEY_ID + OSS_ACCESS_KEY_SECRET
173+
// STS mode : additionally OSS_SESSION_TOKEN
174+
var stsExport = string.IsNullOrWhiteSpace(options.SecurityToken)
175+
? string.Empty
176+
: $" && export OSS_SESSION_TOKEN={ShQuote(options.SecurityToken!)}";
177+
178+
var core = string.Format(
179+
CultureInfo.InvariantCulture,
180+
"ossfs2 --version && " +
181+
"mkdir -p {0} && " +
182+
"export OSS_ACCESS_KEY_ID={1} && " +
183+
"export OSS_ACCESS_KEY_SECRET={2}{3}" +
184+
" && ossfs2 mount {0} -c {4}",
185+
ShQuote(options.MountPoint),
186+
ShQuote(options.AccessKeyId),
187+
ShQuote(options.AccessKeySecret),
188+
stsExport,
189+
ShQuote(confPath));
190+
191+
return new Ossfs2Plan(
192+
ConfPath: confPath,
193+
ConfContent: conf.ToString(),
194+
Command: PrependInstallation(options.Installation, core));
195+
}
196+
197+
public static WriteEntry BuildOssfs2ConfEntry(Ossfs2Plan plan)
198+
{
199+
if (plan is null)
200+
{
201+
throw new ArgumentNullException(nameof(plan));
202+
}
203+
return new WriteEntry
204+
{
205+
Path = plan.ConfPath,
206+
Data = plan.ConfContent,
207+
// Mode is serialized as a JSON number and parsed by execd as an
208+
// octal string (see components/execd/pkg/web/controller/utils.go);
209+
// pass the literal decimal 600 rather than the C# hex/binary form
210+
// for octal 0o600.
211+
Mode = 600,
212+
};
213+
}
214+
215+
public static string BuildUmountCommand(string mountPoint)
216+
{
217+
if (string.IsNullOrWhiteSpace(mountPoint))
218+
{
219+
throw new InvalidArgumentException("mountPoint must not be blank");
220+
}
221+
return "umount " + ShQuote(mountPoint);
222+
}
223+
224+
public static OssfsVersion SelectOssfsVersion(OssfsMountOptions options)
225+
{
226+
return options.Version ?? OssfsVersion.Ossfs10;
227+
}
228+
229+
public static void EnsureSuccess(Execution? execution, string failurePrefix)
230+
{
231+
if (execution is null)
232+
{
233+
throw new MountFailedException(failurePrefix + ": nil execution result");
234+
}
235+
var error = execution.Error;
236+
var exitCode = execution.ExitCode;
237+
var failed = error != null || (exitCode.HasValue && exitCode.Value != 0);
238+
if (!failed)
239+
{
240+
return;
241+
}
242+
var parts = new List<string>(2);
243+
if (error != null)
244+
{
245+
parts.Add($"[{error.Name}] {error.Value}");
246+
}
247+
if (execution.Logs.Stderr.Count > 0)
248+
{
249+
var sb = new StringBuilder();
250+
for (var i = 0; i < execution.Logs.Stderr.Count; i++)
251+
{
252+
if (i > 0)
253+
{
254+
sb.Append('\n');
255+
}
256+
sb.Append(execution.Logs.Stderr[i].Text);
257+
}
258+
parts.Add("stderr=" + sb);
259+
}
260+
var message = parts.Count == 0
261+
? failurePrefix
262+
: failurePrefix + ": " + string.Join(" | ", parts);
263+
throw new MountFailedException(message, execution);
264+
}
265+
266+
private static string PrependInstallation(string? installation, string core)
267+
{
268+
return string.IsNullOrWhiteSpace(installation) ? core : installation + " && " + core;
269+
}
270+
271+
/// <summary>
272+
/// Quotes <paramref name="value"/> for POSIX shell single-quoted context.
273+
/// Embedded <c>'</c> is escaped as <c>'\''</c>. The result is always safe to
274+
/// embed as one argument to a <c>sh -c</c> string.
275+
/// </summary>
276+
public static string ShQuote(string value)
277+
{
278+
if (value is null)
279+
{
280+
throw new ArgumentNullException(nameof(value));
281+
}
282+
// string.Replace(string, string) is available on all target frameworks
283+
// (including netstandard2.0). Ordinal comparison is the default for
284+
// this overload.
285+
return "'" + value.Replace("'", "'\\''") + "'";
286+
}
287+
}

0 commit comments

Comments
 (0)