openvibers/openvibe.host Commits

ovhost release layout: current must resolve under releases/ and be owned by root or the checkout owner (OpenRe's deploy script installs releases as ubuntu, like every checkout on the host)

OP OpenVibers committed 23 Sept 2026 · 30f08d6560 · 3 files, +17 −10


Changes

ddiff --git a/lib/commands/validate.js b/lib/commands/validate.js
iindex 01b9026..0462a9e 100644
---- a/lib/commands/validate.js
++++ b/lib/commands/validate.js
@@@ -32,13 +32,15 @@ async function validate(ctx, id) {
const repoStat = await exec.stat(svc.repo);
if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);
else if (svc.layout === 'release') {
-- // Release layout: <repo>/current -> <repo>/releases/<id>, root-owned so the service user
-- // cannot rewrite the code it runs.
++ // Release layout: <repo>/current -> <repo>/releases/<id>, owned by root or the checkout owner
++ // (like every git checkout here), never by anyone else.
const cur = `${svc.repo}/current`;
const target = await exec.readlink(cur);
const st = target ? await exec.stat(cur) : null;
++ const resolved = target ? require('path').resolve(svc.repo, target) : '';
if (!target || !st || !st.isDir) add('error', 'checkout', `${cur} is not a symlink to a release directory`);
-- else if (repoStat.owner !== 'root' || st.owner !== 'root') add('error', 'checkout', `${svc.repo} and its current release must be root-owned (found ${repoStat.owner}/${st.owner})`);
++ else if (!resolved.startsWith(`${svc.repo}/releases/`)) add('error', 'checkout', `${cur} points outside ${svc.repo}/releases (${target})`);
++ else if (![ 'root', svc.owner ].includes(st.owner)) add('error', 'checkout', `the current release is owned by ${st.owner}, expected root or ${svc.owner}`);
else add('ok', 'checkout', `release layout: current -> ${target}`);
} else {
if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex 3e912ce..f45f89c 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -43,8 +43,8 @@ function normaliseService(id, raw, defaults) {
runAs: s.runAs || s.owner,
remote: s.remote || 'origin',
branch: s.branch || 'main',
-- // 'git' (a checkout ovhost can move) or 'release' (root-owned releases/<id> behind a
-- // `current` symlink, installed by the service's own deploy script: never managed here).
++ // 'git' (a checkout ovhost can move) or 'release' (releases/<id> behind a `current` symlink,
++ // installed by the service's own deploy script: never managed here).
layout: s.layout === 'release' ? 'release' : 'git',
managed: s.layout === 'release' ? false : s.managed !== false,
unmanagedReason: s.unmanagedReason || (s.layout === 'release' ? 'release layout: deployed by its own deploy script' : null),
ddiff --git a/test/validate.test.js b/test/validate.test.js
iindex 98ffcec..5c726df 100644
---- a/test/validate.test.js
++++ b/test/validate.test.js
@@@ -22,13 +22,18 @@ runTests([
const st = await host.cli('status', '--json');
assert.match(st.out, /655b98a10aaa/);
assert.match(st.out, /"managed":\s*false/);
-- // A release the service user could rewrite is refused.
-- host.put('/opt/rel.stream/releases/bad/package.json', '{}', { owner: 'ubuntu' });
-- host.files.get('/opt/rel.stream/releases/bad').owner = 'ubuntu';
++ // current must name a release under releases/, owned by root or the checkout owner.
++ host.put('/opt/elsewhere/package.json', '{}');
host.files.delete('/opt/rel.stream/current');
-- await host.exec.symlink('/opt/rel.stream/releases/bad', '/opt/rel.stream/current');
++ await host.exec.symlink('/opt/elsewhere', '/opt/rel.stream/current');
res = JSON.parse((await host.cli('validate', 'rel', '--json')).out);
-- assert.ok(res.findings.some((f) => f.level === 'error' && /must be root-owned/.test(f.message)));
++ assert.ok(res.findings.some((f) => f.level === 'error' && /points outside/.test(f.message)));
++ host.put('/opt/rel.stream/releases/odd/package.json', '{}', { owner: 'nobody' });
++ host.files.get('/opt/rel.stream/releases/odd').owner = 'nobody';
++ host.files.delete('/opt/rel.stream/current');
++ await host.exec.symlink('/opt/rel.stream/releases/odd', '/opt/rel.stream/current');
++ res = JSON.parse((await host.cli('validate', 'rel', '--json')).out);
++ assert.ok(res.findings.some((f) => f.level === 'error' && /owned by nobody/.test(f.message)));
}),
test('env validation reports names only — never a value — in text and JSON output', async () => {