Merge lp:~sylvain-pineau/checkbox/trusted-launcher-standalone into lp:checkbox

Proposed by Sylvain Pineau
Status: Merged
Approved by: Sylvain Pineau
Approved revision: 2107
Merged at revision: 2107
Proposed branch: lp:~sylvain-pineau/checkbox/trusted-launcher-standalone
Merge into: lp:checkbox
Diff against target: 1208 lines (+814/-111)
11 files modified
plainbox/.coveragerc (+2/-0)
plainbox/MANIFEST.in (+1/-0)
plainbox/plainbox/data/org.freedesktop.policykit.pkexec.policy (+30/-0)
plainbox/plainbox/impl/job.py (+10/-59)
plainbox/plainbox/impl/rfc822.py (+4/-19)
plainbox/plainbox/impl/runner.py (+39/-16)
plainbox/plainbox/impl/secure/__init__.py (+27/-0)
plainbox/plainbox/impl/secure/checkbox_trusted_launcher.py (+402/-0)
plainbox/plainbox/impl/secure/test_checkbox_trusted_launcher.py (+268/-0)
plainbox/plainbox/impl/test_rfc822.py (+23/-16)
plainbox/setup.py (+8/-1)
To merge this branch: bzr merge lp:~sylvain-pineau/checkbox/trusted-launcher-standalone
Reviewer Review Type Date Requested Status
Sylvain Pineau (community) Approve
Zygmunt Krynicki (community) Approve
Review via email: mp+157045@code.launchpad.net

Description of the change

This is an implementation proposal for Plainbox.

It provides the trusted launcher component to run commands as another user (root most of time).
This launcher has be started by pkexec to apply the rules defined in the policy file.
The root password will only be asked once for the lifetime of the plainbox process (see auth_admin_keep).

The policy file has to be installed manually (sorry...) to test it (in /usr/share/polkit-1/actions)

It's just a proof of concept as some code is needed to ask the password when the sessions starts not before the first test requiring root.

To avoid running the trusted launcher as a service, it will be started for each test requiring root privileges.

To post a comment you must log in.
Revision history for this message
Zygmunt Krynicki (zyga) wrote :

The standalone launcher looks pretty good. I need to look at the other bits and read it in more detail.
I'll send a second review after fixing trunk now.

Revision history for this message
Zygmunt Krynicki (zyga) wrote :

92 + cmd = ['checkbox-trusted-launcher', job.get_checksum()] + [
93 "{key}={value}".format(key=key, value=value)
94 for key, value in self._get_script_env(
95 job, only_changes=True
96 ).items()
97 - ] + cmd
98 + ]
99 + if job.via is not None:
100 + cmd = cmd + ['--via', job.via]
101 + if job._checkbox._mode == 'src':
102 + # Running PlainBox from source doesn't require the trusted
103 + # launcher

This looks as if it was structured incorrectly. How about something like this:

if job._checkbox._mode == 'src':
   cmd = self._get_command_src()
elif job.user is not None:
   cmd = self._get_command_trusted()
else:
   ...

+ for filename in path_expand("/usr/share/checkbox*"):

Isn't that /usr/share/checkbox/jobs/*.txt

+
365 + for job in desired_job_list:
366 + job_result = subprocess.check_output(

This means we'll runn all jobs as root. I think we should respect the user key and actually use the right user for each particular job that we execute (including local jobs)

367 + job.command,
368 + shell=True,
369 + universal_newlines=True,
370 + env=job.modify_execution_environment(args.ENV, packages))
371 + stream = io.StringIO()
372 + stream.write(job_result)

Yuck! Just pass PIPE and use the stream, there is no need to copy anything

373 + stream.seek(0)
374 + for message in RFC822Parser().loads(stream):

I'll keep reviewing this

Revision history for this message
Zygmunt Krynicki (zyga) wrote :

+include org.freedesktop.policykit.pkexec.policy

Let's keep that in a non-root directory. Ideas welcome

172 +class Job():

No need to use () there

340 + # List of all available jobs in system-wide locations
341 + builtin_jobs = []
342 + # List of all checkbox related packages, like checkbox-oem
343 + packages = []

I don't like this part. I would much rather have a fixed set of places and not probe the system for anything.

344 +
345 + def path_expand(path):
346 + for path in glob.glob(path):

Variable shadowed

347 + packages.append(path)
348 + for dirpath, dirs, filenames in os.walk(os.path.join(
349 + path, 'jobs')):
350 + for name in filenames:
351 + if name.endswith(".txt") or name.endswith(".txt.in"):
352 + yield os.path.join(dirpath, name)

All of that _lacks_tests_, copying tested code without adding test is semi-okay but adding features without a shred of tests is a no-go.

Revision history for this message
Zygmunt Krynicki (zyga) wrote :

Marking as needs fixing to get it to move to appropriate slot in +activereviews

review: Needs Fixing
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

The new version with tests is ready for review.

The documentation is still missing but I will propose it in a separate branch.

review: Needs Resubmitting
Revision history for this message
Zygmunt Krynicki (zyga) wrote :

103 + cmd = cmd + ['--via', job.via]

+=

107 + # Running PlainBox from source doesn't require the trusted launcher
108 + # That's why we use the env(1)' command and pass it the list of
109 + # changed environment variables.
110 + # The whole pkexec and env part gets prepended to the command
111 + # we were supposed to run.

docstring, also affects the method above

193 +:mod:`plainbox.scripts` -- code for external (trusted) launchers

Move to plainbox.impl.secure please

337 +
338 + pass

Remove

420 + Runner for jobs - executes jobs and produces results

Not true, the runner just executes the process and pipes back stdout/stderr

436 + if name.endswith(".txt") or name.endswith(".txt.in"):

.txt.in should be skipped, it is only relevant to in-tree development

477 + try:
478 + target_job = [j for j in lookup_list
479 + if j.get_checksum() == args.HASH][0]
480 + os.execve(
481 + '/bin/bash',
482 + ['bash', '-c', target_job.command],
483 + target_job.modify_execution_environment(
484 + args.ENV,
485 + self.packages)
486 + )
487 + except IndexError:
488 + return "Job not found"

Move try/index errror together, do anther try/catch for anything you may want to catch from os.execve

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Thanks for your review, I've fixed all the issues except the docstring. env(1) is really only used for the 'src' mode.
When usinf the trusted launcher, env keys are appended to the list of optional parameters.

Regarding the tests, I can't subclass the existing RFCParserTests. When called, it use the plainbox parser load method instead of the launcher one. So it was easier do just duplicate tests.

review: Needs Resubmitting
Revision history for this message
Zygmunt Krynicki (zyga) wrote :

> Regarding the tests, I can't subclass the existing RFCParserTests. When
> called, it use the plainbox parser load method instead of the launcher one. So
> it was easier do just duplicate tests.

You could add a test method like get_parser() that would return the appropriate parser method.

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

RFC822 parser tests are now shared between the trusted launcher and PlainBox core. No duplication of tests!

review: Needs Resubmitting
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Rebasing/reordering/squashing and solving the following issues:

setup.py: pep8 fixes (just for long lines)
runner.py: removed an uneeded logging call
policy file: use the checkbox icon
job.py: add a docstring to Job.via attribute
trusted-launcher: Removed a useless comment in the main function

review: Needs Resubmitting
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :
Download full text (3.9 KiB)

(05:29:23 PM) zyga: spineau: http://bazaar.launchpad.net/~sylvain-pineau/checkbox/trusted-launcher-standalone/revision/2103 the flow in _get_command_src is okay but looks strange, perhaps use cmd += three times or simply return the long expression instead
(05:30:59 PM) spineau: zyga: hehe, I just put there the existing command, but I can make it readable, gimme 5 m
(05:31:20 PM) zyga: spineau: in http://bazaar.launchpad.net/~sylvain-pineau/checkbox/trusted-launcher-standalone/revision/2101 I would move loader and syntax_error to class attributes and rename CommonRFC822ParserTests to MixIn, this way users would not need to worry about setup call (which is actually called per test, and setupclass is more appropriate), I would also move those loader/error attributes to class attributes as those are trivial to redefine in derivative c
(05:31:29 PM) zyga: spineau: but the code there looks great
(05:31:48 PM) zyga: spineau: and that's a very nice way to reuse tests and keep both implementation in sync
(05:33:37 PM) zyga: spineau: lookin at http://bazaar.launchpad.net/~sylvain-pineau/checkbox/trusted-launcher-standalone/revision/2100 I wonder if we could do a base secure job class, dedicated trusted launcher class and decidated normal job class, it would probably cut a lot of the duplication without affecting security, but it's up to you. I just thought that after that test reuse we could perhaps reuse some (most) of the boring job methods
(05:34:10 PM) zyga: spineau: ditto for errors and records, we _might_ import them from the non-secure codepath and cut code
(05:35:45 PM) zyga: spineau: in http://bazaar.launchpad.net/~sylvain-pineau/checkbox/trusted-launcher-standalone/revision/2100 in the _modify_execution_environment() method, did you use both 'bin' and 'scripts' for the old checkbox packaging change or was there any other reason?
(05:36:49 PM) spineau: zyga: I use both, but give precedence to bin
(05:37:00 PM) zyga: spineau: again in http://bazaar.launchpad.net/~sylvain-pineau/checkbox/trusted-launcher-standalone/revision/2100 I would use - instead of _ in metavar, typically metavar is what --help / --usage prints and the convention is to use dashes
(05:37:10 PM) zyga: spineau: yeah, I know you give precendence to bin
(05:37:17 PM) zyga: spineau: I was just checking if that was the cause
(05:37:20 PM) spineau: zyga: and yes it's for the old style packaging
(05:37:43 PM) zyga: spineau: on the same line, VIA_HASH should be lowercase as this is just a variable
(05:38:22 PM) zyga: spineau: same revision, diff lines 319-322, unless you had a reason not to, use with open(...) there
(05:39:41 PM) spineau: zyga: it may be a bug in coverage but with a "with" statement, I get a partial branch coverage, don't know why
(05:40:12 PM) zyga: spineau: same revision, you don't wait for the process wrapped by via_job_result, that adds zombies
(05:40:24 PM) zyga: spineau: interesting, flip that and repush, I'll investigate
(05:40:49 PM) zyga: spineau: I suspect you just need try/finally: with via_job_result.wait() there (to the second problem)
(05:40:59 PM) zyga: spineau: otherwise the runner looks okay
(05:41:42 PM) zyga: spineau: I've yet to read http://baz...

Read more...

review: Needs Fixing
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

This new version is mainly for code optimization and reuse.

base classes are provided by the secure module and inherited by the core.

review: Needs Resubmitting
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Ready to land

review: Needs Resubmitting
Revision history for this message
Zygmunt Krynicki (zyga) wrote :

+1

review: Approve
Revision history for this message
Daniel Manrique (roadmr) wrote :
Download full text (4.1 KiB)

The attempt to merge lp:~sylvain-pineau/checkbox/trusted-launcher-standalone into lp:checkbox failed. Below is the output from the failed tests.

[precise] Bringing VM 'up'
Timing for [precise] Bringing VM 'up'
7.99user 3.50system 5:44.30elapsed 3%CPU (0avgtext+0avgdata 21464maxresident)k
0inputs+200outputs (0major+186336minor)pagefaults 0swaps
[precise] Starting tests...
[precise] CheckBox test suite: pass
Timing for Checkbox test suite
1.14user 0.48system 0:11.96elapsed 13%CPU (0avgtext+0avgdata 20020maxresident)k
7352inputs+16outputs (85major+47707minor)pagefaults 0swaps
Timing for refreshing plainbox installation
0.75user 0.29system 0:05.57elapsed 18%CPU (0avgtext+0avgdata 20592maxresident)k
0inputs+16outputs (0major+49428minor)pagefaults 0swaps
[precise] PlainBox test suite: fail
[precise] stdout: http://paste.ubuntu.com/5641266/
[precise] stderr: http://paste.ubuntu.com/5641267/
Timing for plainbox test suite
Command exited with non-zero status 1
0.92user 0.33system 0:11.58elapsed 10%CPU (0avgtext+0avgdata 20796maxresident)k
0inputs+184outputs (0major+50181minor)pagefaults 0swaps
[precise] PlainBox documentation build: pass
Timing for plainbox documentation build
0.71user 0.31system 0:14.56elapsed 7%CPU (0avgtext+0avgdata 20512maxresident)k
0inputs+16outputs (0major+49217minor)pagefaults 0swaps
[precise] Integration tests: pass
Timing for integration tests
0.78user 0.25system 0:08.41elapsed 12%CPU (0avgtext+0avgdata 20832maxresident)k
0inputs+8outputs (0major+49897minor)pagefaults 0swaps
[precise] Destroying VM
[quantal] Bringing VM 'up'
Timing for [quantal] Bringing VM 'up'
8.05user 3.72system 4:02.01elapsed 4%CPU (0avgtext+0avgdata 21244maxresident)k
24inputs+184outputs (1major+207545minor)pagefaults 0swaps
[quantal] Starting tests...
[quantal] CheckBox test suite: pass
Timing for Checkbox test suite
0.76user 0.31system 0:12.89elapsed 8%CPU (0avgtext+0avgdata 20764maxresident)k
0inputs+16outputs (0major+49537minor)pagefaults 0swaps
Timing for refreshing plainbox installation
0.78user 0.30system 0:06.91elapsed 15%CPU (0avgtext+0avgdata 20800maxresident)k
0inputs+16outputs (0major+49407minor)pagefaults 0swaps
[quantal] PlainBox test suite: fail
[quantal] stdout: http://paste.ubuntu.com/5641278/
[quantal] stderr: http://paste.ubuntu.com/5641279/
Timing for plainbox test suite
Command exited with non-zero status 1
0.98user 0.34system 0:14.49elapsed 9%CPU (0avgtext+0avgdata 19996maxresident)k
0inputs+184outputs (0major+49673minor)pagefaults 0swaps
[quantal] PlainBox documentation build: pass
Timing for plainbox documentation build
0.72user 0.33system 0:07.19elapsed 14%CPU (0avgtext+0avgdata 20612maxresident)k
0inputs+8outputs (0major+49540minor)pagefaults 0swaps
[quantal] Integration tests: pass
Timing for integration tests
0.72user 0.32system 0:09.98elapsed 10%CPU (0avgtext+0avgdata 20608maxresident)k
0inputs+8outputs (0major+49259minor)pagefaults 0swaps
[quantal] Destroying VM
[raring] Bringing VM 'up'
Timing for [raring] Bringing VM 'up'
8.42user 3.67system 6:04.12elapsed 3%CPU (0avgtext+0avgdata 21468maxresident)k
0inputs+232outputs (0major+219084minor)pagefaults 0swaps
[raring] Starting tests...
[raring] CheckBox test suite: pass
Timi...

Read more...

Revision history for this message
Zygmunt Krynicki (zyga) wrote :

14:21 < zyga> spineau_afk: you have two test failures
14:22 < zyga> spineau_afk: so first one is mock older api, you can probably work around that with just assinging stuff after you instantiate mock itself
14:22 < zyga> spineau_afk: the second failure is a bit more annoying, that's argparse incompatibility between 3.2 and 3.3
14:22 < zyga> spineau_afk: plainbox has a fix for that, look at how I resolved it, the fix is in box.py with a lenghty comment that explains why it is needed
14:22 < zyga> spineau_afk: thanks

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Self-approval.

This version fixed the bug with Mock version 0.7.2 on precise and 0.8.0 on quantal.
Also resolved the argparse imcompatibility with python3.3, basically with run the launcher without args we call parser.error().

review: Approve
Revision history for this message
Daniel Manrique (roadmr) wrote :

The attempt to merge lp:~sylvain-pineau/checkbox/trusted-launcher-standalone into lp:checkbox failed. Below is the output from the failed tests.

