Skip to content

Commit dfec96d

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 dfec96d

37 files changed

Lines changed: 4793 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: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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+
/// Prefix for the per-call ossfs 1.x password file under <c>/tmp</c>. A
31+
/// unique suffix is appended per mount so that concurrent ossfs 1.x
32+
/// mounts in the same sandbox do not overwrite or delete each other's
33+
/// credentials.
34+
/// </summary>
35+
public const string Ossfs1PasswdPathPrefix = "/tmp/opensandbox-ossfspass-";
36+
37+
public sealed record Ossfs2Plan(string ConfPath, string ConfContent, string Command);
38+
39+
public static void ValidateNfs(NfsMountOptions options)
40+
{
41+
if (options is null)
42+
{
43+
throw new ArgumentNullException(nameof(options));
44+
}
45+
if (string.IsNullOrWhiteSpace(options.Endpoint))
46+
{
47+
throw new InvalidArgumentException("endpoint must not be blank");
48+
}
49+
if (string.IsNullOrWhiteSpace(options.MountPoint))
50+
{
51+
throw new InvalidArgumentException("mountPoint must not be blank");
52+
}
53+
if (string.IsNullOrWhiteSpace(options.NasPath))
54+
{
55+
throw new InvalidArgumentException("nasPath must not be blank");
56+
}
57+
}
58+
59+
public static void ValidateOssfs(OssfsMountOptions options)
60+
{
61+
if (options is null)
62+
{
63+
throw new ArgumentNullException(nameof(options));
64+
}
65+
if (string.IsNullOrWhiteSpace(options.Endpoint))
66+
{
67+
throw new InvalidArgumentException("endpoint must not be blank");
68+
}
69+
if (string.IsNullOrWhiteSpace(options.Bucket))
70+
{
71+
throw new InvalidArgumentException("bucket must not be blank");
72+
}
73+
if (string.IsNullOrWhiteSpace(options.MountPoint))
74+
{
75+
throw new InvalidArgumentException("mountPoint must not be blank");
76+
}
77+
if (string.IsNullOrWhiteSpace(options.AccessKeyId))
78+
{
79+
throw new InvalidArgumentException("accessKeyId must not be blank");
80+
}
81+
if (string.IsNullOrWhiteSpace(options.AccessKeySecret))
82+
{
83+
throw new InvalidArgumentException("accessKeySecret must not be blank");
84+
}
85+
}
86+
87+
public static string BuildNfsCommand(NfsMountOptions options)
88+
{
89+
var optString = string.IsNullOrWhiteSpace(options.Options)
90+
? NfsMountOptions.DefaultNfsOptions
91+
: options.Options!;
92+
var source = options.Endpoint + ":" + options.NasPath;
93+
var core = string.Format(
94+
CultureInfo.InvariantCulture,
95+
"mkdir -p {0} && mount -t nfs -o {1} {2} {3}",
96+
ShQuote(options.MountPoint),
97+
ShQuote(optString),
98+
ShQuote(source),
99+
ShQuote(options.MountPoint));
100+
return PrependInstallation(options.Installation, core);
101+
}
102+
103+
public static string BuildOssfs1Command(OssfsMountOptions options)
104+
{
105+
var useSts = !string.IsNullOrWhiteSpace(options.SecurityToken);
106+
// ossfs 1.x password file format:
107+
// AK/SK mode : bucket:accessKeyId:accessKeySecret
108+
// STS mode : bucket:accessKeyId:accessKeySecret:securityToken
109+
var passwd = new StringBuilder();
110+
passwd.Append(options.Bucket).Append(':')
111+
.Append(options.AccessKeyId).Append(':')
112+
.Append(options.AccessKeySecret);
113+
if (useSts)
114+
{
115+
passwd.Append(':').Append(options.SecurityToken);
116+
}
117+
118+
var bucketArg = string.IsNullOrWhiteSpace(options.BucketDirectory)
119+
? options.Bucket
120+
: $"{options.Bucket}:/{options.BucketDirectory}";
121+
122+
var optionFlags = new StringBuilder();
123+
if (options.Options is { Count: > 0 } opts)
124+
{
125+
foreach (var opt in opts)
126+
{
127+
optionFlags.Append(" -o").Append(ShQuote(opt));
128+
}
129+
}
130+
131+
// Use a unique per-call password file under /tmp so that concurrent
132+
// ossfs 1.x mounts do not overwrite or delete each other's credentials.
133+
// Create it with mode 0600 in one atomic step (avoids the world-readable
134+
// window that a naive `echo > ; chmod 600` would create). Always clean
135+
// it up even if ossfs fails, by preserving the subshell exit code
136+
// via __rc.
137+
var passwdPath = Ossfs1PasswdPathPrefix + Guid.NewGuid().ToString("N");
138+
var quotedPasswdPath = ShQuote(passwdPath);
139+
var core = string.Format(
140+
CultureInfo.InvariantCulture,
141+
"ossfs --version && " +
142+
"install -m 600 /dev/null {0} && " +
143+
"printf %s {1} > {0} && " +
144+
"mkdir -p {2} && " +
145+
"( ossfs {3} {2} -ourl={4} -opasswd_file={0}{5} ); " +
146+
"__rc=$?; rm -f {0}; exit $__rc",
147+
quotedPasswdPath,
148+
ShQuote(passwd.ToString()),
149+
ShQuote(options.MountPoint),
150+
ShQuote(bucketArg),
151+
ShQuote(options.Endpoint),
152+
optionFlags);
153+
return PrependInstallation(options.Installation, core);
154+
}
155+
156+
public static Ossfs2Plan BuildOssfs2Plan(OssfsMountOptions options)
157+
{
158+
var conf = new StringBuilder();
159+
conf.Append("--oss_endpoint=").Append(options.Endpoint).Append('\n');
160+
conf.Append("--oss_bucket=").Append(options.Bucket).Append('\n');
161+
if (!string.IsNullOrWhiteSpace(options.BucketDirectory))
162+
{
163+
// ossfs2 mounts a bucket root; a subdirectory is expressed as a
164+
// prefix (trailing slash makes it a directory boundary).
165+
var prefix = options.BucketDirectory!.TrimEnd('/') + "/";
166+
conf.Append("--oss_bucket_prefix=").Append(prefix).Append('\n');
167+
}
168+
if (options.Options is { Count: > 0 } opts)
169+
{
170+
foreach (var opt in opts)
171+
{
172+
conf.Append("--").Append(opt).Append('\n');
173+
}
174+
}
175+
176+
var confPath = "/tmp/opensandbox-ossfs-" + Guid.NewGuid().ToString("N") + ".conf";
177+
178+
// ossfs 2.x reads credentials from environment variables:
179+
// AK/SK mode : OSS_ACCESS_KEY_ID + OSS_ACCESS_KEY_SECRET
180+
// STS mode : additionally OSS_SESSION_TOKEN
181+
var stsExport = string.IsNullOrWhiteSpace(options.SecurityToken)
182+
? string.Empty
183+
: $" && export OSS_SESSION_TOKEN={ShQuote(options.SecurityToken!)}";
184+
185+
var core = string.Format(
186+
CultureInfo.InvariantCulture,
187+
"ossfs2 --version && " +
188+
"mkdir -p {0} && " +
189+
"export OSS_ACCESS_KEY_ID={1} && " +
190+
"export OSS_ACCESS_KEY_SECRET={2}{3}" +
191+
" && ossfs2 mount {0} -c {4}",
192+
ShQuote(options.MountPoint),
193+
ShQuote(options.AccessKeyId),
194+
ShQuote(options.AccessKeySecret),
195+
stsExport,
196+
ShQuote(confPath));
197+
198+
return new Ossfs2Plan(
199+
ConfPath: confPath,
200+
ConfContent: conf.ToString(),
201+
Command: PrependInstallation(options.Installation, core));
202+
}
203+
204+
public static WriteEntry BuildOssfs2ConfEntry(Ossfs2Plan plan)
205+
{
206+
if (plan is null)
207+
{
208+
throw new ArgumentNullException(nameof(plan));
209+
}
210+
return new WriteEntry
211+
{
212+
Path = plan.ConfPath,
213+
Data = plan.ConfContent,
214+
// Mode is serialized as a JSON number and parsed by execd as an
215+
// octal string (see components/execd/pkg/web/controller/utils.go);
216+
// pass the literal decimal 600 rather than the C# hex/binary form
217+
// for octal 0o600.
218+
Mode = 600,
219+
};
220+
}
221+
222+
public static string BuildUmountCommand(string mountPoint)
223+
{
224+
if (string.IsNullOrWhiteSpace(mountPoint))
225+
{
226+
throw new InvalidArgumentException("mountPoint must not be blank");
227+
}
228+
return "umount " + ShQuote(mountPoint);
229+
}
230+
231+
public static OssfsVersion SelectOssfsVersion(OssfsMountOptions options)
232+
{
233+
return options.Version ?? OssfsVersion.Ossfs10;
234+
}
235+
236+
public static void EnsureSuccess(Execution? execution, string failurePrefix)
237+
{
238+
if (execution is null)
239+
{
240+
throw new MountFailedException(failurePrefix + ": nil execution result");
241+
}
242+
var error = execution.Error;
243+
var exitCode = execution.ExitCode;
244+
var failed = error != null || (exitCode.HasValue && exitCode.Value != 0);
245+
if (!failed)
246+
{
247+
return;
248+
}
249+
var parts = new List<string>(2);
250+
if (error != null)
251+
{
252+
parts.Add($"[{error.Name}] {error.Value}");
253+
}
254+
if (execution.Logs.Stderr.Count > 0)
255+
{
256+
var sb = new StringBuilder();
257+
for (var i = 0; i < execution.Logs.Stderr.Count; i++)
258+
{
259+
if (i > 0)
260+
{
261+
sb.Append('\n');
262+
}
263+
sb.Append(execution.Logs.Stderr[i].Text);
264+
}
265+
parts.Add("stderr=" + sb);
266+
}
267+
var message = parts.Count == 0
268+
? failurePrefix
269+
: failurePrefix + ": " + string.Join(" | ", parts);
270+
throw new MountFailedException(message, execution);
271+
}
272+
273+
private static string PrependInstallation(string? installation, string core)
274+
{
275+
return string.IsNullOrWhiteSpace(installation) ? core : installation + " && " + core;
276+
}
277+
278+
/// <summary>
279+
/// Quotes <paramref name="value"/> for POSIX shell single-quoted context.
280+
/// Embedded <c>'</c> is escaped as <c>'\''</c>. The result is always safe to
281+
/// embed as one argument to a <c>sh -c</c> string.
282+
/// </summary>
283+
public static string ShQuote(string value)
284+
{
285+
if (value is null)
286+
{
287+
throw new ArgumentNullException(nameof(value));
288+
}
289+
// string.Replace(string, string) is available on all target frameworks
290+
// (including netstandard2.0). Ordinal comparison is the default for
291+
// this overload.
292+
return "'" + value.Replace("'", "'\\''") + "'";
293+
}
294+
}

0 commit comments

Comments
 (0)