-
Notifications
You must be signed in to change notification settings - Fork 3.5k
[Core] Write session files atomically so a partial write cannot destroy them #34060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
om singhal (Om-singhaI)
wants to merge
4
commits into
Azure:dev
Choose a base branch
from
Om-singhaI:fix/session-atomic-save
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+208
−1
Open
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6acd1ad
Write session files atomically so a partial write cannot destroy them
Om-singhaI 9ebb618
Only fall back to the in place write when the directory denies permis…
Om-singhaI d153d0e
Drop EROFS from the fallback and guard the POSIX tests on Windows
Om-singhaI adf242c
Keep the same guarantee on the fallback path
Om-singhaI File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
120 changes: 120 additions & 0 deletions
120
src/azure-cli-core/azure/cli/core/tests/test_session.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| # -------------------------------------------------------------------------------------------- | ||
| # Copyright (c) Microsoft Corporation. All rights reserved. | ||
| # Licensed under the MIT License. See License.txt in the project root for license information. | ||
| # -------------------------------------------------------------------------------------------- | ||
|
|
||
| import json | ||
| import os | ||
| import stat | ||
| import tempfile | ||
| import unittest | ||
|
|
||
| from azure.cli.core._session import Session | ||
|
|
||
|
|
||
| class TestSession(unittest.TestCase): | ||
|
|
||
| def setUp(self): | ||
| self.dir = tempfile.mkdtemp() | ||
| self.filename = os.path.join(self.dir, 'test.json') | ||
|
|
||
| def _session(self, data): | ||
| session = Session() | ||
| session.filename = self.filename | ||
| session.data = data | ||
| return session | ||
|
|
||
| def _read(self): | ||
| with open(self.filename, encoding='utf-8-sig') as f: | ||
| return json.load(f) | ||
|
|
||
| def _temp_files(self): | ||
| return [name for name in os.listdir(self.dir) if name.endswith('.tmp')] | ||
|
|
||
| def test_save_writes_the_data(self): | ||
| self._session({'a': 1}).save() | ||
| self.assertEqual(self._read(), {'a': 1}) | ||
|
|
||
| def test_save_replaces_the_file_instead_of_truncating_it(self): | ||
| # A truncating write leaves the file empty or half written while it runs, and a concurrent | ||
| # reader that fails to parse it has load() overwrite it with defaults. Replacing the file | ||
| # means a reader always sees either the old contents or the new ones. | ||
| with open(self.filename, 'w', encoding='utf-8-sig') as f: | ||
| json.dump({'a': 1}, f) | ||
| inode = os.stat(self.filename).st_ino | ||
|
|
||
| self._session({'a': 2}).save() | ||
|
|
||
| self.assertNotEqual(os.stat(self.filename).st_ino, inode) | ||
| self.assertEqual(self._read(), {'a': 2}) | ||
|
|
||
| def test_save_leaves_the_file_intact_when_the_data_cannot_be_serialized(self): | ||
| with open(self.filename, 'w', encoding='utf-8-sig') as f: | ||
| json.dump({'kept': True}, f) | ||
|
|
||
| with self.assertRaises(TypeError): | ||
| self._session({'a': 'fine', 'b': {1, 2}}).save() | ||
|
|
||
| self.assertEqual(self._read(), {'kept': True}) | ||
| self.assertFalse(self._temp_files()) | ||
|
|
||
| def test_save_preserves_the_permissions_of_an_existing_file(self): | ||
| with open(self.filename, 'w', encoding='utf-8-sig') as f: | ||
| json.dump({}, f) | ||
| os.chmod(self.filename, 0o644) | ||
|
|
||
| self._session({'a': 1}).save() | ||
|
|
||
| self.assertEqual(stat.S_IMODE(os.stat(self.filename).st_mode), 0o644) | ||
|
|
||
| @unittest.skipUnless(hasattr(os, 'symlink'), 'requires symlink support') | ||
| def test_save_follows_a_symlink_rather_than_replacing_it(self): | ||
| target = os.path.join(self.dir, 'target.json') | ||
| link = os.path.join(self.dir, 'link.json') | ||
| with open(target, 'w', encoding='utf-8-sig') as f: | ||
| json.dump({'a': 1}, f) | ||
| os.symlink(target, link) | ||
|
|
||
| session = Session() | ||
| session.filename = link | ||
| session.data = {'a': 2} | ||
| session.save() | ||
|
|
||
| self.assertTrue(os.path.islink(link)) | ||
| with open(target, encoding='utf-8-sig') as f: | ||
| self.assertEqual(json.load(f), {'a': 2}) | ||
|
|
||
| @unittest.skipIf(os.name == 'nt', 'directory permissions are not enforced the same way on Windows') | ||
| def test_save_still_writes_when_the_directory_is_not_writable(self): | ||
| # Locked down containers and build images mount the config directory read only while | ||
| # leaving the file itself writable. A save that used to succeed there must keep working. | ||
| with open(self.filename, 'w', encoding='utf-8-sig') as f: | ||
| json.dump({}, f) | ||
| os.chmod(self.dir, 0o500) | ||
| try: | ||
| self._session({'a': 1}).save() | ||
| self.assertEqual(self._read(), {'a': 1}) | ||
| finally: | ||
| os.chmod(self.dir, 0o700) | ||
|
|
||
| def test_load_reads_back_what_save_wrote(self): | ||
| self._session({'a': 1}).save() | ||
|
|
||
| session = Session() | ||
| session.load(self.filename) | ||
|
|
||
| self.assertEqual(session.data, {'a': 1}) | ||
|
|
||
| def test_load_overrides_a_file_that_cannot_be_parsed(self): | ||
| with open(self.filename, 'w', encoding='utf-8-sig') as f: | ||
| f.write('{"truncated"') | ||
|
|
||
| session = Session() | ||
| session.load(self.filename) | ||
|
|
||
| self.assertEqual(session.data, {}) | ||
| self.assertEqual(self._read(), {}) | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| unittest.main() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.