@synopackageland/cli 0.3.10 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/dist/bin.js +1 -2
- package/dist/build-spk/generator.d.ts +0 -3
- package/dist/build-spk/generator.js +135 -287
- package/dist/build-spk/native-lifecycle.js +0 -3
- package/dist/build-spk/native-payload.js +0 -1
- package/dist/cli.js +171 -151
- package/dist/compose/analyze.d.ts +1 -2
- package/dist/compose/analyze.js +5 -48
- package/dist/contract/unsupported-types.d.ts +10 -0
- package/dist/contract/unsupported-types.js +49 -0
- package/dist/contract/validate.js +25 -2
- package/dist/contract/web-launch.js +0 -2
- package/dist/deploy/deploy.d.ts +1 -4
- package/dist/deploy/deploy.js +45 -156
- package/dist/deploy/homes-values.js +1 -1
- package/dist/deploy/plan.d.ts +18 -0
- package/dist/deploy/plan.js +54 -0
- package/dist/deploy/spk-version.d.ts +2 -14
- package/dist/deploy/spk-version.js +25 -111
- package/dist/dev.d.ts +1 -3
- package/dist/dev.js +14 -28
- package/dist/error-registry.js +810 -862
- package/dist/host/dsm-client.js +12 -272
- package/dist/host/dsm-port-profile.d.ts +0 -1
- package/dist/host/dsm-port-profile.js +0 -2
- package/dist/host/types.d.ts +1 -83
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -6
- package/dist/info.d.ts +0 -1
- package/dist/info.js +3 -10
- package/dist/init.d.ts +11 -1
- package/dist/init.js +112 -4
- package/dist/log.js +2 -3
- package/dist/native/validate.js +0 -1
- package/dist/standalone/binary.d.ts +9 -0
- package/dist/standalone/binary.js +67 -0
- package/dist/standalone/build.d.ts +25 -0
- package/dist/standalone/build.js +108 -0
- package/dist/standalone/dsm-window.d.ts +26 -0
- package/dist/standalone/dsm-window.js +251 -0
- package/dist/standalone/helper.d.ts +31 -0
- package/dist/standalone/helper.js +104 -0
- package/dist/standalone/i18n.d.ts +28 -0
- package/dist/standalone/i18n.js +161 -0
- package/dist/standalone/inspect-spk.d.ts +21 -0
- package/dist/standalone/inspect-spk.js +160 -0
- package/dist/standalone/manifest.d.ts +71 -0
- package/dist/standalone/manifest.js +276 -0
- package/dist/standalone/root-messages.d.ts +7 -0
- package/dist/standalone/root-messages.js +250 -0
- package/dist/standalone/root-ops.d.ts +42 -0
- package/dist/standalone/root-ops.js +101 -0
- package/dist/standalone/root-window.d.ts +35 -0
- package/dist/standalone/root-window.js +290 -0
- package/dist/standalone/spk.d.ts +22 -0
- package/dist/standalone/spk.js +205 -0
- package/dist/templates/binary/synology-app.yaml +7 -0
- package/dist/templates/hello-files/compose.yaml +0 -1
- package/dist/templates/hello-web/compose.yaml +13 -10
- package/dist/templates/hello-web/html/app.js +1 -23
- package/dist/templates/hello-web/html/index.html +1 -3
- package/dist/templates/hello-web-page/compose.yaml +13 -10
- package/dist/templates/hello-web-page/html/app.js +1 -23
- package/dist/templates/hello-web-page/html/index.html +1 -3
- package/dist/templates/native-go/README.md +268 -0
- package/dist/templates/native-go/backend/cmd/app/main.go +49 -0
- package/dist/templates/native-go/backend/cmd/helper/main.go +38 -0
- package/dist/templates/native-go/backend/go.mod +3 -0
- package/dist/templates/native-go/backend/internal/api/errors.go +33 -0
- package/dist/templates/native-go/backend/internal/api/registry.go +94 -0
- package/dist/templates/native-go/backend/internal/api/registry_test.go +329 -0
- package/dist/templates/native-go/backend/internal/api/server.go +155 -0
- package/dist/templates/native-go/backend/internal/app/root_none.go +9 -0
- package/dist/templates/native-go/backend/internal/app/routes.go +127 -0
- package/dist/templates/native-go/backend/internal/app/routes_test.go +167 -0
- package/dist/templates/native-go/backend/internal/dsm/auth.go +118 -0
- package/dist/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
- package/dist/templates/native-go/backend/internal/helper/control.go +219 -0
- package/dist/templates/native-go/backend/internal/helper/control_test.go +242 -0
- package/dist/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
- package/dist/templates/native-go/backend/internal/helper/peer_other.go +7 -0
- package/dist/templates/native-go/backend/internal/helper/process_linux.go +167 -0
- package/dist/templates/native-go/backend/internal/helper/process_other.go +5 -0
- package/dist/templates/native-go/backend/internal/helper/server_linux.go +83 -0
- package/dist/templates/native-go/backend/internal/helper/status.go +271 -0
- package/dist/templates/native-go/backend/internal/helper/status_test.go +205 -0
- package/dist/templates/native-go/backend/internal/helper/supervision.go +84 -0
- package/dist/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
- package/dist/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
- package/dist/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
- package/dist/templates/native-go/dsm-package.yaml +58 -0
- package/dist/templates/native-go/gitignore +1 -0
- package/dist/templates/native-go/i18n/glossary.json +662 -0
- package/dist/templates/native-go/i18n/messages/de.json +28 -0
- package/dist/templates/native-go/i18n/messages/en.json +28 -0
- package/dist/templates/native-go/i18n/messages/es.json +28 -0
- package/dist/templates/native-go/i18n/messages/fr.json +28 -0
- package/dist/templates/native-go/i18n/messages/it.json +28 -0
- package/dist/templates/native-go/i18n/messages/ja.json +28 -0
- package/dist/templates/native-go/i18n/messages/ko.json +28 -0
- package/dist/templates/native-go/i18n/messages/zh-CN.json +28 -0
- package/dist/templates/native-go/i18n/messages/zh-TW.json +28 -0
- package/dist/templates/native-go/ui/app.css +35 -0
- package/dist/templates/native-go/ui/app.js +94 -0
- package/dist/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
- package/dist/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
- package/dist/templates/native-go-better-auth/node/app.mjs +72 -0
- package/dist/templates/native-go-better-auth/node/build.mjs +2 -0
- package/dist/templates/native-go-better-auth/node/package-lock.json +862 -0
- package/dist/templates/native-go-better-auth/node/package.json +1 -0
- package/dist/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
- package/dist/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
- package/dist/templates/native-go-integration/backend/internal/app/personal.go +203 -0
- package/dist/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
- package/dist/templates/native-go-root/backend/internal/app/root.go +107 -0
- package/dist/templates/native-go-root/backend/internal/app/root_test.go +124 -0
- package/dist/templates/native-go-root/root/whoami.sh +6 -0
- package/dist/templates/native-go-vue/README.md +110 -0
- package/dist/templates/native-go-vue/frontend/DESIGN.md +78 -0
- package/dist/templates/native-go-vue/frontend/package-lock.json +2507 -0
- package/dist/templates/native-go-vue/frontend/package.json +6 -0
- package/dist/templates/native-go-vue/frontend/src/App.vue +132 -0
- package/dist/templates/native-go-vue/frontend/src/context.ts +17 -0
- package/dist/templates/native-go-vue/frontend/src/main.ts +26 -0
- package/dist/templates/native-go-vue/frontend/src/style.css +14 -0
- package/dist/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
- package/dist/templates/native-go-vue/frontend/tsconfig.json +1 -0
- package/dist/templates/native-go-vue/frontend/vite.config.ts +8 -0
- package/dist/templates/native-go-vue/gitignore +2 -0
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +11 -7
- package/package.json +3 -7
- package/schema/synology-app.schema.json +36 -37
- package/skills/SKILL.md +4 -48
- package/skills/reference/create.md +11 -83
- package/skills/reference/deploy.md +7 -78
- package/skills/reference/diagnose.md +4 -101
- package/skills/reference/files.md +5 -41
- package/skills/reference/identity.md +3 -29
- package/skills/reference/wrap.md +0 -2
- package/templates/binary/synology-app.yaml +7 -0
- package/templates/hello-files/compose.yaml +0 -1
- package/templates/hello-web/compose.yaml +13 -10
- package/templates/hello-web/html/app.js +1 -23
- package/templates/hello-web/html/index.html +1 -3
- package/templates/hello-web-page/compose.yaml +13 -10
- package/templates/hello-web-page/html/app.js +1 -23
- package/templates/hello-web-page/html/index.html +1 -3
- package/templates/native-go/README.md +268 -0
- package/templates/native-go/backend/cmd/app/main.go +49 -0
- package/templates/native-go/backend/cmd/helper/main.go +38 -0
- package/templates/native-go/backend/go.mod +3 -0
- package/templates/native-go/backend/internal/api/errors.go +33 -0
- package/templates/native-go/backend/internal/api/registry.go +94 -0
- package/templates/native-go/backend/internal/api/registry_test.go +329 -0
- package/templates/native-go/backend/internal/api/server.go +155 -0
- package/templates/native-go/backend/internal/app/root_none.go +9 -0
- package/templates/native-go/backend/internal/app/routes.go +127 -0
- package/templates/native-go/backend/internal/app/routes_test.go +167 -0
- package/templates/native-go/backend/internal/dsm/auth.go +118 -0
- package/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
- package/templates/native-go/backend/internal/helper/control.go +219 -0
- package/templates/native-go/backend/internal/helper/control_test.go +242 -0
- package/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
- package/templates/native-go/backend/internal/helper/peer_other.go +7 -0
- package/templates/native-go/backend/internal/helper/process_linux.go +167 -0
- package/templates/native-go/backend/internal/helper/process_other.go +5 -0
- package/templates/native-go/backend/internal/helper/server_linux.go +83 -0
- package/templates/native-go/backend/internal/helper/status.go +271 -0
- package/templates/native-go/backend/internal/helper/status_test.go +205 -0
- package/templates/native-go/backend/internal/helper/supervision.go +84 -0
- package/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
- package/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
- package/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
- package/templates/native-go/dsm-package.yaml +58 -0
- package/templates/native-go/gitignore +1 -0
- package/templates/native-go/i18n/glossary.json +662 -0
- package/templates/native-go/i18n/messages/de.json +28 -0
- package/templates/native-go/i18n/messages/en.json +28 -0
- package/templates/native-go/i18n/messages/es.json +28 -0
- package/templates/native-go/i18n/messages/fr.json +28 -0
- package/templates/native-go/i18n/messages/it.json +28 -0
- package/templates/native-go/i18n/messages/ja.json +28 -0
- package/templates/native-go/i18n/messages/ko.json +28 -0
- package/templates/native-go/i18n/messages/zh-CN.json +28 -0
- package/templates/native-go/i18n/messages/zh-TW.json +28 -0
- package/templates/native-go/ui/app.css +35 -0
- package/templates/native-go/ui/app.js +94 -0
- package/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
- package/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
- package/templates/native-go-better-auth/node/app.mjs +72 -0
- package/templates/native-go-better-auth/node/build.mjs +2 -0
- package/templates/native-go-better-auth/node/package-lock.json +862 -0
- package/templates/native-go-better-auth/node/package.json +1 -0
- package/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
- package/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
- package/templates/native-go-integration/backend/internal/app/personal.go +203 -0
- package/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
- package/templates/native-go-root/backend/internal/app/root.go +107 -0
- package/templates/native-go-root/backend/internal/app/root_test.go +124 -0
- package/templates/native-go-root/root/whoami.sh +6 -0
- package/templates/native-go-vue/README.md +110 -0
- package/templates/native-go-vue/frontend/DESIGN.md +78 -0
- package/templates/native-go-vue/frontend/package-lock.json +2507 -0
- package/templates/native-go-vue/frontend/package.json +6 -0
- package/templates/native-go-vue/frontend/src/App.vue +132 -0
- package/templates/native-go-vue/frontend/src/context.ts +17 -0
- package/templates/native-go-vue/frontend/src/main.ts +26 -0
- package/templates/native-go-vue/frontend/src/style.css +14 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
- package/templates/native-go-vue/frontend/tsconfig.json +1 -0
- package/templates/native-go-vue/frontend/vite.config.ts +8 -0
- package/templates/native-go-vue/gitignore +2 -0
- package/dist/attestation/app-package.d.ts +0 -86
- package/dist/attestation/app-package.js +0 -206
- package/dist/attestation/install-on-host.d.ts +0 -14
- package/dist/attestation/install-on-host.js +0 -52
- package/dist/attestation/keyring.d.ts +0 -21
- package/dist/attestation/keyring.js +0 -168
- package/dist/attestation/platform-service.d.ts +0 -70
- package/dist/attestation/platform-service.js +0 -250
- package/dist/build-spk/broker-glue.d.ts +0 -16
- package/dist/build-spk/broker-glue.js +0 -40
- package/dist/deploy/catalog-publish.d.ts +0 -3
- package/dist/deploy/catalog-publish.js +0 -7
- package/dist/doctor.d.ts +0 -22
- package/dist/doctor.js +0 -822
- package/dist/error-collector.d.ts +0 -2
- package/dist/error-collector.js +0 -46
- package/dist/host/broker-paths.d.ts +0 -1
- package/dist/host/broker-paths.js +0 -1
- package/dist/host/broker-upgrade-preflight.d.ts +0 -61
- package/dist/host/broker-upgrade-preflight.js +0 -156
- package/dist/host/digests.d.ts +0 -5
- package/dist/host/digests.js +0 -63
- package/dist/host/fake-nas.d.ts +0 -49
- package/dist/host/fake-nas.js +0 -193
- package/dist/mock-broker/bootstrap.d.ts +0 -2
- package/dist/mock-broker/bootstrap.js +0 -8
- package/dist/mock-broker/files.d.ts +0 -60
- package/dist/mock-broker/files.js +0 -408
- package/dist/mock-broker/oidc.d.ts +0 -31
- package/dist/mock-broker/oidc.js +0 -161
- package/dist/mock-broker/path.d.ts +0 -9
- package/dist/mock-broker/path.js +0 -50
- package/dist/mock-broker/server.d.ts +0 -19
- package/dist/mock-broker/server.js +0 -350
- package/dist/publish/build-ledger.d.ts +0 -22
- package/dist/publish/build-ledger.js +0 -26
- package/dist/publish/community-path.d.ts +0 -6
- package/dist/publish/community-path.js +0 -41
- package/dist/publish/curated-release.d.ts +0 -78
- package/dist/publish/curated-release.js +0 -883
- package/dist/publish/file-build-ledger.d.ts +0 -18
- package/dist/publish/file-build-ledger.js +0 -268
- package/dist/publish/github-pr.d.ts +0 -25
- package/dist/publish/github-pr.js +0 -85
- package/dist/publish/github-repo.d.ts +0 -8
- package/dist/publish/github-repo.js +0 -21
- package/dist/publish/platform-service-release.d.ts +0 -60
- package/dist/publish/platform-service-release.js +0 -333
- package/dist/publish/preflight.d.ts +0 -24
- package/dist/publish/preflight.js +0 -138
- package/dist/publish/publish.d.ts +0 -27
- package/dist/publish/publish.js +0 -136
- package/dist/publish/sync-definition.d.ts +0 -4
- package/dist/publish/sync-definition.js +0 -48
- package/dist/release-bin.d.ts +0 -2
- package/dist/release-bin.js +0 -23
- package/dist/templates/hello-web/html/sdk.js +0 -68
- package/dist/templates/hello-web-page/html/sdk.js +0 -68
- package/dist/templates/native/synology-app.yaml +0 -7
- package/dist/test-host.d.ts +0 -15
- package/dist/test-host.js +0 -37
- package/templates/hello-web/html/sdk.js +0 -68
- package/templates/hello-web-page/html/sdk.js +0 -68
- package/templates/native/synology-app.yaml +0 -7
- /package/dist/templates/{native → binary}/native.yaml +0 -0
- /package/templates/{native → binary}/native.yaml +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"native-dsm-better-auth","private":true,"type":"module","scripts":{"build":"node build.mjs","test":"node --test test/*.test.mjs"},"dependencies":{"better-auth":"1.7.7","@better-auth/memory-adapter":"1.7.7"},"devDependencies":{"esbuild":"0.25.12"},"engines":{"node":">=22"}}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {makeApp} from '../app.mjs';
|
|
4
|
+
const p1={name:'normal1',uid:'1001',gid:'100'},p2={name:'normal2',uid:'1002',gid:'100'};
|
|
5
|
+
const state=()=>({secret:'fixture-secret-'.repeat(4),db:{user:[],session:[],account:[],verification:[]},bindings:{}});
|
|
6
|
+
const cookie=r=>r.cookies.map(v=>v.split(';')[0]).join('; ');
|
|
7
|
+
test('single-use native tickets bind Better Auth to a freshly authenticated principal',async()=>{
|
|
8
|
+
const store=state(),app=makeApp({packageID:'Alpha',store,ticket:'one',principal:p1});
|
|
9
|
+
const login=await app.handle('login','one');assert.equal(login.status,200);assert.equal(login.body.user,'normal1');
|
|
10
|
+
const repeated=await app.handle('login','one');assert.equal(repeated.status,401);
|
|
11
|
+
const session=cookie(login);assert.ok(session);
|
|
12
|
+
const fresh=makeApp({packageID:'Alpha',store,ticket:'two',principal:p1});
|
|
13
|
+
assert.equal((await fresh.handle('current','two',session)).status,200);
|
|
14
|
+
const crossed=makeApp({packageID:'Alpha',store,ticket:'three',principal:p2});
|
|
15
|
+
assert.equal((await crossed.handle('current','three',session)).body.error,'principal_mismatch');
|
|
16
|
+
});
|
|
17
|
+
test('store survives a CGI restart and refuses expired or foreign-package sessions',async()=>{
|
|
18
|
+
const store=state(),a=makeApp({packageID:'Alpha',store,ticket:'one',principal:p1});
|
|
19
|
+
const login=await a.handle('login','one'),session=cookie(login);
|
|
20
|
+
const restored=JSON.parse(JSON.stringify(store));
|
|
21
|
+
assert.equal((await makeApp({packageID:'Alpha',store:restored,ticket:'two',principal:p1}).handle('current','two',session)).status,200);
|
|
22
|
+
assert.equal((await makeApp({packageID:'Beta',store:restored,ticket:'three',principal:p1}).handle('current','three',session)).status,401);
|
|
23
|
+
restored.db.session[0].expiresAt=new Date(0).toISOString();
|
|
24
|
+
assert.equal((await makeApp({packageID:'Alpha',store:restored,ticket:'four',principal:p1}).handle('current','four',session)).status,401);
|
|
25
|
+
});
|
|
26
|
+
test('logout revokes app session and does not create a verified placeholder identity',async()=>{
|
|
27
|
+
const store=state();const login=await makeApp({packageID:'Alpha',store,ticket:'one',principal:p1}).handle('login','one');const session=cookie(login);
|
|
28
|
+
assert.equal(store.db.user[0].emailVerified,false);
|
|
29
|
+
assert.equal((await makeApp({packageID:'Alpha',store,ticket:'two',principal:p1}).handle('logout','two',session)).status,200);
|
|
30
|
+
assert.equal((await makeApp({packageID:'Alpha',store,ticket:'three',principal:p1}).handle('current','three',session)).status,401);
|
|
31
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"io"
|
|
10
|
+
"io/fs"
|
|
11
|
+
"os"
|
|
12
|
+
"os/user"
|
|
13
|
+
"syscall"
|
|
14
|
+
"unicode/utf8"
|
|
15
|
+
|
|
16
|
+
"dsmapp/internal/api"
|
|
17
|
+
"dsmapp/internal/helper"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// This example stores one synthetic note per authenticated local account in
|
|
21
|
+
// package-owned data. It is not a shared-folder grant or a NAS file gateway.
|
|
22
|
+
// The client may send an opaque ID, never a user name, UID or filesystem path.
|
|
23
|
+
var personalLookup = user.Lookup
|
|
24
|
+
var integrationHealth = func(ctx context.Context, cfg Config) helper.State {
|
|
25
|
+
return helper.NewClient(cfg.HelperInventory).Status(ctx)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func (cfg Config) integrationRoutes() []api.Route {
|
|
29
|
+
if cfg.HelperInventory.Protocol != 1 {
|
|
30
|
+
return nil
|
|
31
|
+
}
|
|
32
|
+
return append([]api.Route{api.User("personal.list", cfg.personalList), api.User("personal.read", cfg.personalRead), api.User("personal.write", cfg.personalWrite)}, cfg.identityRoutes()...)
|
|
33
|
+
}
|
|
34
|
+
func (cfg Config) personalID(ctx context.Context, caller api.Caller) (string, error) {
|
|
35
|
+
if !caller.Authenticated() {
|
|
36
|
+
return "", &api.Error{Code: "session_required", Message: "Sign in to DSM again"}
|
|
37
|
+
}
|
|
38
|
+
if integrationHealth(ctx, cfg).State != "ready" {
|
|
39
|
+
return "", &api.Error{Code: "integration_unavailable", Message: "Ask an administrator to activate or recover integration"}
|
|
40
|
+
}
|
|
41
|
+
// Re-resolve the authenticated name on every request. No browser-supplied
|
|
42
|
+
// identity is used; directory users and account lifecycle need M2 validation.
|
|
43
|
+
u, err := personalLookup(caller.User())
|
|
44
|
+
if err != nil || u.Username != caller.User() || u.Uid == "" || u.Gid == "" {
|
|
45
|
+
return "", &api.Error{Code: "identity_unavailable", Message: "Local account identity is unavailable"}
|
|
46
|
+
}
|
|
47
|
+
digest := sha256.Sum256([]byte(cfg.PackageID + "\x00" + caller.User() + "\x00" + u.Uid + "\x00" + u.Gid))
|
|
48
|
+
return hex.EncodeToString(digest[:]), nil
|
|
49
|
+
}
|
|
50
|
+
func (cfg Config) personalList(ctx context.Context, caller api.Caller, params json.RawMessage) (any, error) {
|
|
51
|
+
if err := decodeParams(params, &struct{}{}); err != nil {
|
|
52
|
+
return nil, err
|
|
53
|
+
}
|
|
54
|
+
id, err := cfg.personalID(ctx, caller)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
return map[string]any{"items": []map[string]string{{"id": id, "name": "My sample note"}}, "scope": "package-owned synthetic data"}, nil
|
|
59
|
+
}
|
|
60
|
+
func (cfg Config) personalRead(ctx context.Context, caller api.Caller, params json.RawMessage) (any, error) {
|
|
61
|
+
var in struct {
|
|
62
|
+
ID string `json:"id"`
|
|
63
|
+
}
|
|
64
|
+
if err := decodeParams(params, &in); err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
id, err := cfg.personalID(ctx, caller)
|
|
68
|
+
if err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
if in.ID != id {
|
|
72
|
+
return nil, personalDenied()
|
|
73
|
+
}
|
|
74
|
+
text, err := cfg.personalFile(id, nil)
|
|
75
|
+
if err != nil {
|
|
76
|
+
return nil, personalUnavailable()
|
|
77
|
+
}
|
|
78
|
+
if text == nil {
|
|
79
|
+
return map[string]string{"id": id, "text": "Private synthetic sample for " + caller.User()}, nil
|
|
80
|
+
}
|
|
81
|
+
return map[string]string{"id": id, "text": *text}, nil
|
|
82
|
+
}
|
|
83
|
+
func (cfg Config) personalWrite(ctx context.Context, caller api.Caller, params json.RawMessage) (any, error) {
|
|
84
|
+
var in struct {
|
|
85
|
+
ID string `json:"id"`
|
|
86
|
+
Text string `json:"text"`
|
|
87
|
+
}
|
|
88
|
+
if err := decodeParams(params, &in); err != nil {
|
|
89
|
+
return nil, err
|
|
90
|
+
}
|
|
91
|
+
if len(in.Text) > 4096 || !utf8.ValidString(in.Text) {
|
|
92
|
+
return nil, api.BadRequest("text must be valid UTF-8, at most 4096 bytes")
|
|
93
|
+
}
|
|
94
|
+
id, err := cfg.personalID(ctx, caller)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return nil, err
|
|
97
|
+
}
|
|
98
|
+
if in.ID != id {
|
|
99
|
+
return nil, personalDenied()
|
|
100
|
+
}
|
|
101
|
+
if _, err := cfg.personalFile(id, &in.Text); err != nil {
|
|
102
|
+
return nil, personalUnavailable()
|
|
103
|
+
}
|
|
104
|
+
return map[string]string{"id": id, "text": in.Text}, nil
|
|
105
|
+
}
|
|
106
|
+
func personalDenied() error {
|
|
107
|
+
return &api.Error{Code: "access_denied", Message: "This sample belongs to another account"}
|
|
108
|
+
}
|
|
109
|
+
func personalUnavailable() error {
|
|
110
|
+
return &api.Error{Code: "personal_unavailable", Message: "Sample storage is unavailable; ask an administrator to check package data"}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Root confines every lookup to package data; no paths come from the client.
|
|
114
|
+
// O_NOFOLLOW rejects final symlinks. Shared/exclusive flock covers reads/writes
|
|
115
|
+
// across separate CGI processes; a write is bounded and fsynced before success.
|
|
116
|
+
func (cfg Config) personalFile(id string, text *string) (*string, error) {
|
|
117
|
+
root, err := os.OpenRoot(cfg.DataDir)
|
|
118
|
+
if err != nil {
|
|
119
|
+
return nil, err
|
|
120
|
+
}
|
|
121
|
+
defer root.Close()
|
|
122
|
+
if err = root.Mkdir("personal", 0700); err != nil && !errors.Is(err, fs.ErrExist) {
|
|
123
|
+
return nil, err
|
|
124
|
+
}
|
|
125
|
+
info, err := root.Lstat("personal")
|
|
126
|
+
if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 {
|
|
127
|
+
return nil, errors.New("unsafe directory")
|
|
128
|
+
}
|
|
129
|
+
dir, err := root.OpenRoot("personal")
|
|
130
|
+
if err != nil {
|
|
131
|
+
return nil, err
|
|
132
|
+
}
|
|
133
|
+
defer dir.Close()
|
|
134
|
+
lock, err := dir.OpenFile(id+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
|
|
135
|
+
if err != nil {
|
|
136
|
+
return nil, err
|
|
137
|
+
}
|
|
138
|
+
defer lock.Close()
|
|
139
|
+
if err = personalRegular(lock); err != nil {
|
|
140
|
+
return nil, err
|
|
141
|
+
}
|
|
142
|
+
mode := syscall.LOCK_SH
|
|
143
|
+
if text != nil {
|
|
144
|
+
mode = syscall.LOCK_EX
|
|
145
|
+
}
|
|
146
|
+
if err = syscall.Flock(int(lock.Fd()), mode); err != nil {
|
|
147
|
+
return nil, err
|
|
148
|
+
}
|
|
149
|
+
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
|
150
|
+
flags := os.O_RDONLY | syscall.O_NOFOLLOW
|
|
151
|
+
if text != nil {
|
|
152
|
+
flags = os.O_CREATE | os.O_RDWR | syscall.O_NOFOLLOW
|
|
153
|
+
}
|
|
154
|
+
file, err := dir.OpenFile(id+".json", flags, 0600)
|
|
155
|
+
if text == nil && errors.Is(err, fs.ErrNotExist) {
|
|
156
|
+
return nil, nil
|
|
157
|
+
}
|
|
158
|
+
if err != nil {
|
|
159
|
+
return nil, err
|
|
160
|
+
}
|
|
161
|
+
defer file.Close()
|
|
162
|
+
if err = personalRegular(file); err != nil {
|
|
163
|
+
return nil, err
|
|
164
|
+
}
|
|
165
|
+
if text != nil {
|
|
166
|
+
bytes, err := json.Marshal(map[string]string{"text": *text})
|
|
167
|
+
if err != nil {
|
|
168
|
+
return nil, err
|
|
169
|
+
}
|
|
170
|
+
if err = file.Truncate(0); err != nil {
|
|
171
|
+
return nil, err
|
|
172
|
+
}
|
|
173
|
+
if _, err = file.Write(bytes); err != nil {
|
|
174
|
+
return nil, err
|
|
175
|
+
}
|
|
176
|
+
if err = file.Sync(); err != nil {
|
|
177
|
+
return nil, err
|
|
178
|
+
}
|
|
179
|
+
return text, nil
|
|
180
|
+
}
|
|
181
|
+
bytes, err := io.ReadAll(io.LimitReader(file, 32769))
|
|
182
|
+
if err != nil || len(bytes) > 32768 {
|
|
183
|
+
return nil, errors.New("oversized note")
|
|
184
|
+
}
|
|
185
|
+
var note struct {
|
|
186
|
+
Text string `json:"text"`
|
|
187
|
+
}
|
|
188
|
+
if err = json.Unmarshal(bytes, ¬e); err != nil {
|
|
189
|
+
return nil, err
|
|
190
|
+
}
|
|
191
|
+
return ¬e.Text, nil
|
|
192
|
+
}
|
|
193
|
+
func personalRegular(f *os.File) error {
|
|
194
|
+
info, err := f.Stat()
|
|
195
|
+
if err != nil {
|
|
196
|
+
return err
|
|
197
|
+
}
|
|
198
|
+
st, ok := info.Sys().(*syscall.Stat_t)
|
|
199
|
+
if !ok || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 {
|
|
200
|
+
return errors.New("unsafe file")
|
|
201
|
+
}
|
|
202
|
+
return nil
|
|
203
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"dsmapp/internal/api"
|
|
6
|
+
"dsmapp/internal/helper"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"os"
|
|
9
|
+
"os/user"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func personalFixture(t *testing.T) Config {
|
|
16
|
+
oldLookup, oldHealth := personalLookup, integrationHealth
|
|
17
|
+
personalLookup = func(name string) (*user.User, error) {
|
|
18
|
+
return &user.User{Username: name, Uid: map[string]string{"normal1": "1001", "normal2": "1002", "alice": "1000"}[name], Gid: "100"}, nil
|
|
19
|
+
}
|
|
20
|
+
integrationHealth = func(context.Context, Config) helper.State { return helper.State{State: "ready"} }
|
|
21
|
+
t.Cleanup(func() { personalLookup, integrationHealth = oldLookup, oldHealth })
|
|
22
|
+
cfg := testConfig(t)
|
|
23
|
+
cfg.HelperInventory = helper.Inventory{Package: cfg.PackageID, Protocol: 1}
|
|
24
|
+
return cfg
|
|
25
|
+
}
|
|
26
|
+
func personalCall(t *testing.T, cfg Config, name, method, params string) (string, map[string]any) {
|
|
27
|
+
return call(t, cfg, stubAuth{identity: api.Identity{User: name}}, method, params)
|
|
28
|
+
}
|
|
29
|
+
func personalHandle(t *testing.T, cfg Config, name string) string {
|
|
30
|
+
code, out := personalCall(t, cfg, name, "personal.list", `{}`)
|
|
31
|
+
if code != "" {
|
|
32
|
+
t.Fatal(code, out)
|
|
33
|
+
}
|
|
34
|
+
return out["data"].(map[string]any)["items"].([]any)[0].(map[string]any)["id"].(string)
|
|
35
|
+
}
|
|
36
|
+
func TestPersonalUsersReadWriteAndMutuallyRefuse(t *testing.T) {
|
|
37
|
+
cfg := personalFixture(t)
|
|
38
|
+
first, second := personalHandle(t, cfg, "normal1"), personalHandle(t, cfg, "normal2")
|
|
39
|
+
if first == second {
|
|
40
|
+
t.Fatal("handles must bind caller")
|
|
41
|
+
}
|
|
42
|
+
for _, pair := range [][2]string{{"normal1", first}, {"normal2", second}} {
|
|
43
|
+
data, _ := json.Marshal(map[string]string{"id": pair[1], "text": "Private " + pair[0]})
|
|
44
|
+
code, out := personalCall(t, cfg, pair[0], "personal.write", string(data))
|
|
45
|
+
if code != "" {
|
|
46
|
+
t.Fatal(code, out)
|
|
47
|
+
}
|
|
48
|
+
code, out = personalCall(t, cfg, pair[0], "personal.read", `{"id":"`+pair[1]+`"}`)
|
|
49
|
+
if code != "" || out["data"].(map[string]any)["text"] != "Private "+pair[0] {
|
|
50
|
+
t.Fatal(code, out)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for _, pair := range [][2]string{{"normal1", second}, {"normal2", first}} {
|
|
54
|
+
for _, method := range []string{"personal.read", "personal.write"} {
|
|
55
|
+
params := `{"id":"` + pair[1] + `"`
|
|
56
|
+
if method == "personal.write" {
|
|
57
|
+
params += `,"text":"attack"`
|
|
58
|
+
}
|
|
59
|
+
params += `}`
|
|
60
|
+
code, _ := personalCall(t, cfg, pair[0], method, params)
|
|
61
|
+
if code != "access_denied" {
|
|
62
|
+
t.Fatal(method, code)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Administrator privilege also gives no authority over another user's note.
|
|
67
|
+
if code, _ := call(t, cfg, alice, "personal.read", `{"id":"`+first+`"}`); code != "access_denied" {
|
|
68
|
+
t.Fatal(code)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
func TestPersonalRejectsForgedIdentityAndPaths(t *testing.T) {
|
|
72
|
+
cfg := personalFixture(t)
|
|
73
|
+
id := personalHandle(t, cfg, "normal1")
|
|
74
|
+
for _, params := range []string{`{"user":"normal2"}`, `{"path":"../normal2"}`, `{"id":"` + id + `","user":"normal2"}`} {
|
|
75
|
+
if code, _ := personalCall(t, cfg, "normal1", "personal.list", params); code != "bad_request" {
|
|
76
|
+
t.Fatal(code)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for _, id := range []string{"../normal2", "/etc/passwd", "", "unknown"} {
|
|
80
|
+
if code, _ := personalCall(t, cfg, "normal1", "personal.read", `{"id":"`+id+`"}`); code != "access_denied" {
|
|
81
|
+
t.Fatal(code)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if code, _ := call(t, cfg, nobody, "personal.list", `{}`); code != "session_required" {
|
|
85
|
+
t.Fatal(code)
|
|
86
|
+
}
|
|
87
|
+
integrationHealth = func(context.Context, Config) helper.State { return helper.State{State: "stopped"} }
|
|
88
|
+
if code, _ := personalCall(t, cfg, "normal1", "personal.list", `{}`); code != "integration_unavailable" {
|
|
89
|
+
t.Fatal(code)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
func TestPersonalRefusesSymlinkAndOversizedData(t *testing.T) {
|
|
93
|
+
cfg := personalFixture(t)
|
|
94
|
+
id := personalHandle(t, cfg, "normal1")
|
|
95
|
+
outside := filepath.Join(t.TempDir(), "victim")
|
|
96
|
+
os.WriteFile(outside, []byte("unchanged"), 0600)
|
|
97
|
+
os.Mkdir(filepath.Join(cfg.DataDir, "personal"), 0700)
|
|
98
|
+
if err := os.Symlink(outside, filepath.Join(cfg.DataDir, "personal", id+".json")); err != nil {
|
|
99
|
+
t.Fatal(err)
|
|
100
|
+
}
|
|
101
|
+
for _, method := range []string{"personal.read", "personal.write"} {
|
|
102
|
+
params := `{"id":"` + id + `"`
|
|
103
|
+
if method == "personal.write" {
|
|
104
|
+
params += `,"text":"attack"`
|
|
105
|
+
}
|
|
106
|
+
params += `}`
|
|
107
|
+
if code, _ := personalCall(t, cfg, "normal1", method, params); code != "personal_unavailable" {
|
|
108
|
+
t.Fatal(method, code)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
b, _ := os.ReadFile(outside)
|
|
112
|
+
if string(b) != "unchanged" {
|
|
113
|
+
t.Fatal("escaped")
|
|
114
|
+
}
|
|
115
|
+
data, _ := json.Marshal(map[string]string{"id": id, "text": strings.Repeat("x", 4097)})
|
|
116
|
+
if code, _ := personalCall(t, cfg, "normal1", "personal.write", string(data)); code != "bad_request" {
|
|
117
|
+
t.Fatal(code)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"io"
|
|
8
|
+
"io/fs"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"regexp"
|
|
12
|
+
"syscall"
|
|
13
|
+
|
|
14
|
+
"dsmapp/internal/api"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// Root operations (root/<name>.sh) run as DSM Task Scheduler tasks that a DSM
|
|
18
|
+
// administrator approved in the package window. Each one writes its standard
|
|
19
|
+
// output to /run/synopkgland-<package>/<name>.out, a root-owned folder this
|
|
20
|
+
// package can read but not write. This package never runs anything as root
|
|
21
|
+
// itself; it only reads those results.
|
|
22
|
+
|
|
23
|
+
var rootOpName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`)
|
|
24
|
+
|
|
25
|
+
const maxRootOutput = 64 << 10
|
|
26
|
+
|
|
27
|
+
// rootRunDir and rootOwner are variables only so tests can point them at a
|
|
28
|
+
// temporary folder owned by the test user.
|
|
29
|
+
var (
|
|
30
|
+
rootRunDir = "/run"
|
|
31
|
+
rootOwner = uint32(0)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
func (cfg Config) rootRoutes() []api.Route {
|
|
35
|
+
return []api.Route{
|
|
36
|
+
api.Admin("root.output", cfg.rootOutput),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type rootResult struct {
|
|
41
|
+
Op string `json:"op"`
|
|
42
|
+
Output *string `json:"output"`
|
|
43
|
+
Modified int64 `json:"modified"`
|
|
44
|
+
Truncated bool `json:"truncated,omitempty"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (cfg Config) rootOutput(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
|
|
48
|
+
var in struct {
|
|
49
|
+
Op string `json:"op"`
|
|
50
|
+
}
|
|
51
|
+
if err := decodeParams(params, &in); err != nil {
|
|
52
|
+
return nil, err
|
|
53
|
+
}
|
|
54
|
+
if !rootOpName.MatchString(in.Op) {
|
|
55
|
+
return nil, api.BadRequest("op must be the name of a root operation")
|
|
56
|
+
}
|
|
57
|
+
dir := filepath.Join(rootRunDir, "synopkgland-"+cfg.PackageID)
|
|
58
|
+
if err := requireRootOwned(dir, true); err != nil {
|
|
59
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
60
|
+
return rootResult{Op: in.Op}, nil
|
|
61
|
+
}
|
|
62
|
+
return nil, err
|
|
63
|
+
}
|
|
64
|
+
file, err := os.OpenFile(filepath.Join(dir, in.Op+".out"), os.O_RDONLY|syscall.O_NOFOLLOW, 0)
|
|
65
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
66
|
+
return rootResult{Op: in.Op}, nil
|
|
67
|
+
}
|
|
68
|
+
if err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
defer file.Close()
|
|
72
|
+
info, err := file.Stat()
|
|
73
|
+
if err != nil {
|
|
74
|
+
return nil, err
|
|
75
|
+
}
|
|
76
|
+
if err := requireRootOwnedInfo(info, false); err != nil {
|
|
77
|
+
return nil, err
|
|
78
|
+
}
|
|
79
|
+
data, err := io.ReadAll(io.LimitReader(file, maxRootOutput+1))
|
|
80
|
+
if err != nil {
|
|
81
|
+
return nil, err
|
|
82
|
+
}
|
|
83
|
+
text := string(data[:min(len(data), maxRootOutput)])
|
|
84
|
+
return rootResult{Op: in.Op, Output: &text, Modified: info.ModTime().Unix(), Truncated: len(data) > maxRootOutput}, nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// requireRootOwned refuses anything the package account, or anyone but root,
|
|
88
|
+
// could have put there: symlinks, other owners, group- or world-writable.
|
|
89
|
+
func requireRootOwned(path string, dir bool) error {
|
|
90
|
+
info, err := os.Lstat(path)
|
|
91
|
+
if err != nil {
|
|
92
|
+
return err
|
|
93
|
+
}
|
|
94
|
+
return requireRootOwnedInfo(info, dir)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func requireRootOwnedInfo(info fs.FileInfo, dir bool) error {
|
|
98
|
+
st, ok := info.Sys().(*syscall.Stat_t)
|
|
99
|
+
wantType := info.Mode().IsRegular()
|
|
100
|
+
if dir {
|
|
101
|
+
wantType = info.IsDir()
|
|
102
|
+
}
|
|
103
|
+
if !ok || !wantType || st.Uid != rootOwner || info.Mode().Perm()&0o022 != 0 {
|
|
104
|
+
return errors.New("root output is not a root-owned " + map[bool]string{true: "folder", false: "file"}[dir])
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func init() { sampleParams["root.output"] = `{"op":"whoami"}` }
|
|
11
|
+
|
|
12
|
+
// rootFixture points the reader at a temporary run folder owned by the test
|
|
13
|
+
// user, standing in for root's /run/synopkgland-<package>.
|
|
14
|
+
func rootFixture(t *testing.T) (Config, string) {
|
|
15
|
+
t.Helper()
|
|
16
|
+
base := t.TempDir()
|
|
17
|
+
rootRunDir, rootOwner = base, uint32(os.Getuid())
|
|
18
|
+
t.Cleanup(func() { rootRunDir, rootOwner = "/run", 0 })
|
|
19
|
+
cfg := testConfig(t)
|
|
20
|
+
return cfg, filepath.Join(base, "synopkgland-"+cfg.PackageID)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func writeResult(t *testing.T, dir, name, text string) {
|
|
24
|
+
t.Helper()
|
|
25
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
if err := os.WriteFile(filepath.Join(dir, name), []byte(text), 0o644); err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestRootOutputIsForAdministratorsOnly(t *testing.T) {
|
|
34
|
+
cfg, dir := rootFixture(t)
|
|
35
|
+
writeResult(t, dir, "whoami.out", `{"euid":0}`)
|
|
36
|
+
if code, _ := call(t, cfg, bob, "root.output", `{"op":"whoami"}`); code != "admin_required" {
|
|
37
|
+
t.Fatalf("non-admin: %q", code)
|
|
38
|
+
}
|
|
39
|
+
if code, _ := call(t, cfg, nobody, "root.output", `{"op":"whoami"}`); code != "session_required" {
|
|
40
|
+
t.Fatalf("no session: %q", code)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestRootOutputIsEmptyBeforeTheFirstRun(t *testing.T) {
|
|
45
|
+
cfg, _ := rootFixture(t)
|
|
46
|
+
code, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`)
|
|
47
|
+
data := out["data"].(map[string]any)
|
|
48
|
+
if code != "" || data["output"] != nil || data["modified"] != float64(0) {
|
|
49
|
+
t.Fatalf("%q %v", code, out)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func TestRootOutputReturnsTheLatestResult(t *testing.T) {
|
|
54
|
+
cfg, dir := rootFixture(t)
|
|
55
|
+
writeResult(t, dir, "whoami.out", "{\"euid\":0}\n")
|
|
56
|
+
code, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`)
|
|
57
|
+
data := out["data"].(map[string]any)
|
|
58
|
+
if code != "" || data["output"] != "{\"euid\":0}\n" || data["modified"].(float64) <= 0 || data["truncated"] != nil {
|
|
59
|
+
t.Fatalf("%q %v", code, out)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestRootOutputOnlyReadsOperationNames(t *testing.T) {
|
|
64
|
+
cfg, dir := rootFixture(t)
|
|
65
|
+
writeResult(t, dir, "whoami.out", "x")
|
|
66
|
+
for _, op := range []string{"", "../whoami", "Whoami", "who/ami", "whoami.out", strings.Repeat("a", 33)} {
|
|
67
|
+
if code, _ := call(t, cfg, alice, "root.output", `{"op":"`+op+`"}`); code != "bad_request" {
|
|
68
|
+
t.Errorf("op %q: %q", op, code)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestRootOutputRefusesAnythingTheRootTaskDidNotWrite(t *testing.T) {
|
|
74
|
+
cases := map[string]func(t *testing.T, dir string){
|
|
75
|
+
"symlinked result": func(t *testing.T, dir string) {
|
|
76
|
+
writeResult(t, dir, "elsewhere", "x")
|
|
77
|
+
if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "whoami.out")); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"symlinked folder": func(t *testing.T, dir string) {
|
|
82
|
+
real := dir + "-real"
|
|
83
|
+
writeResult(t, real, "whoami.out", "x")
|
|
84
|
+
if err := os.Symlink(real, dir); err != nil {
|
|
85
|
+
t.Fatal(err)
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"writable folder": func(t *testing.T, dir string) {
|
|
89
|
+
writeResult(t, dir, "whoami.out", "x")
|
|
90
|
+
if err := os.Chmod(dir, 0o777); err != nil {
|
|
91
|
+
t.Fatal(err)
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"writable result": func(t *testing.T, dir string) {
|
|
95
|
+
writeResult(t, dir, "whoami.out", "x")
|
|
96
|
+
if err := os.Chmod(filepath.Join(dir, "whoami.out"), 0o666); err != nil {
|
|
97
|
+
t.Fatal(err)
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"another owner": func(t *testing.T, dir string) {
|
|
101
|
+
writeResult(t, dir, "whoami.out", "x")
|
|
102
|
+
rootOwner = uint32(os.Getuid()) + 1
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
for name, arrange := range cases {
|
|
106
|
+
t.Run(name, func(t *testing.T) {
|
|
107
|
+
cfg, dir := rootFixture(t)
|
|
108
|
+
arrange(t, dir)
|
|
109
|
+
if code, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`); code != "internal" {
|
|
110
|
+
t.Fatalf("%q %v", code, out)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func TestRootOutputIsBounded(t *testing.T) {
|
|
117
|
+
cfg, dir := rootFixture(t)
|
|
118
|
+
writeResult(t, dir, "whoami.out", strings.Repeat("x", maxRootOutput+10))
|
|
119
|
+
_, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`)
|
|
120
|
+
data := out["data"].(map[string]any)
|
|
121
|
+
if len(data["output"].(string)) != maxRootOutput || data["truncated"] != true {
|
|
122
|
+
t.Fatalf("not bounded: %d %v", len(data["output"].(string)), data["truncated"])
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# A root operation: runs as root only when a DSM administrator clicks Run in
|
|
3
|
+
# the package window, after approving this exact text with their password.
|
|
4
|
+
# Its standard output is the result the window shows. Keep root operations
|
|
5
|
+
# small and fixed; every change needs the administrator's approval again.
|
|
6
|
+
printf '{"euid":%s}\n' "$(id -u)"
|