diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml index d31dff0..952537c 100644 --- a/.github/workflows/native-qualification.yml +++ b/.github/workflows/native-qualification.yml @@ -38,12 +38,15 @@ jobs: node scripts/test-native-report-fixture.cjs node scripts/test-native-fixture-paths.cjs - name: Stage pinned official runtime and labeled fixtures - run: python3 scripts/prepare-native-inputs.py --output "$RUNNER_TEMP/vmpower-native" + run: | + test "$(git rev-parse HEAD)" = "$QUALIFIED_SOURCE_SHA" + echo "QUALIFIED_SOURCE_TREE=$(git rev-parse HEAD^{tree})" >> "$GITHUB_ENV" + python3 scripts/prepare-native-inputs.py --output "$RUNNER_TEMP/vmpower-native" - name: Stage exact retained public Debian package families before quarantine run: python3 scripts/prepare-legacy-package-inputs.py --output "$RUNNER_TEMP/vmpower-native" - name: Isolated systemd lifecycle and progress run: | - sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,ImageOS,ImageVersion,RUNNER_TEMP \ + sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,GITHUB_RUN_ATTEMPT,GITHUB_JOB,GITHUB_REPOSITORY,QUALIFIED_SOURCE_SHA,QUALIFIED_SOURCE_TREE,ImageOS,ImageVersion,RUNNER_TEMP \ python3 scripts/linux-package-quarantine.py --output "$RUNNER_TEMP/vmpower-native" --legacy-dpkg - name: Always reconcile owned fixture cleanup if: always() @@ -61,6 +64,18 @@ jobs: if-no-files-found: ignore retention-days: 3 + - name: Reopen retained final package after successful lifecycle and cleanup + run: python3 -B scripts/qualify-linux-dpkg.py --verify-retained "$RUNNER_TEMP/vmpower-native" + - name: Retain exact qualified final Linux package only + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: final-linux-package-${{ env.QUALIFIED_SOURCE_SHA }} + path: | + ${{ runner.temp }}/vmpower-native/evidence/final-linux/vmpower-agent_0.2.0_amd64.deb + ${{ runner.temp }}/vmpower-native/evidence/final-linux/manifest.json + if-no-files-found: error + retention-days: 3 + windows-native: runs-on: windows-2025 timeout-minutes: 20 diff --git a/distribution/linux-debian-handoff.md b/distribution/linux-debian-handoff.md index 481d588..567c059 100644 --- a/distribution/linux-debian-handoff.md +++ b/distribution/linux-debian-handoff.md @@ -1,8 +1,8 @@ # Linux Debian ownership and rollback candidate This source prepares one package builder and the actual maintainer hooks for a -future release. It is not published or customer-qualified. The optional native -gate below has not run. The current public binaries remain unchanged. +future release. It is not published or customer-qualified. The final-version +retention extension below has not run. The current public binaries remain unchanged. The two cached public Linux packages are both `vmpower-agent` version `0.1`, with different executable bytes. They own `/usr/bin/vmpower`, @@ -101,8 +101,36 @@ before any push; this source preparation has not dispatched CI. that registration **before dropping quarantine**, verifies absence, stops the exact watchdog, removes owned state, stops all owned processes/receivers, removes firewall rules and restores the original `/opt` - permissions. Upload sanitized lifecycle/cleanup JSON only; no packages, - configuration bytes, raw environments or snapshots. + permissions. Upload sanitized lifecycle/cleanup JSON and, only after successful + qualification, the final package described below. Exclude synthetic packages, + configuration bytes, raw environments and snapshots. + +## Final-version retention prerequisite + +The Linux job additionally builds `vmpower-agent_0.2.0_amd64.deb` from the original +unmodified compiled bundle, never the synthetic upgrade/crash variant. Its source +commit/tree, run/attempt, original pinned Node archive, application lock, bundle +manifest and included licenses are bound in a sanitized manifest. Before each +actual final-package dpkg install, a separate read requires a nonzero bounded +regular single-link archive, computes its size and SHA256, reopens its control/data +archives, and compares every payload, hook and mode with the original inputs. +Neither a builder receipt nor a zero-byte metadata field substitutes for this read. + +After all existing adverse phases, each exact cached legacy family is installed +again without executing Node10. The same retained final archive must upgrade it, +preserve config bytes and stopped/disabled state, own the package paths, run as the +service user and produce two reports before purge preserves config. This adds two +existing bounded 85-second report waits; the 750-second scope and 15-minute job +limits are unchanged. Actual runtime fit remains an execution prerequisite. + +Only successful lifecycle, both final-family report proofs, supervisor and cleanup +receipts, removed isolation, and a final independent archive reopen/hash allow the +two exact files under `evidence/final-linux/` to upload for three days. No wildcard +package upload, release, tag, public consumer URL or publishing action is added. +This prepares retention; only an admitted native run can produce the actual package +and lifecycle evidence. Post-success rollback remains **UNQUALIFIED**: the adverse +fixture's failed-upgrade compensation is not proof of recovery after successful +customer installation. Customer OS compatibility remains Unknown. Expected incremental gate work is two package-family matrices within the existing 15-minute Linux job. The two-report waits alone need about 60 seconds per family; diff --git a/distribution/native-qualification.md b/distribution/native-qualification.md index 494056d..27af484 100644 --- a/distribution/native-qualification.md +++ b/distribution/native-qualification.md @@ -4,10 +4,13 @@ Root review and CI/budget admission precede a push. The prepared workflow `.github/workflows/native-qualification.yml` has one standard Ubuntu 24.04 x64 job (15 minutes) and one standard Windows 2025 x64 job (20 minutes). It runs on this delivery branch/master pushes and master PRs, with read-only contents permission, -no customer/provider secrets, no dispatch/release/tag event and no binary artifact -publishing. Existing source/CodeQL jobs remain separate and count toward admission. -Only sanitized JSON qualification receipts are uploaded for three days; package -bytes, synthetic configuration, login rows and raw service environments are excluded. +no customer/provider secrets and no dispatch/release/tag event or release publishing. +Existing source/CodeQL jobs remain separate and count toward admission. Sanitized +JSON qualification receipts are uploaded for three days. After successful Linux +lifecycle and cleanup, a separate three-day artifact retains only the exact final +`vmpower-agent_0.2.0_amd64.deb` and its sanitized provenance manifest; the success-only +gate reopens and hashes that archive again. Synthetic package variants, +configuration, login rows and raw service environments are excluded. Download only the pinned public Node and NSSM inputs and public npm packages before enforcing outbound isolation. Record exact runner OS, kernel/libc or Windows build and PowerShell version. These results do not establish Windows 10/Server 2016, diff --git a/scripts/build-linux-deb.py b/scripts/build-linux-deb.py index 522e245..eaa40e7 100644 --- a/scripts/build-linux-deb.py +++ b/scripts/build-linux-deb.py @@ -14,6 +14,7 @@ import os from pathlib import Path import re +import stat import sys import tarfile @@ -85,6 +86,63 @@ def build(bundle,expected,version,output): return {'package':'vmpower-agent','version':version,'architecture':'amd64','sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw),'bundleManifestSha256':expected,'maintainerHookSha256':hashlib.sha256(controls['postinst'][0]).hexdigest(),'files':len(payload)} +def read_package(path): + """Reopen our fixed three-member deb without extracting or executing anything.""" + before=path.lstat() + if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or not 0\n':raise ValueError('Invalid deb archive') + position=8;members={} + for name in ('debian-binary','control.tar.gz','data.tar.gz'): + header=raw[position:position+60] + try:size=int(header[48:58]) + except ValueError:raise ValueError('Invalid deb member size') from None + expected=f'{name+"/":<16}{0:<12}{0:<6}{0:<6}{"100644":<8}{size:<10}`\n'.encode() + if header!=expected or not 0512*1024*1024:raise ValueError('Uncompressed archive bound exceeded') + with tarfile.open(fileobj=io.BytesIO(tar),mode='r:') as archive: + for entry in archive: + total+=entry.size + if len(seen)>=20000 or entry.size<0 or total>512*1024*1024 or entry.name in seen:raise ValueError('Archive bounds or duplicate member') + seen.add(entry.name) + if not entry.name.startswith('./') or any(x in ('','..','.') for x in entry.name[2:].split('/')) or entry.uid or entry.gid or entry.mtime: + raise ValueError('Unexpected archive metadata') + if entry.isdir(): + if entry.mode!=0o755 or entry.size:raise ValueError('Unexpected directory metadata') + elif entry.isfile():files[entry.name[2:]]=(archive.extractfile(entry).read(),entry.mode) + else:raise ValueError('Package links or special files refused') + expected_names={'./'+f for f in files}|{'./'+str(parent) for f in files for parent in Path(f).parents if str(parent)!='.'} + if seen!=expected_names:raise ValueError('Unexpected archive directory inventory') + archives[name]=files + return {'sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw)},archives + + +def verify_package(path,bundle,expected,version,receipt): + measured,archives=read_package(path) + if measured!={k:receipt[k] for k in ('sha256','bytes')}:raise ValueError('Retained package size/hash mismatch') + payload,controls=package_files(bundle,expected,version) + if archives!={'data.tar.gz':payload,'control.tar.gz':controls}:raise ValueError('Reopened package differs from qualified inputs') + return {**measured,'archiveReopened':True,'exactControlAndData':True,'files':len(payload)} + + if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--bundle',type=Path,required=True);parser.add_argument('--expected-manifest-sha256',required=True);parser.add_argument('--version',required=True);parser.add_argument('--output',type=Path,required=True);args=parser.parse_args() print(json.dumps(build(args.bundle,args.expected_manifest_sha256,args.version,args.output))) diff --git a/scripts/linux-package-quarantine.py b/scripts/linux-package-quarantine.py index 272e0b3..7146f65 100644 --- a/scripts/linux-package-quarantine.py +++ b/scripts/linux-package-quarantine.py @@ -18,7 +18,8 @@ def __init__(self,reason,resource='local',query=None): def failure_record(exc,phase): reason=(exc.reason if isinstance(exc,AdmissionRefused) else - 'QUERY_FAILED' if isinstance(exc,commands.CommandFailure) else + 'OWNED_SCOPE_FAILED' if type(exc) is commands.CommandFailure and exc.record.get('operation')=='OWNED_SCOPE' else + 'COMMAND_FAILED' if isinstance(exc,commands.CommandFailure) else 'SIGNAL' if isinstance(exc,commands.Interrupted) else 'IO_FAILED' if isinstance(exc,OSError) else 'UNKNOWN') return {'phase':phase,'resource':exc.resource if isinstance(exc,AdmissionRefused) else 'local','reason':reason, diff --git a/scripts/qualify-linux-dpkg.py b/scripts/qualify-linux-dpkg.py index 50d1dd3..6e65aa8 100644 --- a/scripts/qualify-linux-dpkg.py +++ b/scripts/qualify-linux-dpkg.py @@ -5,16 +5,20 @@ postinst and pre-existing synthetic config. Their Node10 executables never run. Only the built package's actual hooks activate the qualified modern runtime. """ +import argparse import hashlib import importlib.util import json +import os from pathlib import Path import pwd +import re import stat import sys sys.dont_write_bytecode=True PACKAGE='vmpower-agent' +FINAL_NAME='vmpower-agent_0.2.0_amd64.deb' EXTRA_PATHS=[Path('/usr/lib/vmpower-agent'),Path('/var/lib/vmpower-agent'),Path('/etc/vmpower-template.conf')] LEGACY={ 'dashboard-linux.deb':{'bytes':10794560,'sha256':'06bf0d767e4625db0477f3e23e30b4e36ac6e7a7d50882b3c655c472f45875b1','binarySha256':'6e26bae20a2d0a45135c0b8591d41982175cff5b844ffcd2c3a610b1b49e3a80'}, @@ -72,6 +76,71 @@ def assert_nonroot_child(native,expected): return pid +def final_identity(): + source=os.environ.get('QUALIFIED_SOURCE_SHA','');tree=os.environ.get('QUALIFIED_SOURCE_TREE','') + if not re.fullmatch('[a-f0-9]{40}',source) or not re.fullmatch('[a-f0-9]{40}',tree):raise ValueError('Exact final source identity required') + if os.environ.get('GITHUB_REPOSITORY')!='vmpowerio/agent' or os.environ.get('GITHUB_JOB')!='linux-native':raise ValueError('Final package job route refused') + return {'repository':'vmpowerio/agent','source':source,'tree':tree,'runId':quarantine().canonical_run_id(os.environ.get('GITHUB_RUN_ID','')), + 'attempt':quarantine().canonical_run_id(os.environ.get('GITHUB_RUN_ATTEMPT','')),'job':'linux-native'} + + +def final_bundle(builder,out,expected,root): + manifest=builder.installer.stage.verify_bundle(out/'bundle') + if (manifest.get('qualification')!='Staged bytes only; native execution, installer/service and release acceptance are separate.' or + manifest['applicationVersion']!=json.loads((root/'package.json').read_text())['version'] or + manifest['sourceLockSha256']!=hashlib.sha256((root/'package-lock.json').read_bytes()).hexdigest() or + manifest['files']['app/index.js']!=hashlib.sha256((root/'build/index.js').read_bytes()).hexdigest() or + hashlib.sha256((out/'bundle/bundle-manifest.json').read_bytes()).hexdigest()!=expected): + raise ValueError('Only the unmodified compiled bundle may be retained') + pins=json.loads((root/'distribution/runtime-pins.json').read_text()) + pin=pins['targets']['linux-x64'] + runtime=builder.installer.stage.runtime_files(out/pin['archive'],'linux-x64',pins) + if manifest['runtimeArchiveSha256']!=pin['sha256'] or manifest['nodeVersion']!=pins['nodeVersion']:raise ValueError('Original runtime archive binding failed') + if any((out/'bundle/runtime'/name).read_bytes()!=raw for name,raw in runtime.items()):raise ValueError('Runtime/license differs from original pinned archive') + return manifest + + +def verify_retained(out): + """Success-only upload gate; same retained bytes, no rebuild or config export.""" + from native_fixture_paths import require_output + out=require_output(out);directory=out/'evidence/final-linux' + if directory.is_symlink() or {p.name for p in directory.iterdir()}!={FINAL_NAME,'manifest.json'}:raise ValueError('Unexpected retained inventory') + info=(directory/'manifest.json').lstat() + if not stat.S_ISREG(info.st_mode) or info.st_nlink!=1 or not 0=before+2,85) + assert assert_nonroot_child(native,hashes['bundle'])==pid and native.state(out)['rejected']==0 + phases.append({'phase':'retained-final-0.2.0-direct-legacy-upgrade-and-two-reports','status':'PASS','archive':reopened,'reportsObserved':native.state(out)['count']-before}) + native.command('dpkg','--purge',PACKAGE);native.wait(lambda:native.dead(pid));require_absent(native) + assert not launcher.exists() and not unit.exists() and config.read_bytes()==raw + phases.append({'phase':'final-purge-preserves-config','status':'PASS'}) + result['finalPackage']={**identity,'status':'NATIVE_FIXTURE_QUALIFIED_NOT_RELEASED','package':final_receipt, + 'applicationVersion':manifest['applicationVersion'],'nodeVersion':manifest['nodeVersion'],'runtimeArchiveSha256':manifest['runtimeArchiveSha256'], + 'sourceLockSha256':manifest['sourceLockSha256'],'legacyFamilies':sorted(legacy),'postSuccessRollback':'UNQUALIFIED','customerOSCompatibility':'Unknown', + 'licensesIncluded':[name for name in sorted(manifest['files']) if Path(name).name.lower().startswith(('license','copying'))]} + native.write(retained/'manifest.json',result['finalPackage']) result.update({'status':'PASS','packageRegistrationAbsent':True,'actualCandidateHooksExecuted':True,'customerOSCompatibility':'Unknown','retainedConfigSha256':hashlib.sha256(raw).hexdigest()}) return result + + +if __name__=='__main__': + parser=argparse.ArgumentParser();parser.add_argument('--verify-retained',type=Path,required=True);args=parser.parse_args() + verify_retained(args.verify_retained) diff --git a/scripts/qualify-native-linux.py b/scripts/qualify-native-linux.py index 3c85f62..82f89da 100644 --- a/scripts/qualify-native-linux.py +++ b/scripts/qualify-native-linux.py @@ -126,6 +126,78 @@ def wait(check,timeout=15): def state(out): try:return json.loads((out/'receiver.json').read_text()) except (FileNotFoundError,json.JSONDecodeError):return {'count':0,'port':None,'rejected':0} +def diagnostic_json(path): + """Bounded metadata snapshot only; never follow links or block on a FIFO.""" + try: + fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK) + with os.fdopen(fd,'rb') as stream: + before=os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or not 00)): + return {**empty,'state':'INVALID'} + first,last=value['firstReportAt'],value['lastReportAt'];count=value['count'] + if count==0: + valid=first is None and last is None;interval=None + else: + valid=integer(first,9007199254740991) and integer(last,9007199254740991) and 0<=last-first<=86400000 + interval=last-first if valid else None + valid=valid and (count!=1 or interval==0) + if not valid:return {**empty,'state':'INVALID'} + return {'state':'VALID','count':count,'rejected':value['rejected'],'intervalMs':interval} + +def command_diagnostic(value): + """Project finite labels only, even if a record contains unexpected fields.""" + result={'operation':'UNCLASSIFIED','classification':'UNCLASSIFIED','exitCode':None} + if type(value) is not dict:return result + for key,allowed in [('operation',{'SYSTEMCTL','OWNED_SCOPE','DPKG_INSTALL','DPKG_REMOVE','DPKG_PURGE','DPKG_QUERY','SERVICE_USER','PYTHON','NODE','FIXED_HELPER'}), + ('classification',{'SUCCESS','TIMEOUT','OUTPUT_LIMIT','INTERRUPTED','REAP_TIMEOUT','SIGNAL','EXIT_NONZERO'})]: + if type(value.get(key)) is str and value[key] in allowed:result[key]=value[key] + code=value.get('exitCode') + if type(code) is int and -64<=code<=255:result['exitCode']=code + return result + +def failure_diagnostic(exc,out): + """Snapshot before outer cleanup. No messages, locals, paths, IDs or timestamps.""" + result={'category':'OTHER','locations':[],'command':None,'lastCommand':None, + 'receiver':{'state':'UNVERIFIED','count':None,'rejected':None,'intervalMs':None}} + try: + categories={AssertionError:'ASSERTION',RuntimeError:'RUNTIME',KeyError:'KEY',ValueError:'VALUE', + json.JSONDecodeError:'VALUE',TypeError:'TYPE',bounded.Interrupted:'SIGNAL',bounded.CommandFailure:'COMMAND'} + result['category']=categories.get(type(exc),'IO' if isinstance(exc,OSError) else 'OTHER') + # Full exact checkout paths, not suffix/basename matches. Keep the outer + # caller and last owned location within 64 frames; never inspect locals. + owned={str(ROOT/'scripts'/name):name for name in ('qualify-native-linux.py','install-linux-bundle.py','qualify-linux-dpkg.py','linux-package-quarantine.py','native_linux_commands.py')} + trace=BaseException.__traceback__.__get__(exc);locations=[] + for _ in range(64): + if trace is None:break + name=owned.get(trace.tb_frame.f_code.co_filename) + if name and 1<=trace.tb_lineno<=10000:locations.append({'script':name,'line':trace.tb_lineno}) + trace=trace.tb_next + result['locations']=locations if len(locations)<=2 else [locations[0],locations[-1]] + if type(exc) is bounded.CommandFailure:result['command']=command_diagnostic(vars(exc).get('record')) + status,last=diagnostic_json(out/'evidence/operation-linux.json') + if status=='VALID':result['lastCommand']=command_diagnostic(last) + result['receiver']=receiver_diagnostic(out) + except BaseException: + # A diagnostic failure cannot replace the primary exception or cleanup. + pass + return result def mainpid():return int(command('systemctl','show','vmpower.service','--property=MainPID','--value').stdout.strip()) def child(expected): pid=mainpid() @@ -313,6 +385,7 @@ def qualify(out,legacy_dpkg=False): result['status']='PASS' except BaseException as exc: result['failure']=exc.record if isinstance(exc,bounded.CommandFailure) else {'classification':'LIFECYCLE_REFUSED_OR_INTERRUPTED'} + result['diagnostic']=failure_diagnostic(exc,out) raise finally: result['cleanup']={'status':'PENDING_OUTER_SCOPE_RECONCILIATION'} diff --git a/scripts/test-linux-deb.py b/scripts/test-linux-deb.py index 60e704f..f713966 100644 --- a/scripts/test-linux-deb.py +++ b/scripts/test-linux-deb.py @@ -82,6 +82,132 @@ def race(root): with mock.patch.object(builder.installer.stage,'verify_bundle',side_effect=race): with self.assertRaisesRegex(ValueError,'during packaging'):self.build() self.assertFalse(self.output.exists()) + def test_final_archive_reopened_independently_with_exact_control_payload_and_license(self): + receipt=self.build('0.2.0') + proof=builder.verify_package(self.output,self.bundle,self.digest,'0.2.0',receipt) + self.assertGreater(proof['bytes'],0);self.assertEqual(proof['bytes'],self.output.stat().st_size) + self.assertEqual(proof['sha256'],hashlib.sha256(self.output.read_bytes()).hexdigest()) + self.assertTrue(proof['archiveReopened'] and proof['exactControlAndData']) + _,archives=builder.read_package(self.output) + self.assertIn('usr/lib/vmpower-agent/bundle/runtime/LICENSE',archives['data.tar.gz']) + self.assertNotIn('etc/vmpower.conf',archives['data.tar.gz']) + def test_zero_size_receipt_and_rc_archive_cannot_qualify_as_final(self): + receipt=self.build() + with self.assertRaisesRegex(ValueError,'differs'):builder.verify_package(self.output,self.bundle,self.digest,'0.2.0',receipt) + with self.assertRaisesRegex(ValueError,'size/hash'):builder.verify_package(self.output,self.bundle,self.digest,'0.2.0~rc1',{**receipt,'bytes':0}) + def test_empty_truncated_trailing_or_changed_archive_refused(self): + receipt=self.build('0.2.0');original=self.output.read_bytes() + for raw in (b'',original[:50],original[:-1],original+b'extra',b'badmagic'+original[8:]): + with self.subTest(size=len(raw)): + self.output.write_bytes(raw) + with self.assertRaises(ValueError):builder.verify_package(self.output,self.bundle,self.digest,'0.2.0',receipt) + def test_rehashed_unexpected_config_or_hook_is_not_an_accepted_archive(self): + receipt=self.build('0.2.0');payload,controls=builder.package_files(self.bundle,self.digest,'0.2.0') + variants=[({**payload,'etc/vmpower.conf':(b'fixture only',0o600)},controls),(payload,{**controls,'postinst':(b'altered hook',0o755)})] + for data,control in variants: + raw=builder.ar_bytes([('debian-binary',b'2.0\n'),('control.tar.gz',builder.tar_bytes(control)),('data.tar.gz',builder.tar_bytes(data))]);self.output.write_bytes(raw) + updated={**receipt,'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()} + with self.assertRaisesRegex(ValueError,'differs'):builder.verify_package(self.output,self.bundle,self.digest,'0.2.0',updated) + def test_archive_links_and_excessive_member_size_refused_without_extraction(self): + for kind in ('link','oversize'): + stream=io.BytesIO() + with tarfile.open(fileobj=stream,mode='w:gz') as archive: + entry=tarfile.TarInfo('./unexpected') + if kind=='link':entry.type=tarfile.SYMTYPE;entry.linkname='/outside' + else:entry.size=512*1024*1024+1 + archive.addfile(entry) + self.output.write_bytes(builder.ar_bytes([('debian-binary',b'2.0\n'),('control.tar.gz',stream.getvalue()),('data.tar.gz',stream.getvalue())])) + with self.subTest(kind=kind),self.assertRaisesRegex(ValueError,'links|bounds'):builder.read_package(self.output) + def test_linked_package_and_oversize_file_refused_before_open(self): + self.build('0.2.0');alias=self.output.with_name('alias.deb');alias.symlink_to(self.output) + with self.assertRaisesRegex(ValueError,'single-link'):builder.read_package(alias) + alias.unlink();os.link(self.output,alias) + with self.assertRaisesRegex(ValueError,'single-link'):builder.read_package(self.output) + alias.unlink() + with self.output.open('wb') as handle:handle.truncate(160*1024*1024+1) + with mock.patch.object(builder.os,'open',side_effect=AssertionError('Must not open excessive input')): + with self.assertRaisesRegex(ValueError,'single-link'):builder.read_package(self.output) + def test_final_bundle_requires_original_runtime_source_and_unmodified_application(self): + manifest=dpkg_fixture.final_bundle(builder,self.fixture.root,self.digest,self.fixture.repo) + self.assertEqual(manifest['nodeVersion'],'24.22.1') + (self.fixture.repo/'build/index.js').write_text('different compiled application') + with self.assertRaisesRegex(ValueError,'unmodified'):dpkg_fixture.final_bundle(builder,self.fixture.root,self.digest,self.fixture.repo) + def test_synthetic_fixture_cannot_be_retained_even_with_consistent_manifest_hash(self): + file=self.bundle/'bundle-manifest.json';manifest=json.loads(file.read_bytes());manifest['qualification']='SYNTHETIC LIFECYCLE FIXTURE; NEVER PUBLISH' + file.write_text(json.dumps(manifest));digest=hashlib.sha256(file.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError,'unmodified'):dpkg_fixture.final_bundle(builder,self.fixture.root,digest,self.fixture.repo) + def test_original_runtime_archive_must_still_match_pin(self): + with self.fixture.archive.open('ab') as handle:handle.write(b'changed archive') + with self.assertRaisesRegex(ValueError,'pinned'):dpkg_fixture.final_bundle(builder,self.fixture.root,self.digest,self.fixture.repo) + + +class RetainedGateTests(unittest.TestCase): + def setUp(self): + self.fixture=fixtures.BundleTests();self.fixture.setUp();self.addCleanup(self.fixture.doCleanups) + self.bundle=self.fixture.bundle();self.out=self.fixture.root;self.evidence=self.out/'evidence';self.directory=self.evidence/'final-linux';self.directory.mkdir(parents=True) + digest=hashlib.sha256((self.bundle/'bundle-manifest.json').read_bytes()).hexdigest() + self.package=builder.build(self.bundle,digest,'0.2.0',self.directory/dpkg_fixture.FINAL_NAME) + self.identity={'repository':'vmpowerio/agent','source':'a'*40,'tree':'b'*40,'runId':'123','attempt':'1','job':'linux-native'} + bundle=json.loads((self.bundle/'bundle-manifest.json').read_bytes()) + self.manifest={**self.identity,'status':'NATIVE_FIXTURE_QUALIFIED_NOT_RELEASED','package':self.package, + **{k:bundle[k] for k in ('applicationVersion','nodeVersion','runtimeArchiveSha256','sourceLockSha256')}, + 'legacyFamilies':sorted(dpkg_fixture.LEGACY),'postSuccessRollback':'UNQUALIFIED','customerOSCompatibility':'Unknown','licensesIncluded':['runtime/LICENSE']} + proof=builder.verify_package(self.directory/dpkg_fixture.FINAL_NAME,self.bundle,digest,'0.2.0',self.package) + families=[{'name':name,'phases':[{'phase':'retained-final-0.2.0-direct-legacy-upgrade-and-two-reports','status':'PASS','archive':proof,'reportsObserved':2}]} for name in dpkg_fixture.LEGACY] + self.lifecycle={'status':'PASS','head':self.identity['source'],'realPackageOwnership':{'status':'PASS','finalPackage':self.manifest,'packages':{'final':self.package},'families':families}} + self.write(self.directory/'manifest.json',self.manifest);self.write(self.evidence/'lifecycle-linux.json',self.lifecycle) + self.cleanup={'status':'PASS',**{k:True for k in ('serviceAbsent','childrenAbsent','managedFilesAbsent','packageRegistrationAbsent')}} + self.write(self.evidence/'cleanup-linux.json',self.cleanup);self.write(self.evidence/'supervisor-outcome-linux.json',{'status':'PASS'});self.write(self.out/'isolation.json',{'state':'REMOVED'}) + import native_fixture_paths + for patch in (mock.patch.object(native_fixture_paths,'require_output',return_value=self.out),mock.patch.object(dpkg_fixture,'final_identity',return_value=self.identity)): + patch.start();self.addCleanup(patch.stop) + def write(self,file,value):file.write_text(json.dumps(value)) + def verify(self): + with mock.patch('sys.stdout',new_callable=io.StringIO) as output: + result=dpkg_fixture.verify_retained(self.out) + self.assertEqual(set(json.loads(output.getvalue())),{'retainedFinalPackage','bytes','sha256','release','customerDelivery'}) + return result + def test_success_gate_keeps_exact_final_bytes_and_explicit_unreleased_limits(self): + before=(self.directory/dpkg_fixture.FINAL_NAME).read_bytes();self.assertEqual(self.verify(),self.manifest) + self.assertEqual((self.directory/dpkg_fixture.FINAL_NAME).read_bytes(),before) + self.assertEqual(self.manifest['postSuccessRollback'],'UNQUALIFIED') + def test_any_cleanup_or_quarantine_failure_blocks_retention(self): + for key in ('serviceAbsent','childrenAbsent','managedFilesAbsent','packageRegistrationAbsent'): + self.write(self.evidence/'cleanup-linux.json',{**self.cleanup,key:False}) + with self.subTest(key=key),self.assertRaisesRegex(ValueError,'cleanup'):self.verify() + self.write(self.evidence/'cleanup-linux.json',self.cleanup) + for file,value in ((self.evidence/'supervisor-outcome-linux.json',{'status':'FAIL'}),(self.out/'isolation.json',{'state':'INSTALLED'})): + original=file.read_bytes();self.write(file,value) + with self.assertRaisesRegex(ValueError,'not complete'):self.verify() + file.write_bytes(original) + def test_source_drift_and_missing_family_report_block_retention(self): + self.write(self.directory/'manifest.json',{**self.manifest,'source':'c'*40}) + with self.assertRaisesRegex(ValueError,'source/job'):self.verify() + self.write(self.directory/'manifest.json',self.manifest) + self.lifecycle['realPackageOwnership']['families'][1]['phases'][0]['reportsObserved']=1 + self.write(self.evidence/'lifecycle-linux.json',self.lifecycle) + with self.assertRaisesRegex(ValueError,'reports'):self.verify() + def test_zero_size_or_changed_final_archive_blocks_retention(self): + file=self.directory/dpkg_fixture.FINAL_NAME;original=file.read_bytes() + for raw in (b'',original+b'extra'): + file.write_bytes(raw) + with self.assertRaises(ValueError):self.verify() + def test_extra_config_or_unknown_manifest_fields_block_retention(self): + extra=self.directory/'config.json';extra.write_text('synthetic only') + with self.assertRaisesRegex(ValueError,'inventory'):self.verify() + extra.unlink();self.write(self.directory/'manifest.json',{**self.manifest,'config':'synthetic only'}) + with self.assertRaisesRegex(ValueError,'schema'):self.verify() + def test_preinstall_proof_cannot_use_zero_byte_receipt(self): + self.lifecycle['realPackageOwnership']['families'][0]['phases'][0]['archive']['bytes']=0 + self.write(self.evidence/'lifecycle-linux.json',self.lifecycle) + with self.assertRaisesRegex(ValueError,'pre-install'):self.verify() + def test_manifest_cannot_misstate_included_licenses(self): + self.manifest['licensesIncluded']=[] + self.write(self.directory/'manifest.json',self.manifest);self.write(self.evidence/'lifecycle-linux.json',self.lifecycle) + with self.assertRaisesRegex(ValueError,'license'):self.verify() + def test_linked_manifest_refused(self): + file=self.directory/'manifest.json';target=self.out/'other.json';file.rename(target);file.symlink_to(target) + with self.assertRaisesRegex(ValueError,'regular'):self.verify() class HookTests(unittest.TestCase): @@ -168,6 +294,11 @@ def test_complete_transaction_is_resnapshotted_for_reconfigure(self): class NativeBoundaryTests(unittest.TestCase): + def test_final_identity_rejects_wrong_job_repo_source_and_noncanonical_run(self): + env={'QUALIFIED_SOURCE_SHA':'a'*40,'QUALIFIED_SOURCE_TREE':'b'*40,'GITHUB_REPOSITORY':'vmpowerio/agent','GITHUB_JOB':'linux-native','GITHUB_RUN_ID':'123','GITHUB_RUN_ATTEMPT':'1'} + with mock.patch.dict(os.environ,env,clear=True):self.assertEqual(dpkg_fixture.final_identity()['runId'],'123') + for key,value in [('GITHUB_JOB','windows-native'),('GITHUB_REPOSITORY','other/agent'),('QUALIFIED_SOURCE_SHA',''),('QUALIFIED_SOURCE_TREE','c'*39),('GITHUB_RUN_ID','00123'),('GITHUB_RUN_ATTEMPT','0')]: + with self.subTest(key=key),mock.patch.dict(os.environ,{**env,key:value},clear=True),self.assertRaises((ValueError,RuntimeError)):dpkg_fixture.final_identity() def test_slice_refuses_mac_before_reading_ownership(self): with mock.patch.object(quarantine.sys,'platform','darwin'): with self.assertRaisesRegex(RuntimeError,'HOST_REFUSED'):quarantine.begin(native,Path('/not-used'),1234) diff --git a/scripts/test-linux-slice.py b/scripts/test-linux-slice.py index c88b72c..73f1b53 100644 --- a/scripts/test-linux-slice.py +++ b/scripts/test-linux-slice.py @@ -76,6 +76,90 @@ def test_atomic_progress_survives_next_operation(self): with self.assertRaises(commands.CommandFailure):commands.run(sys.executable,'-c','raise SystemExit(2)') self.assertEqual(json.loads((evidence/'progress-linux.json').read_text())['completed'][0]['phase'],'completed') +class FailureDiagnosticTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup);self.root=Path(self.temp.name) + (self.root/'evidence').mkdir() + self.valid={'pid':123,'port':4567,'count':2,'rejected':0,'firstReportAt':1000,'lastReportAt':61000} + def state(self,value): + (self.root/'receiver.json').write_text(json.dumps(value)) + return native.receiver_diagnostic(self.root) + def test_valid_state_projects_only_counts_and_derived_interval(self): + self.assertEqual(self.state(self.valid),{'state':'VALID','count':2,'rejected':0,'intervalMs':60000}) + self.assertEqual(self.state({**self.valid,'count':0,'firstReportAt':None,'lastReportAt':None}),{'state':'VALID','count':0,'rejected':0,'intervalMs':None}) + def test_missing_invalid_and_unverified_never_substitute_zero(self): + self.assertEqual(native.receiver_diagnostic(self.root)['state'],'MISSING') + for raw in ['', '{', 'secret'*1000, '[]']: + (self.root/'receiver.json').write_text(raw) + result=native.receiver_diagnostic(self.root) + self.assertEqual(result['state'],'INVALID');self.assertIsNone(result['count']);self.assertIsNone(result['rejected']) + with mock.patch.object(native.os,'open',side_effect=PermissionError('secret')): + self.assertEqual(native.receiver_diagnostic(self.root),{'state':'UNVERIFIED','count':None,'rejected':None,'intervalMs':None}) + def test_invalid_fields_and_bounds(self): + for key,value in [('count',True),('count',-1),('count',1000001),('rejected','secret'),('firstReportAt',float('nan')),('lastReportAt',999),('lastReportAt',86401001),('pid',0),('port',65536)]: + with self.subTest(key=key,value=value): + result=self.state({**self.valid,key:value});self.assertEqual(result['state'],'INVALID');self.assertIsNone(result['count']) + self.assertEqual(self.state({**self.valid,'count':1})['state'],'INVALID') + self.assertEqual(self.state({**self.valid,'secret':'do-not-emit'})['state'],'INVALID') + def test_link_fifo_and_changed_file_cannot_be_valid(self): + target=self.root/'secret';target.write_text(json.dumps(self.valid));link=self.root/'receiver.json';link.symlink_to(target) + self.assertEqual(native.receiver_diagnostic(self.root)['state'],'UNVERIFIED');link.unlink() + os.mkfifo(link);self.assertEqual(native.receiver_diagnostic(self.root)['state'],'INVALID');link.unlink() + self.state(self.valid) + before=os.stat(link);after=mock.Mock(st_size=before.st_size,st_mtime_ns=before.st_mtime_ns+1) + with mock.patch.object(native.os,'fstat',side_effect=[before,after]):self.assertEqual(native.receiver_diagnostic(self.root)['state'],'UNVERIFIED') + def test_owned_location_and_secret_exception_are_closed(self): + filename=str(native.ROOT/'scripts/qualify-native-linux.py') + namespace={};exec(compile('def fail():\n assert False, "secret/path?token=hidden"\n',filename,'exec'),namespace) + try:namespace['fail']() + except AssertionError as exc:result=native.failure_diagnostic(exc,self.root) + self.assertEqual(result['category'],'ASSERTION');self.assertEqual(result['locations'],[{'script':'qualify-native-linux.py','line':2}]) + self.assertNotIn('secret',json.dumps(result));self.assertNotIn(str(native.ROOT),json.dumps(result)) + def test_foreign_same_basename_and_long_or_nested_trace_are_bounded(self): + for filename in ['/foreign/qualify-native-linux.py',str(native.ROOT/'scripts/../scripts/qualify-native-linux.py'),'']: + namespace={};exec(compile('def fail():\n raise KeyError("secret")\n',filename,'exec'),namespace) + try:namespace['fail']() + except KeyError as exc:result=native.failure_diagnostic(exc,self.root) + self.assertEqual(result['locations'],[]);self.assertEqual(result['category'],'KEY') + namespace={};exec(compile('def fail(n):\n if n: return fail(n-1)\n raise RuntimeError("secret")\n',str(native.ROOT/'scripts/qualify-native-linux.py'),'exec'),namespace) + try:namespace['fail'](70) + except RuntimeError as exc:result=native.failure_diagnostic(exc,self.root) + self.assertLessEqual(len(result['locations']),2) + def test_command_record_fields_are_whitelisted(self): + record={'operation':'OWNED_SCOPE','classification':'EXIT_NONZERO','exitCode':1,'argv':'secret','diagnosticLabels':['secret']} + result=native.failure_diagnostic(commands.CommandFailure(record),self.root) + self.assertEqual(result['command'],{'operation':'OWNED_SCOPE','classification':'EXIT_NONZERO','exitCode':1});self.assertNotIn('secret',json.dumps(result)) + for code in [True,256,-65,'secret']: + self.assertEqual(native.command_diagnostic({'operation':'secret','classification':'secret','exitCode':code}),{'operation':'UNCLASSIFIED','classification':'UNCLASSIFIED','exitCode':None}) + def test_hostile_exception_getters_and_diagnostic_errors_do_not_escape(self): + class Hostile(RuntimeError): + def __str__(self):raise AssertionError('secret') + def __getattribute__(self,name):raise AssertionError('secret') + self.assertEqual(native.failure_diagnostic(Hostile(),self.root)['category'],'OTHER') + with mock.patch.object(native,'receiver_diagnostic',side_effect=RuntimeError('secret')): + result=native.failure_diagnostic(AssertionError('primary-secret'),self.root) + self.assertEqual(result['category'],'ASSERTION');self.assertEqual(result['receiver']['state'],'UNVERIFIED') + def test_actual_qualifier_catch_preserves_primary_before_cleanup_overwrite(self): + failure=AssertionError('primary-secret');self.state(self.valid) + original={'operation':'NODE','classification':'EXIT_NONZERO','exitCode':2} + commands.atomic(self.root/'evidence/operation-linux.json',original) + (self.root/'evidence/prepared.json').write_text(json.dumps({'bundles':{'bundle':'a'*64},'head':'b'*40})) + with mock.patch.object(native,'guard'),mock.patch.object(native,'require_output',return_value=self.root),mock.patch.object(native,'MANAGED',[]),mock.patch.object(native,'command',return_value=mock.Mock(stdout='not-found')),mock.patch.object(native.pwd,'getpwnam',side_effect=KeyError),mock.patch.object(native.grp,'getgrnam',side_effect=KeyError),mock.patch.dict(os.environ,{'GITHUB_RUN_ID':'12345'}),mock.patch.object(native,'fixed_ancestor_metadata',side_effect=failure): + with self.assertRaises(AssertionError) as caught:native.qualify(self.root) + self.assertIs(caught.exception,failure) + saved=json.loads((self.root/'evidence/lifecycle-linux.json').read_text());snapshot=saved['diagnostic'] + self.assertEqual(snapshot['category'],'ASSERTION');self.assertEqual(snapshot['lastCommand'],original);self.assertEqual(snapshot['receiver']['intervalMs'],60000) + commands.atomic(self.root/'evidence/operation-linux.json',{'operation':'SYSTEMCTL','classification':'SUCCESS','exitCode':0}) + saved['cleanup']={'status':'PASS'};commands.atomic(self.root/'evidence/lifecycle-linux.json',saved) + self.assertEqual(json.loads((self.root/'evidence/lifecycle-linux.json').read_text())['diagnostic'],snapshot) + self.assertNotIn('primary-secret',json.dumps(saved)) + def test_scope_exit_is_not_query_failure(self): + failure=commands.CommandFailure({'operation':'OWNED_SCOPE','classification':'EXIT_NONZERO','exitCode':1}) + self.assertEqual(isolation.failure_record(failure,'QUALIFIER')['reason'],'OWNED_SCOPE_FAILED') + failure.record['operation']='SYSTEMCTL' + self.assertEqual(isolation.failure_record(failure,'CLEANUP')['reason'],'COMMAND_FAILED') + self.assertEqual(isolation.failure_record(isolation.AdmissionRefused('QUERY_FAILED',query='LIST_UNITS'),'UNIT_ADMISSION')['reason'],'QUERY_FAILED') + class SliceTests(unittest.TestCase): def test_run_id_bounded_canonical_identity(self): for run in ['1','12345','99999999999999999999']: