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)
Changes
@@@ -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}`);@@@ -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),@@@ -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 () => {
@@ -32,13 +32,15 @@ async function validate(ctx, id) {
@@ -32,13 +32,15 @@ async function validate(ctx, id) {
const repoStat = await exec.stat(svc.repo);
const repoStat = await exec.stat(svc.repo);
if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);
if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);
else if (svc.layout === 'release') {
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 cur = `${svc.repo}/current`;
const target = await exec.readlink(cur);
const target = await exec.readlink(cur);
const st = target ? await exec.stat(cur) : null;
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`);
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 add('ok', 'checkout', `release layout: current -> ${target}`);
} else {
} else {
if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);
if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);
@@ -43,8 +43,8 @@ function normaliseService(id, raw, defaults) {
@@ -43,8 +43,8 @@ function normaliseService(id, raw, defaults) {
runAs: s.runAs || s.owner,
runAs: s.runAs || s.owner,
remote: s.remote || 'origin',
remote: s.remote || 'origin',
branch: s.branch || 'main',
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',
layout: s.layout === 'release' ? 'release' : 'git',
managed: s.layout === 'release' ? false : s.managed !== false,
managed: s.layout === 'release' ? false : s.managed !== false,
unmanagedReason: s.unmanagedReason || (s.layout === 'release' ? 'release layout: deployed by its own deploy script' : null),
unmanagedReason: s.unmanagedReason || (s.layout === 'release' ? 'release layout: deployed by its own deploy script' : null),
@@ -22,13 +22,18 @@ runTests([
@@ -22,13 +22,18 @@ runTests([
const st = await host.cli('status', '--json');
const st = await host.cli('status', '--json');
assert.match(st.out, /655b98a10aaa/);
assert.match(st.out, /655b98a10aaa/);
assert.match(st.out, /"managed":\s*false/);
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');
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);
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 () => {
test('env validation reports names only — never a value — in text and JSON output', async () => {