[precise] Bringing VM 'up'
Timing for [precise] Bringing VM 'up'
8.50user 3.87system 6:01.39elapsed 3%CPU (0avgtext+0avgdata 21308maxresident)k
0inputs+216outputs (0major+184644minor)pagefaults 0swaps
[precise] Starting tests...
[precise] CheckBox test suite: pass
Timing for Checkbox test suite
0.82user 0.25system 0:11.25elapsed 9%CPU (0avgtext+0avgdata 20600maxresident)k
0inputs+16outputs (0major+48956minor)pagefaults 0swaps
Timing for refreshing plainbox installation
0.76user 0.31system 0:05.58elapsed 19%CPU (0avgtext+0avgdata 20584maxresident)k
0inputs+16outputs (0major+49567minor)pagefaults 0swaps
[precise] PlainBox test suite: pass
Timing for plainbox test suite
0.96user 0.32system 0:12.08elapsed 10%CPU (0avgtext+0avgdata 20800maxresident)k
0inputs+184outputs (0major+49372minor)pagefaults 0swaps
[precise] PlainBox documentation build: pass
Timing for plainbox documentation build
0.84user 0.29system 0:14.77elapsed 7%CPU (0avgtext+0avgdata 20820maxresident)k
0inputs+16outputs (0major+48997minor)pagefaults 0swaps
[precise] Integration tests: pass
Timing for integration tests
0.80user 0.32system 0:08.99elapsed 12%CPU (0avgtext+0avgdata 19980maxresident)k
0inputs+8outputs (0major+49591minor)pagefaults 0swaps
[precise] Destroying VM
[quantal] Bringing VM 'up'
Timing for [quantal] Bringing VM 'up'
7.78user 3.36system 3:49.48elapsed 4%CPU (0avgtext+0avgdata 22176maxresident)k
0inputs+176outputs (0major+195590minor)pagefaults 0swaps
[quantal] Starting tests...
[quantal] CheckBox test suite: pass
Timing for Checkbox test suite
0.78user 0.28system 0:13.06elapsed 8%CPU (0avgtext+0avgdata 20496maxresident)k
0inputs+16outputs (0major+49512minor)pagefaults 0swaps
Timing for refreshing plainbox installation
0.80user 0.27system 0:06.67elapsed 16%CPU (0avgtext+0avgdata 20680maxresident)k
0inputs+16outputs (0major+49785minor)pagefaults 0swaps
[quantal] PlainBox test suite: pass
Timing for plainbox test suite
0.98user 0.28system 0:14.31elapsed 8%CPU (0avgtext+0avgdata 20812maxresident)k
0inputs+184outputs (0major+49627minor)pagefaults 0swaps
[quantal] PlainBox documentation build: pass
Timing for plainbox documentation build
0.78user 0.26system 0:07.18elapsed 14%CPU (0avgtext+0avgdata 20600maxresident)k
0inputs+8outputs (0major+49466minor)pagefaults 0swaps
[quantal] Integration tests: pass
Timing for integration tests
0.82user 0.26system 0:10.03elapsed 10%CPU (0avgtext+0avgdata 20748maxresident)k
0inputs+8outputs (0major+49358minor)pagefaults 0swaps
[quantal] Destroying VM
[raring] Bringing VM 'up'
[raring] Unable to 'up' VM!
[raring] stdout: http://paste.ubuntu.com/5643984/
[raring] stderr: http://paste.ubuntu.com/5643985/
[raring] NOTE: unable to execute tests, marked as failed

Revision history for this message
Daniel Manrique (roadmr) wrote :

The attempt to merge lp:~sylvain-pineau/checkbox/trusted-launcher-standalone into lp:checkbox failed. Below is the output from the failed tests.

[precise] Bringing VM 'up'
[precise] Unable to 'up' VM!
[precise] stdout: http://paste.ubuntu.com/5644063/
[precise] stderr: http://paste.ubuntu.com/5644064/
[precise] NOTE: unable to execute tests, marked as failed
[quantal] Bringing VM 'up'
[quantal] Unable to 'up' VM!
[quantal] stdout: http://paste.ubuntu.com/5644065/
[quantal] stderr: http://paste.ubuntu.com/5644066/
[quantal] NOTE: unable to execute tests, marked as failed
[raring] Bringing VM 'up'
[raring] Unable to 'up' VM!
[raring] stdout: http://paste.ubuntu.com/5644067/
[raring] stderr: http://paste.ubuntu.com/5644068/
[raring] NOTE: unable to execute tests, marked as failed

Revision history for this message
Daniel Manrique (roadmr) wrote :

The attempt to merge lp:~sylvain-pineau/checkbox/trusted-launcher-standalone into lp:checkbox failed. Below is the output from the failed tests.

[precise] Bringing VM 'up'
[precise] Unable to 'up' VM!
[precise] stdout: http://paste.ubuntu.com/5644099/
[precise] stderr: http://paste.ubuntu.com/5644100/
[precise] NOTE: unable to execute tests, marked as failed
[quantal] Bringing VM 'up'
[quantal] Unable to 'up' VM!
[quantal] stdout: http://paste.ubuntu.com/5644104/
[quantal] stderr: http://paste.ubuntu.com/5644105/
[quantal] NOTE: unable to execute tests, marked as failed
[raring] Bringing VM 'up'
[raring] Unable to 'up' VM!
[raring] stdout: http://paste.ubuntu.com/5644107/
[raring] stderr: http://paste.ubuntu.com/5644108/
[raring] NOTE: unable to execute tests, marked as failed

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'plainbox/.coveragerc'
2--- plainbox/.coveragerc 2013-03-22 08:30:28 +0000
3+++ plainbox/.coveragerc 2013-05-08 07:38:29 +0000
4@@ -9,6 +9,8 @@
5 plainbox/impl/integration_tests.py
6 plainbox/impl/commands/test_*
7 plainbox/impl/exporter/test_*
8+ plainbox/impl/transport/test_*
9+ plainbox/impl/secure/test_*
10 plainbox/testing_utils/test_*
11
12 [report]
13
14=== modified file 'plainbox/MANIFEST.in'
15--- plainbox/MANIFEST.in 2013-04-04 17:12:12 +0000
16+++ plainbox/MANIFEST.in 2013-05-08 07:38:29 +0000
17@@ -5,3 +5,4 @@
18 recursive-include docs *.rst
19 include docs/conf.py
20 include plainbox/data/report/hardware-1_0.rng
21+include plainbox/data/org.freedesktop.policykit.pkexec.policy
22
23=== added file 'plainbox/plainbox/data/org.freedesktop.policykit.pkexec.policy'
24--- plainbox/plainbox/data/org.freedesktop.policykit.pkexec.policy 1970-01-01 00:00:00 +0000
25+++ plainbox/plainbox/data/org.freedesktop.policykit.pkexec.policy 2013-05-08 07:38:29 +0000
26@@ -0,0 +1,30 @@
27+<?xml version="1.0" encoding="UTF-8"?>
28+<!DOCTYPE policyconfig PUBLIC
29+ "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
30+ "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
31+<policyconfig>
32+
33+ <!--
34+ Policy definitions for PlainBox system actions.
35+ (C) 2013 Canonical Ltd.
36+ Author: Sylvain Pineau <sylvain.pineau@canonical.com>
37+ -->
38+
39+ <vendor>PlainBox</vendor>
40+ <vendor_url>https://launchpad.net/checkbox</vendor_url>
41+ <icon_name>checkbox</icon_name>
42+
43+ <action id="org.freedesktop.policykit.pkexec.run-plainbox-job">
44+ <description>Run Job command</description>
45+ <message>Authentication is required to run a job command as another user</message>
46+ <defaults>
47+ <allow_any>no</allow_any>
48+ <allow_inactive>no</allow_inactive>
49+ <allow_active>auth_admin_keep</allow_active>
50+ </defaults>
51+ <annotate key="org.freedesktop.policykit.exec.path">/usr/bin/checkbox-trusted-launcher</annotate>
52+ <annotate key="org.freedesktop.policykit.exec.allow_gui">TRUE</annotate>
53+ </action>
54+
55+</policyconfig>
56+
57
58=== modified file 'plainbox/plainbox/impl/job.py'
59--- plainbox/plainbox/impl/job.py 2013-04-23 00:16:38 +0000
60+++ plainbox/plainbox/impl/job.py 2013-05-08 07:38:29 +0000
61@@ -26,9 +26,6 @@
62 THIS MODULE DOES NOT HAVE STABLE PUBLIC API
63 """
64
65-import collections
66-import hashlib
67-import json
68 import logging
69 import os
70 import re
71@@ -36,12 +33,13 @@
72 from plainbox.abc import IJobDefinition
73 from plainbox.impl.config import Unset
74 from plainbox.impl.resource import ResourceProgram
75+from plainbox.impl.secure.checkbox_trusted_launcher import BaseJob
76
77
78 logger = logging.getLogger("plainbox.job")
79
80
81-class JobDefinition(IJobDefinition):
82+class JobDefinition(BaseJob, IJobDefinition):
83 """
84 Job definition class.
85
86@@ -50,10 +48,6 @@
87 """
88
89 @property
90- def plugin(self):
91- return self.__getattr__('plugin')
92-
93- @property
94 def name(self):
95 return self.__getattr__('name')
96
97@@ -65,13 +59,6 @@
98 return None
99
100 @property
101- def command(self):
102- try:
103- return self.__getattr__('command')
104- except AttributeError:
105- return None
106-
107- @property
108 def description(self):
109 try:
110 return self.__getattr__('description')
111@@ -86,16 +73,13 @@
112 return None
113
114 @property
115- def user(self):
116- try:
117- return self.__getattr__('user')
118- except AttributeError:
119- return None
120-
121- @property
122- def environ(self):
123- try:
124- return self.__getattr__('environ')
125+ def via(self):
126+ """
127+ The checksum of the "parent" job when the current JobDefinition comes
128+ from a job output using the local plugin
129+ """
130+ try:
131+ return self.__getattr__('via')
132 except AttributeError:
133 return None
134
135@@ -184,15 +168,6 @@
136 else:
137 return set()
138
139- def get_environ_settings(self):
140- """
141- Return a set of requested environment variables
142- """
143- if self.environ is not None:
144- return {variable for variable in re.split('[\s,]+', self.environ)}
145- else:
146- return set()
147-
148 @classmethod
149 def from_rfc822_record(cls, record):
150 """
151@@ -270,35 +245,11 @@
152 jobs (plugin local). The intent is to encapsulate the sharing of the
153 embedded checkbox reference.
154 """
155+ record.data['via'] = self.get_checksum()
156 job = self.from_rfc822_record(record)
157 job._checkbox = self._checkbox
158 return job
159
160- def get_checksum(self):
161- """
162- Compute a checksum of the job definition.
163-
164- This method can be used to compute the checksum of the canonical form
165- of the job definition. The canonical form is the UTF-8 encoded JSON
166- serialization of the data that makes up the full definition of the job
167- (all keys and values). The JSON serialization uses no indent and
168- minimal separators.
169-
170- The checksum is defined as the SHA256 hash of the canonical form.
171- """
172- # Ideally we'd use simplejson.dumps() with sorted keys to get
173- # predictable serialization but that's another dependency. To get
174- # something simple that is equally reliable, just sort all the keys
175- # manually and ask standard json to serialize that..
176- sorted_data = collections.OrderedDict(sorted(self._data.items()))
177- # Compute the canonical form which is arbitrarily defined as sorted
178- # json text with default indent and separator settings.
179- canonical_form = json.dumps(
180- sorted_data, indent=None, separators=(',', ':'))
181- # Compute the sha256 hash of the UTF-8 encoding of the canonical form
182- # and return the hex digest as the checksum that can be displayed.
183- return hashlib.sha256(canonical_form.encode('UTF-8')).hexdigest()
184-
185 @classmethod
186 def from_json_record(cls, record):
187 """
188
189=== modified file 'plainbox/plainbox/impl/rfc822.py'
190--- plainbox/plainbox/impl/rfc822.py 2013-02-25 11:02:59 +0000
191+++ plainbox/plainbox/impl/rfc822.py 2013-05-08 07:38:29 +0000
192@@ -32,6 +32,9 @@
193
194 from inspect import cleandoc
195
196+from plainbox.impl.secure.checkbox_trusted_launcher import RFC822SyntaxError
197+from plainbox.impl.secure.checkbox_trusted_launcher import BaseRFC822Record
198+
199 logger = logging.getLogger("plainbox.rfc822")
200
201
202@@ -62,7 +65,7 @@
203 self.filename, self.line_start, self.line_end)
204
205
206-class RFC822Record:
207+class RFC822Record(BaseRFC822Record):
208 """
209 Class for tracking RFC822 records
210
211@@ -85,24 +88,6 @@
212 """
213 return self._origin
214
215- @property
216- def data(self):
217- """
218- The data set (dictionary)
219- """
220- return self._data
221-
222-
223-class RFC822SyntaxError(SyntaxError):
224- """
225- SyntaxError subclass for RFC822 parsing functions
226- """
227-
228- def __init__(self, filename, lineno, msg):
229- self.filename = filename
230- self.lineno = lineno
231- self.msg = msg
232-
233
234 def load_rfc822_records(stream, data_cls=dict):
235 """
236
237=== modified file 'plainbox/plainbox/impl/runner.py'
238--- plainbox/plainbox/impl/runner.py 2013-04-25 09:23:45 +0000
239+++ plainbox/plainbox/impl/runner.py 2013-05-08 07:38:29 +0000
240@@ -298,6 +298,37 @@
241 else:
242 return env
243
244+ def _get_command_trusted(self, job, config=None):
245+ # When the job requires to run as root then elevate our permissions
246+ # via pkexec(1). Since pkexec resets environment we need to somehow
247+ # pass the extra things we require. To do that we pass the list of
248+ # changed environment variables in addition to the job hash.
249+ cmd = ['checkbox-trusted-launcher', job.get_checksum()] + [
250+ "{key}={value}".format(key=key, value=value)
251+ for key, value in self._get_script_env(
252+ job, config, only_changes=True
253+ ).items()
254+ ]
255+ if job.via is not None:
256+ cmd += ['--via', job.via]
257+ return cmd
258+
259+ def _get_command_src(self, job, config=None):
260+ # Running PlainBox from source doesn't require the trusted launcher
261+ # That's why we use the env(1)' command and pass it the list of
262+ # changed environment variables.
263+ # The whole pkexec and env part gets prepended to the command
264+ # we were supposed to run.
265+ cmd = ['env']
266+ cmd += [
267+ "{key}={value}".format(key=key, value=value)
268+ for key, value in self._get_script_env(
269+ job, only_changes=True
270+ ).items()
271+ ]
272+ cmd += ['bash', '-c', job.command]
273+ return cmd
274+
275 def _run_command(self, job, config):
276 """
277 Run the shell command associated with the specified job.
278@@ -350,26 +381,18 @@
279 # threads although all callbacks will be fired from a single
280 # thread (which is _not_ the main thread)
281 logger.debug("job[%s] starting command: %s", job.name, job.command)
282- # XXX: sadly using /bin/sh results in broken output
283- # XXX: maybe run it both ways and raise exceptions on differences?
284- cmd = ['bash', '-c', job.command]
285 if job.user is not None:
286- # When the job requires to run as root then elevate our permissions
287- # via pkexec(1). Since pkexec resets environment we need to somehow
288- # pass the extra things we require. To do that we use the env(1)
289- # command and pass it the list of changed environment variables.
290- #
291- # The whole pkexec and env part gets prepended to the command we
292- # were supposed to run.
293- cmd = ['pkexec', '--user', job.user, 'env'] + [
294- "{key}={value}".format(key=key, value=value)
295- for key, value in self._get_script_env(
296- job, config, only_changes=True
297- ).items()
298- ] + cmd
299+ if job._checkbox._mode == 'src':
300+ cmd = self._get_command_src(job, config)
301+ else:
302+ cmd = self._get_command_trusted(job, config)
303+ cmd = ['pkexec', '--user', job.user] + cmd
304 logging.debug("job[%s] executing %r", job.name, cmd)
305 return_code = logging_popen.call(cmd)
306 else:
307+ # XXX: sadly using /bin/sh results in broken output
308+ # XXX: maybe run it both ways and raise exceptions on differences?
309+ cmd = ['bash', '-c', job.command]
310 logging.debug("job[%s] executing %r", job.name, cmd)
311 return_code = logging_popen.call(
312 cmd, env=self._get_script_env(job, config))
313
314=== added directory 'plainbox/plainbox/impl/secure'
315=== added file 'plainbox/plainbox/impl/secure/__init__.py'
316--- plainbox/plainbox/impl/secure/__init__.py 1970-01-01 00:00:00 +0000
317+++ plainbox/plainbox/impl/secure/__init__.py 2013-05-08 07:38:29 +0000
318@@ -0,0 +1,27 @@
319+# This file is part of Checkbox.
320+#
321+# Copyright 2013 Canonical Ltd.
322+# Written by:
323+# Sylvain Pineau <sylvain.pineau@canonical.com>
324+#
325+# Checkbox is free software: you can redistribute it and/or modify
326+# it under the terms of the GNU General Public License as published by
327+# the Free Software Foundation, either version 3 of the License, or
328+# (at your option) any later version.
329+#
330+# Checkbox is distributed in the hope that it will be useful,
331+# but WITHOUT ANY WARRANTY; without even the implied warranty of
332+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
333+# GNU General Public License for more details.
334+#
335+# You should have received a copy of the GNU General Public License
336+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
337+
338+"""
339+:mod:`plainbox.impl.secure` -- code for external (trusted) launchers
340+====================================================================
341+
342+.. warning::
343+
344+ THIS MODULE DOES NOT HAVE STABLE PUBLIC API
345+"""
346
347=== added file 'plainbox/plainbox/impl/secure/checkbox_trusted_launcher.py'
348--- plainbox/plainbox/impl/secure/checkbox_trusted_launcher.py 1970-01-01 00:00:00 +0000
349+++ plainbox/plainbox/impl/secure/checkbox_trusted_launcher.py 2013-05-08 07:38:29 +0000
350@@ -0,0 +1,402 @@
351+# This file is part of Checkbox.
352+#
353+# Copyright 2013 Canonical Ltd.
354+# Written by:
355+# Sylvain Pineau <sylvain.pineau@canonical.com>
356+#
357+# Checkbox is free software: you can redistribute it and/or modify
358+# it under the terms of the GNU General Public License as published by
359+# the Free Software Foundation, either version 3 of the License, or
360+# (at your option) any later version.
361+#
362+# Checkbox is distributed in the hope that it will be useful,
363+# but WITHOUT ANY WARRANTY; without even the implied warranty of
364+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
365+# GNU General Public License for more details.
366+#
367+# You should have received a copy of the GNU General Public License
368+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
369+
370+"""
371+:mod:`plainbox.impl.secure.checkbox_trusted_launcher` -- command launcher
372+=========================================================================
373+
374+.. warning::
375+
376+ THIS MODULE DOES NOT HAVE STABLE PUBLIC API
377+"""
378+
379+import argparse
380+import collections
381+import glob
382+import hashlib
383+import json
384+import os
385+import re
386+import subprocess
387+from argparse import _ as argparse_gettext
388+from inspect import cleandoc
389+
390+
391+class BaseJob:
392+ """
393+ Base Job definition class.
394+ """
395+
396+ @property
397+ def plugin(self):
398+ return self.__getattr__('plugin')
399+
400+ @property
401+ def command(self):
402+ try:
403+ return self.__getattr__('command')
404+ except AttributeError:
405+ return None
406+
407+ @property
408+ def environ(self):
409+ try:
410+ return self.__getattr__('environ')
411+ except AttributeError:
412+ return None
413+
414+ @property
415+ def user(self):
416+ try:
417+ return self.__getattr__('user')
418+ except AttributeError:
419+ return None
420+
421+ def __init__(self, data):
422+ self._data = data
423+
424+ def __getattr__(self, attr):
425+ if attr in self._data:
426+ return self._data[attr]
427+ raise AttributeError(attr)
428+
429+ def get_checksum(self):
430+ """
431+ Compute a checksum of the job definition.
432+
433+ This method can be used to compute the checksum of the canonical form
434+ of the job definition. The canonical form is the UTF-8 encoded JSON
435+ serialization of the data that makes up the full definition of the job
436+ (all keys and values). The JSON serialization uses no indent and
437+ minimal separators.
438+
439+ The checksum is defined as the SHA256 hash of the canonical form.
440+ """
441+ # Ideally we'd use simplejson.dumps() with sorted keys to get
442+ # predictable serialization but that's another dependency. To get
443+ # something simple that is equally reliable, just sort all the keys
444+ # manually and ask standard json to serialize that..
445+ sorted_data = collections.OrderedDict(sorted(self._data.items()))
446+ # Compute the canonical form which is arbitrarily defined as sorted
447+ # json text with default indent and separator settings.
448+ canonical_form = json.dumps(
449+ sorted_data, indent=None, separators=(',', ':'))
450+ # Compute the sha256 hash of the UTF-8 encoding of the canonical form
451+ # and return the hex digest as the checksum that can be displayed.
452+ return hashlib.sha256(canonical_form.encode('UTF-8')).hexdigest()
453+
454+ def get_environ_settings(self):
455+ """
456+ Return a set of requested environment variables
457+ """
458+ if self.environ is not None:
459+ return {variable for variable in re.split('[\s,]+', self.environ)}
460+ else:
461+ return set()
462+
463+ def modify_execution_environment(self, environ, packages):
464+ """
465+ Compute the environment the script will be executed in
466+ """
467+ # Get a proper environment
468+ env = dict(os.environ)
469+ # Use non-internationalized environment
470+ env['LANG'] = 'C.UTF-8'
471+ # Create CHECKBOX*_SHARE for every checkbox related packages
472+ # Add their respective script directory to the PATH variable
473+ # giving precedence to those located in /usr/lib/
474+ for path in packages:
475+ basename = os.path.basename(path)
476+ env[basename.upper().replace('-', '_') + '_SHARE'] = path
477+ # Update PATH so that scripts can be found
478+ env['PATH'] = os.pathsep.join([
479+ os.path.join('usr', 'lib', basename, 'bin'),
480+ os.path.join(path, 'scripts')]
481+ + env.get("PATH", "").split(os.pathsep))
482+ if 'CHECKBOX_DATA' in env:
483+ env['CHECKBOX_DATA'] = environ['CHECKBOX_DATA']
484+ # Add new environment variables only if they are defined in the
485+ # job environ property
486+ for key in self.get_environ_settings():
487+ if key in environ:
488+ env[key] = environ[key]
489+ return env
490+
491+
492+class BaseRFC822Record:
493+ """
494+ Base class for tracking RFC822 records
495+
496+ This is a simple container for the dictionary of data.
497+ """
498+
499+ def __init__(self, data):
500+ self._data = data
501+
502+ @property
503+ def data(self):
504+ """
505+ The data set (dictionary)
506+ """
507+ return self._data
508+
509+
510+class RFC822SyntaxError(SyntaxError):
511+ """
512+ SyntaxError subclass for RFC822 parsing functions
513+ """
514+
515+ def __init__(self, filename, lineno, msg):
516+ self.filename = filename
517+ self.lineno = lineno
518+ self.msg = msg
519+
520+
521+def load_rfc822_records(stream, data_cls=dict):
522+ """
523+ Load a sequence of rfc822-like records from a text stream.
524+
525+ Each record consists of any number of key-value pairs. Subsequent records
526+ are separated by one blank line. A record key may have a multi-line value
527+ if the line starts with whitespace character.
528+
529+ Returns a list of subsequent values as instances BaseRFC822Record class. If
530+ the optional data_cls argument is collections.OrderedDict then the values
531+ retain their original ordering.
532+ """
533+ return list(gen_rfc822_records(stream, data_cls))
534+
535+
536+def gen_rfc822_records(stream, data_cls=dict):
537+ """
538+ Load a sequence of rfc822-like records from a text stream.
539+
540+ Each record consists of any number of key-value pairs. Subsequent records
541+ are separated by one blank line. A record key may have a multi-line value
542+ if the line starts with whitespace character.
543+
544+ Returns a list of subsequent values as instances BaseRFC822Record class. If
545+ the optional data_cls argument is collections.OrderedDict then the values
546+ retain their original ordering.
547+ """
548+ record = None
549+ data = None
550+ key = None
551+ value_list = None
552+
553+ def _syntax_error(msg):
554+ """
555+ Report a syntax error in the current line
556+ """
557+ try:
558+ filename = stream.name
559+ except AttributeError:
560+ filename = None
561+ return RFC822SyntaxError(filename, lineno, msg)
562+
563+ def _new_record():
564+ """
565+ Reset local state to track new record
566+ """
567+ nonlocal key
568+ nonlocal value_list
569+ nonlocal record
570+ nonlocal data
571+ key = None
572+ value_list = None
573+ data = data_cls()
574+ record = BaseRFC822Record(data)
575+
576+ def _commit_key_value_if_needed():
577+ """
578+ Finalize the most recently seen key: value pair
579+ """
580+ nonlocal key
581+ if key is not None:
582+ data[key] = cleandoc('\n'.join(value_list))
583+ key = None
584+
585+ # Start with an empty record
586+ _new_record()
587+ # Iterate over subsequent lines of the stream
588+ for lineno, line in enumerate(stream, start=1):
589+ # Treat empty lines as record separators
590+ if line.strip() == "":
591+ # Commit the current record so that the multi-line value of the
592+ # last key, if any, is saved as a string
593+ _commit_key_value_if_needed()
594+ # If data is non-empty, yield the record, this allows us to safely
595+ # use newlines for formatting
596+ if data:
597+ yield record
598+ # Reset local state so that we can build a new record
599+ _new_record()
600+ # Treat lines staring with whitespace as multi-line continuation of the
601+ # most recently seen key-value
602+ elif line.startswith(" "):
603+ if key is None:
604+ # If we have not seen any keys yet then this is a syntax error
605+ raise _syntax_error("Unexpected multi-line value")
606+ # Append the current line to the list of values of the most recent
607+ # key. This prevents quadratic complexity of string concatenation
608+ if line == " .\n":
609+ value_list.append(" ")
610+ elif line == " ..\n":
611+ value_list.append(" .")
612+ else:
613+ value_list.append(line.rstrip())
614+ # Treat lines with a colon as new key-value pairs
615+ elif ":" in line:
616+ # Since we have a new, key-value pair we need to commit any
617+ # previous key that we may have (regardless of multi-line or
618+ # single-line values).
619+ _commit_key_value_if_needed()
620+ # Parse the line by splitting on the colon, get rid of additional
621+ # whitespace from both key and the value
622+ key, value = line.split(":", 1)
623+ key = key.strip()
624+ value = value.strip()
625+ # Check if the key already exist in this message
626+ if key in record.data:
627+ raise _syntax_error((
628+ "Job has a duplicate key {!r} "
629+ "with old value {!r} and new value {!r}").format(
630+ key, record.data[key], value))
631+ # Construct initial value list out of the (only) value that we have
632+ # so far. Additional multi-line values will just append to
633+ # value_list
634+ value_list = [value]
635+ # Treat all other lines as syntax errors
636+ else:
637+ raise _syntax_error("Unexpected non-empty line")
638+ # Make sure to commit the last key from the record
639+ _commit_key_value_if_needed()
640+ # Once we've seen the whole file return the last record, if any
641+ if data:
642+ yield record
643+
644+
645+class Runner:
646+ """
647+ Runner for jobs
648+
649+ Executes the command process and pipes back stdout/stderr
650+ """
651+
652+ CHECKBOXES = "/usr/share/checkbox*"
653+
654+ def __init__(self, builtin_jobs=[], packages=[]):
655+ # List of all available jobs in system-wide locations
656+ self.builtin_jobs = builtin_jobs
657+ # List of all checkbox variants, like checkbox-oem(-.*)?
658+ self.packages = packages
659+
660+ def path_expand(self, path):
661+ for p in glob.glob(path):
662+ self.packages.append(p)
663+ for dirpath, dirs, filenames in os.walk(os.path.join(p, 'jobs')):
664+ for name in filenames:
665+ if name.endswith(".txt"):
666+ yield os.path.join(dirpath, name)
667+
668+ def main(self, argv=None):
669+ parser = argparse.ArgumentParser(prog="checkbox-trusted-launcher")
670+ # Argh the horrror!
671+ #
672+ # Since CPython revision cab204a79e09 (landed for python3.3)
673+ # http://hg.python.org/cpython/diff/cab204a79e09/Lib/argparse.py
674+ # the argparse module behaves differently than it did in python3.2
675+ #
676+ # On python3.2, if we didn't use all the Positional objects, there
677+ # were too few arg strings supplied and we called parser.error().
678+ #
679+ # To compensate, on python3.3 and beyond, when the user just runs
680+ # the trusted launcher without specifying arguments, we manually,
681+ # explicitly do what python3.2 did:
682+ # call parser.error(_('too few arguments'))
683+ if not argv:
684+ parser.error(argparse_gettext("too few arguments"))
685+ parser.add_argument('HASH', metavar='HASH', help='job hash to match')
686+ parser.add_argument(
687+ 'ENV', metavar='NAME=VALUE', nargs='*',
688+ help='Set each NAME to VALUE in the string environment')
689+ parser.add_argument(
690+ '--via',
691+ metavar='LOCAL-JOB-HASH',
692+ dest='via_hash',
693+ help='Local job hash to use to match the generated job')
694+ args = parser.parse_args(argv)
695+
696+ for filename in self.path_expand(self.CHECKBOXES):
697+ stream = open(filename, "r", encoding="utf-8")
698+ for message in load_rfc822_records(stream):
699+ self.builtin_jobs.append(BaseJob(message.data))
700+ stream.close()
701+ lookup_list = [j for j in self.builtin_jobs if j.user]
702+
703+ if args.via_hash is not None:
704+ local_list = [j for j in self.builtin_jobs if j.plugin == 'local']
705+ desired_job_list = [j for j in local_list
706+ if j.get_checksum() == args.via_hash]
707+ if desired_job_list:
708+ via_job = desired_job_list.pop()
709+ via_job_result = subprocess.Popen(
710+ via_job.command,
711+ shell=True,
712+ universal_newlines=True,
713+ stdout=subprocess.PIPE,
714+ env=via_job.modify_execution_environment(
715+ args.ENV,
716+ self.packages)
717+ )
718+ try:
719+ for message in load_rfc822_records(via_job_result.stdout):
720+ message._data['via'] = args.via_hash
721+ lookup_list.append(BaseJob(message.data))
722+ finally:
723+ # Always call Popen.wait() in order to avoid zombies
724+ via_job_result.stdout.close()
725+ via_job_result.wait()
726+
727+ try:
728+ target_job = [j for j in lookup_list
729+ if j.get_checksum() == args.HASH][0]
730+ except IndexError:
731+ return "Job not found"
732+ try:
733+ os.execve(
734+ '/bin/bash',
735+ ['bash', '-c', target_job.command],
736+ target_job.modify_execution_environment(
737+ args.ENV,
738+ self.packages)
739+ )
740+ # if execv doesn't fail, it never returns...
741+ except OSError:
742+ return "Fatal error"
743+ finally:
744+ return "Fatal error"
745+
746+
747+def main(argv=None):
748+ """
749+ Entry point for the checkbox trusted launcher
750+ """
751+ runner = Runner()
752+ raise SystemExit(runner.main(argv))
753
754=== added file 'plainbox/plainbox/impl/secure/test_checkbox_trusted_launcher.py'
755--- plainbox/plainbox/impl/secure/test_checkbox_trusted_launcher.py 1970-01-01 00:00:00 +0000
756+++ plainbox/plainbox/impl/secure/test_checkbox_trusted_launcher.py 2013-05-08 07:38:29 +0000
757@@ -0,0 +1,268 @@
758+# This file is part of Checkbox.
759+#
760+# Copyright 2013 Canonical Ltd.
761+# Written by:
762+# Sylvain Pineau <sylvain.pineau@canonical.com>
763+#
764+# Checkbox is free software: you can redistribute it and/or modify
765+# it under the terms of the GNU General Public License as published by
766+# the Free Software Foundation, either version 3 of the License, or
767+# (at your option) any later version.
768+#
769+# Checkbox is distributed in the hope that it will be useful,
770+# but WITHOUT ANY WARRANTY; without even the implied warranty of
771+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
772+# GNU General Public License for more details.
773+#
774+# You should have received a copy of the GNU General Public License
775+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
776+
777+"""
778+plainbox.impl.secure.test_checkbox_trusted_launcher
779+===================================================
780+
781+Test definitions for plainbox.impl.secure.checkbox_trusted_launcher module
782+"""
783+
784+import os
785+
786+from inspect import cleandoc
787+from io import StringIO
788+from mock import Mock, patch
789+from tempfile import NamedTemporaryFile, TemporaryDirectory
790+from unittest import TestCase
791+
792+from plainbox.impl.secure.checkbox_trusted_launcher import BaseJob
793+from plainbox.impl.secure.checkbox_trusted_launcher import load_rfc822_records
794+from plainbox.impl.secure.checkbox_trusted_launcher import main
795+from plainbox.impl.secure.checkbox_trusted_launcher import Runner
796+from plainbox.impl.test_rfc822 import RFC822ParserTestsMixIn
797+from plainbox.testing_utils.io import TestIO
798+from plainbox.testing_utils.testcases import TestCaseWithParameters
799+
800+
801+class TestJobDefinition(TestCase):
802+
803+ def setUp(self):
804+ self._full_record = {
805+ 'plugin': 'plugin',
806+ 'command': 'command',
807+ 'environ': 'environ',
808+ 'user': 'user'
809+ }
810+ self._min_record = {
811+ 'plugin': 'plugin',
812+ 'name': 'name',
813+ }
814+
815+ def test_smoke_full_record(self):
816+ job = BaseJob(self._full_record)
817+ self.assertEqual(job.plugin, "plugin")
818+ self.assertEqual(job.command, "command")
819+ self.assertEqual(job.environ, "environ")
820+ self.assertEqual(job.user, "user")
821+
822+ def test_smoke_min_record(self):
823+ job = BaseJob(self._min_record)
824+ self.assertEqual(job.plugin, "plugin")
825+ self.assertEqual(job.command, None)
826+ self.assertEqual(job.environ, None)
827+ self.assertEqual(job.user, None)
828+
829+ def test_checksum_smoke(self):
830+ job1 = BaseJob({'plugin': 'plugin', 'user': 'root'})
831+ identical_to_job1 = BaseJob({'plugin': 'plugin', 'user': 'root'})
832+ # Two distinct but identical jobs have the same checksum
833+ self.assertEqual(job1.get_checksum(), identical_to_job1.get_checksum())
834+ job2 = BaseJob({'plugin': 'plugin', 'user': 'anonymous'})
835+ # Two jobs with different definitions have different checksum
836+ self.assertNotEqual(job1.get_checksum(), job2.get_checksum())
837+ # The checksum is stable and does not change over time
838+ self.assertEqual(
839+ job1.get_checksum(),
840+ "c47cc3719061e4df0010d061e6f20d3d046071fd467d02d093a03068d2f33400")
841+
842+
843+class ParsingTests(TestCaseWithParameters):
844+
845+ parameter_names = ('glue',)
846+ parameter_values = (
847+ ('commas',),
848+ ('spaces',),
849+ ('tabs',),
850+ ('newlines',),
851+ ('spaces_and_commas',),
852+ ('multiple_spaces',),
853+ ('multiple_commas',)
854+ )
855+ parameters_keymap = {
856+ 'commas': ',',
857+ 'spaces': ' ',
858+ 'tabs': '\t',
859+ 'newlines': '\n',
860+ 'spaces_and_commas': ', ',
861+ 'multiple_spaces': ' ',
862+ 'multiple_commas': ',,,,'
863+ }
864+
865+ def test_environ_parsing_with_various_separators(self):
866+ job = BaseJob({
867+ 'name': 'name',
868+ 'plugin': 'plugin',
869+ 'environ': self.parameters_keymap[
870+ self.parameters.glue].join(['foo', 'bar', 'froz'])})
871+ expected = set({'foo', 'bar', 'froz'})
872+ observed = job.get_environ_settings()
873+ self.assertEqual(expected, observed)
874+
875+ def test_environ_parsing_empty(self):
876+ job = BaseJob({'plugin': 'plugin'})
877+ expected = set()
878+ observed = job.get_environ_settings()
879+ self.assertEqual(expected, observed)
880+
881+
882+class JobEnvTests(TestCase):
883+
884+ def setUp(self):
885+ self.job = BaseJob({'plugin': 'plugin', 'environ': 'foo'})
886+
887+ def test_checkbox_env(self):
888+ base_env = {"PATH": "", 'foo': 'bar', 'baz': 'qux'}
889+ path = '/usr/share/checkbox-lambda'
890+ with patch.dict('os.environ', {}):
891+ env = self.job.modify_execution_environment(base_env, [path])
892+ self.assertEqual(env['CHECKBOX_LAMBDA_SHARE'], path)
893+ self.assertEqual(env['foo'], 'bar')
894+ self.assertNotIn('bar', env)
895+
896+ def test_checkbox_env_with_data_dir(self):
897+ base_env = {"PATH": "", "CHECKBOX_DATA": "DEADBEEF"}
898+ path = '/usr/share/checkbox-lambda'
899+ with patch.dict('os.environ', {"CHECKBOX_DATA": "DEADBEEF"}):
900+ env = self.job.modify_execution_environment(base_env, [path])
901+ self.assertEqual(env['CHECKBOX_LAMBDA_SHARE'], path)
902+ self.assertEqual(env['CHECKBOX_DATA'], "DEADBEEF")
903+
904+
905+class RFC822ParserTests(TestCase, RFC822ParserTestsMixIn):
906+
907+ @classmethod
908+ def setUpClass(cls):
909+ cls.loader = load_rfc822_records
910+
911+
912+class TestMain(TestCase):
913+
914+ def setUp(self):
915+ self.scratch_dir = TemporaryDirectory(prefix='checkbox-')
916+ job_dir = os.path.join(self.scratch_dir.name, 'jobs')
917+ os.mkdir(job_dir)
918+ with NamedTemporaryFile(suffix='.txt', dir=job_dir, delete=False) as f:
919+ f.write(b'plugin: plugin\nuser: me\ncommand: true\n')
920+ with NamedTemporaryFile(
921+ suffix='.txt', dir=job_dir, delete=False) as f:
922+ f.write(b'plugin: unknown\nuser: user\n')
923+ with NamedTemporaryFile(
924+ suffix='.txt', dir=job_dir, delete=False) as f:
925+ f.write(b'plugin: local\nuser: you\ncommand: a new job\n')
926+ with NamedTemporaryFile(
927+ suffix='.conf', dir=job_dir, delete=False) as f:
928+ f.write(b'plugin: local\nuser: you\ncommand: a new job\n')
929+
930+ def test_help(self):
931+ with TestIO(combined=True) as io:
932+ with self.assertRaises(SystemExit) as call:
933+ main(['--help'])
934+ self.assertEqual(call.exception.args, (0,))
935+ self.maxDiff = None
936+ expected = """
937+ usage: checkbox-trusted-launcher [-h] [--via LOCAL-JOB-HASH]
938+ HASH [NAME=VALUE [NAME=VALUE ...]]
939+
940+ positional arguments:
941+ HASH job hash to match
942+ NAME=VALUE Set each NAME to VALUE in the string environment
943+
944+ optional arguments:
945+ -h, --help show this help message and exit
946+ --via LOCAL-JOB-HASH Local job hash to use to match the generated job
947+ """
948+ self.assertEqual(io.combined, cleandoc(expected) + "\n")
949+
950+ def test_run_without_args(self):
951+ with TestIO(combined=True) as io:
952+ with self.assertRaises(SystemExit) as call:
953+ main([])
954+ self.assertEqual(call.exception.args, (2,))
955+ expected = """
956+ usage: checkbox-trusted-launcher [-h]
957+ checkbox-trusted-launcher: error: too few arguments
958+ """
959+ self.assertEqual(io.combined, cleandoc(expected) + "\n")
960+
961+ @patch.object(Runner, 'CHECKBOXES', 'foo')
962+ def test_run_invalid_hash(self):
963+ with TestIO(combined=True) as io:
964+ with self.assertRaises(SystemExit) as call:
965+ main(['bar'])
966+ self.assertEqual(call.exception.args, ('Job not found',))
967+ self.assertEqual(io.combined, '')
968+
969+ def test_run_valid_hash(self):
970+ self.maxDiff = None
971+ with patch.object(Runner, 'CHECKBOXES', self.scratch_dir.name),\
972+ patch('os.execve'):
973+ with TestIO(combined=True) as io:
974+ with self.assertRaises(SystemExit) as call:
975+ main(['9ab0e98cce8866b9a2fa217e87b4e8bb'
976+ '739e5f74977ba5fa30822cab2a178c48'])
977+ self.assertEqual(call.exception.args, ('Fatal error',))
978+ self.assertEqual(io.combined, '')
979+
980+ def test_run_valid_hash_exec_error(self):
981+ self.maxDiff = None
982+ with patch.object(Runner, 'CHECKBOXES', self.scratch_dir.name),\
983+ patch('os.execve') as mock_execve:
984+ mock_execve.side_effect = OSError('foo')
985+ with TestIO(combined=True) as io:
986+ with self.assertRaises(SystemExit) as call:
987+ main(['9ab0e98cce8866b9a2fa217e87b4e8bb'
988+ '739e5f74977ba5fa30822cab2a178c48'])
989+ self.assertEqual(call.exception.args, ('Fatal error',))
990+ self.assertEqual(io.combined, '')
991+
992+ def test_run_valid_hash_invalid_via(self):
993+ self.maxDiff = None
994+ with patch.object(Runner, 'CHECKBOXES', self.scratch_dir.name),\
995+ patch('os.execve'),\
996+ patch('subprocess.Popen') as mock_popen:
997+ mock_iobuffer = Mock()
998+ mock_iobuffer.stdout = StringIO('test: me')
999+ mock_popen.return_value = mock_iobuffer
1000+ with TestIO(combined=True) as io:
1001+ with self.assertRaises(SystemExit) as call:
1002+ main(['9ab0e98cce8866b9a2fa217e87b4e8bb'
1003+ '739e5f74977ba5fa30822cab2a178c48', '--via=maybe'])
1004+ self.assertEqual(call.exception.args, ('Fatal error',))
1005+ self.assertEqual(io.combined, '')
1006+
1007+ def test_run_valid_hash_valid_via(self):
1008+ self.maxDiff = None
1009+ with patch.object(Runner, 'CHECKBOXES', self.scratch_dir.name),\
1010+ patch('os.execve'),\
1011+ patch('subprocess.Popen') as mock_popen:
1012+ mock_iobuffer = Mock()
1013+ mock_iobuffer.stdout = StringIO('test: me')
1014+ mock_popen.return_value = mock_iobuffer
1015+ with TestIO(combined=True) as io:
1016+ with self.assertRaises(SystemExit) as call:
1017+ main(['9ab0e98cce8866b9a2fa217e87b4e8bb'
1018+ '739e5f74977ba5fa30822cab2a178c48',
1019+ '--via=a0dc4a9673b8f2d80b1ae4775c'
1020+ '03e8a777eefa061fc7ecf7a3af2f20a33bb177'])
1021+ self.assertEqual(call.exception.args, ('Fatal error',))
1022+ self.assertEqual(io.combined, '')
1023+
1024+ def tearDown(self):
1025+ self.scratch_dir.cleanup()
1026
1027=== modified file 'plainbox/plainbox/impl/test_rfc822.py'
1028--- plainbox/plainbox/impl/test_rfc822.py 2013-03-14 10:36:25 +0000
1029+++ plainbox/plainbox/impl/test_rfc822.py 2013-05-08 07:38:29 +0000
1030@@ -29,9 +29,9 @@
1031
1032 from plainbox.impl.rfc822 import Origin
1033 from plainbox.impl.rfc822 import RFC822Record
1034-from plainbox.impl.rfc822 import RFC822SyntaxError
1035 from plainbox.impl.rfc822 import load_rfc822_records
1036 from plainbox.impl.rfc822 import dump_rfc822_records
1037+from plainbox.impl.secure.checkbox_trusted_launcher import RFC822SyntaxError
1038
1039
1040 class OriginTests(TestCase):
1041@@ -65,16 +65,18 @@
1042 self.assertEqual(record.origin, origin)
1043
1044
1045-class RFC822ParserTests(TestCase):
1046+class RFC822ParserTestsMixIn():
1047+
1048+ loader = load_rfc822_records
1049
1050 def test_empty(self):
1051 with StringIO("") as stream:
1052- records = load_rfc822_records(stream)
1053+ records = type(self).loader(stream)
1054 self.assertEqual(len(records), 0)
1055
1056 def test_single_record(self):
1057 with StringIO("key:value") as stream:
1058- records = load_rfc822_records(stream)
1059+ records = type(self).loader(stream)
1060 self.assertEqual(len(records), 1)
1061 self.assertEqual(records[0].data, {'key': 'value'})
1062
1063@@ -94,7 +96,7 @@
1064 "\n"
1065 )
1066 with StringIO(text) as stream:
1067- records = load_rfc822_records(stream)
1068+ records = type(self).loader(stream)
1069 self.assertEqual(len(records), 3)
1070 self.assertEqual(records[0].data, {'key1': 'value1'})
1071 self.assertEqual(records[1].data, {'key2': 'value2'})
1072@@ -109,7 +111,7 @@
1073 "key3:value3\n"
1074 )
1075 with StringIO(text) as stream:
1076- records = load_rfc822_records(stream)
1077+ records = type(self).loader(stream)
1078 self.assertEqual(len(records), 3)
1079 self.assertEqual(records[0].data, {'key1': 'value1'})
1080 self.assertEqual(records[1].data, {'key2': 'value2'})
1081@@ -122,7 +124,7 @@
1082 " value\n"
1083 )
1084 with StringIO(text) as stream:
1085- records = load_rfc822_records(stream)
1086+ records = type(self).loader(stream)
1087 self.assertEqual(len(records), 1)
1088 self.assertEqual(records[0].data, {'key': 'longer\nvalue'})
1089
1090@@ -134,7 +136,7 @@
1091 " value\n"
1092 )
1093 with StringIO(text) as stream:
1094- records = load_rfc822_records(stream)
1095+ records = type(self).loader(stream)
1096 self.assertEqual(len(records), 1)
1097 self.assertEqual(records[0].data, {'key': 'longer\n\nvalue'})
1098
1099@@ -146,7 +148,7 @@
1100 " value\n"
1101 )
1102 with StringIO(text) as stream:
1103- records = load_rfc822_records(stream)
1104+ records = type(self).loader(stream)
1105 self.assertEqual(len(records), 1)
1106 self.assertEqual(records[0].data, {'key': 'longer\n.\nvalue'})
1107
1108@@ -161,7 +163,7 @@
1109 " value 2\n"
1110 )
1111 with StringIO(text) as stream:
1112- records = load_rfc822_records(stream)
1113+ records = type(self).loader(stream)
1114 self.assertEqual(len(records), 2)
1115 self.assertEqual(records[0].data, {'key1': 'initial\nlonger\nvalue 1'})
1116 self.assertEqual(records[1].data, {'key2': 'longer\nvalue 2'})
1117@@ -169,7 +171,7 @@
1118 def test_irrelevant_whitespace(self):
1119 text = "key : value "
1120 with StringIO(text) as stream:
1121- records = load_rfc822_records(stream)
1122+ records = type(self).loader(stream)
1123 self.assertEqual(len(records), 1)
1124 self.assertEqual(records[0].data, {'key': 'value'})
1125
1126@@ -179,7 +181,7 @@
1127 " value\n"
1128 )
1129 with StringIO(text) as stream:
1130- records = load_rfc822_records(stream)
1131+ records = type(self).loader(stream)
1132 self.assertEqual(len(records), 1)
1133 self.assertEqual(records[0].data, {'key': 'value'})
1134
1135@@ -187,21 +189,21 @@
1136 text = " extra value"
1137 with StringIO(text) as stream:
1138 with self.assertRaises(RFC822SyntaxError) as call:
1139- load_rfc822_records(stream)
1140+ type(self).loader(stream)
1141 self.assertEqual(call.exception.msg, "Unexpected multi-line value")
1142
1143 def test_garbage(self):
1144 text = "garbage"
1145 with StringIO(text) as stream:
1146 with self.assertRaises(RFC822SyntaxError) as call:
1147- load_rfc822_records(stream)
1148+ type(self).loader(stream)
1149 self.assertEqual(call.exception.msg, "Unexpected non-empty line")
1150
1151 def test_syntax_error(self):
1152 text = "key1 = value1"
1153 with StringIO(text) as stream:
1154 with self.assertRaises(RFC822SyntaxError) as call:
1155- load_rfc822_records(stream)
1156+ type(self).loader(stream)
1157 self.assertEqual(call.exception.msg, "Unexpected non-empty line")
1158
1159 def test_duplicate_error(self):
1160@@ -211,12 +213,17 @@
1161 )
1162 with StringIO(text) as stream:
1163 with self.assertRaises(RFC822SyntaxError) as call:
1164- load_rfc822_records(stream)
1165+ type(self).loader(stream)
1166 self.assertEqual(call.exception.msg, (
1167 "Job has a duplicate key 'key1' with old value 'value1'"
1168 " and new value 'value2'"))
1169
1170
1171+class RFC822ParserTests(TestCase, RFC822ParserTestsMixIn):
1172+
1173+ pass
1174+
1175+
1176 class RFC822WriterTests(TestCase):
1177
1178 def test_single_record(self):
1179
1180=== modified file 'plainbox/setup.py'
1181--- plainbox/setup.py 2013-04-25 20:43:24 +0000
1182+++ plainbox/setup.py 2013-05-08 07:38:29 +0000
1183@@ -40,9 +40,15 @@
1184 'lxml >= 2.3',
1185 'requests >= 1.0',
1186 ],
1187+ data_files=[
1188+ ("share/polkit-1/actions",
1189+ ["plainbox/data/org.freedesktop.policykit.pkexec.policy"])
1190+ ],
1191 entry_points={
1192 'console_scripts': [
1193 'plainbox=plainbox.public:main',
1194+ 'checkbox-trusted-launcher='
1195+ 'plainbox.impl.secure.checkbox_trusted_launcher:main',
1196 ],
1197 'plainbox.exporter': [
1198 'text=plainbox.impl.exporter.text:TextSessionStateExporter',
1199@@ -51,7 +57,8 @@
1200 'xml=plainbox.impl.exporter.xml:XMLSessionStateExporter',
1201 ],
1202 'plainbox.transport': [
1203- 'certification=plainbox.impl.transport.certification:CertificationTransport',
1204+ 'certification='
1205+ 'plainbox.impl.transport.certification:CertificationTransport',
1206 ],
1207 },
1208 include_package_data=True)

Subscribers

People subscribed via source and target branches