diff --git a/.gitignore b/.gitignore index eab642c84..7df3f0de4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ tags test/tmp/ vendor/ /exec +.ruby-bundle # Documentation _site/* diff --git a/lib/vagrant/errors.rb b/lib/vagrant/errors.rb index 3be2aae84..95fae400d 100644 --- a/lib/vagrant/errors.rb +++ b/lib/vagrant/errors.rb @@ -947,5 +947,9 @@ module Vagrant class WSLVirtualBoxWindowsAccessError < VagrantError error_key(:wsl_virtualbox_windows_access) end + + class WSLRootFsNotFoundError < VagrantError + error_key(:wsl_rootfs_not_found_error) + end end end diff --git a/lib/vagrant/util/platform.rb b/lib/vagrant/util/platform.rb index 9d581c527..7f0d6f039 100644 --- a/lib/vagrant/util/platform.rb +++ b/lib/vagrant/util/platform.rb @@ -1,6 +1,8 @@ require "rbconfig" require "shellwords" +require "tempfile" require "tmpdir" +require "log4r" require "vagrant/util/subprocess" require "vagrant/util/powershell" @@ -291,6 +293,87 @@ module Vagrant wsl? && !path.to_s.downcase.start_with?("/mnt/") end + # Compute the path to rootfs of currently active WSL. + # + # @return [String] A path to rootfs of a current WSL instance. + def wsl_rootfs + return @_wsl_rootfs if defined?(@_wsl_rootfs) + + if wsl? + # Mark our filesystem with a temporary file having an unique name. + marker = Tempfile.new(Time.now.to_i.to_s) + logger = Log4r::Logger.new("vagrant::util::platform::wsl") + + # Check for lxrun installation first + lxrun_path = [wsl_windows_appdata_local, "lxss"].join("\\") + paths = [lxrun_path] + + logger.debug("checking registry for WSL installation path") + paths += PowerShell.execute_cmd( + '(Get-ChildItem HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss ' \ + '| ForEach-Object {Get-ItemProperty $_.PSPath}).BasePath').to_s.split("\r\n").map(&:strip) + paths.delete_if{|path| path.to_s.empty?} + + paths.each do |path| + # Lowercase the drive letter, skip the next symbol (which is a + # colon from a Windows path) and convert path to UNIX style. + check_path = "/mnt/#{path[0, 1].downcase}#{path[2..-1].tr('\\', '/')}/rootfs" + + logger.debug("checking `#{path}` for current WSL instance") + begin + # https://blogs.msdn.microsoft.com/wsl/2016/06/15/wsl-file-system-support + # Current WSL instance doesn't have an access to its mount from + # within itself despite all others are available. That's the + # hacky way we're using to determine current instance. + # For example we have three WSL instances: + # A -> C:\User\USER\AppData\Local\Packages\A\LocalState\rootfs + # B -> C:\User\USER\AppData\Local\Packages\B\LocalState\rootfs + # C -> C:\User\USER\AppData\Local\Packages\C\LocalState\rootfs + # If we're in "A" WSL at the moment, then its path will not be + # accessible since it's mounted for exactly the instance we're + # in. All others can be opened. + Dir.open(check_path) do |fs| + # A fallback for a case if our trick will stop working. For + # that we've created a temporary file with an unique name in + # a current WSL and now seeking it among all WSL. + if File.exist?("#{fs.path}/#{marker.path}") + @_wsl_rootfs = path + break + end + end + rescue Errno::EACCES + @_wsl_rootfs = path + # You can create and simultaneously run multiple WSL instances, + # comment out the "break", run this script within each one and + # it'll return only single value. + break + rescue Errno::ENOENT + # Warn about data discrepancy between Winreg and file system + # states. For the sake of justice, it's worth mentioning that + # it is possible only when someone will manually break WSL by + # removing a directory of its base path (kinda "stupid WSL + # uninstallation by removing hidden and system directory"). + logger.warn("WSL instance at `#{path} is broken or no longer exists") + end + # All other exceptions have to be raised since they will mean + # something unpredictably terrible. + end + + marker.close! + + raise Vagrant::Errors::WSLRootFsNotFoundError if @_wsl_rootfs.nil? + end + + # Attach the rootfs leaf to the path + if @_wsl_rootfs != lxrun_path + @_wsl_rootfs = "#{@_wsl_rootfs}\\rootfs" + end + + logger.debug("detected `#{@_wsl_rootfs}` as current WSL instance") + + @_wsl_rootfs + end + # Convert a WSL path to the local Windows path. This is useful # for conversion when calling out to Windows executables from # the WSL @@ -298,11 +381,17 @@ module Vagrant # @param [String, Pathname] path Path to convert # @return [String] def wsl_to_windows_path(path) - if wsl? && wsl_windows_access? + if wsl? && wsl_windows_access? && !path.match(/^[a-zA-Z]:/) if wsl_path?(path) parts = path.split("/") parts.delete_if(&:empty?) - [wsl_windows_appdata_local, "lxss", *parts].join("\\") + root_path = wsl_rootfs + # lxrun splits home separate so we need to account + # for it's specialness here when we build the path + if root_path.end_with?("lxss") && parts.first != "home" + root_path = "#{root_path}\\rootfs" + end + [root_path, *parts].join("\\") else path = path.to_s.sub("/mnt/", "") parts = path.split("/") diff --git a/lib/vagrant/util/powershell.rb b/lib/vagrant/util/powershell.rb index 62a8bc472..d685e0b56 100644 --- a/lib/vagrant/util/powershell.rb +++ b/lib/vagrant/util/powershell.rb @@ -13,12 +13,27 @@ module Vagrant MINIMUM_REQUIRED_VERSION = 3 LOGGER = Log4r::Logger.new("vagrant::util::powershell") + # @return [String|nil] a powershell executable, depending on environment + def self.executable + if !defined?(@_powershell_executable) + @_powershell_executable = "powershell" + + # Try to use WSL interoperability if PowerShell is not symlinked to + # the container. + if Which.which(@_powershell_executable).nil? && Platform.wsl? + @_powershell_executable += ".exe" + + if Which.which(@_powershell_executable).nil? + @_powershell_executable = nil + end + end + end + @_powershell_executable + end + # @return [Boolean] powershell executable available on PATH def self.available? - if !defined?(@_powershell_available) - @_powershell_available = !!Which.which("powershell") - end - @_powershell_available + !executable.nil? end # Execute a powershell script. @@ -27,12 +42,11 @@ module Vagrant # @return [Subprocess::Result] def self.execute(path, *args, **opts, &block) validate_install! - if opts.delete(:sudo) || opts.delete(:runas) powerup_command(path, args, opts) else command = [ - "powershell", + executable, "-NoLogo", "-NoProfile", "-NonInteractive", @@ -57,7 +71,7 @@ module Vagrant def self.execute_cmd(command) validate_install! c = [ - "powershell", + executable, "-NoLogo", "-NoProfile", "-NonInteractive", @@ -77,7 +91,7 @@ module Vagrant def self.version if !defined?(@_powershell_version) command = [ - "powershell", + executable, "-NoLogo", "-NoProfile", "-NonInteractive", diff --git a/templates/locales/en.yml b/templates/locales/en.yml index aefee43aa..d6c78cacd 100644 --- a/templates/locales/en.yml +++ b/templates/locales/en.yml @@ -1629,6 +1629,10 @@ en: Linux, please refer to the Vagrant documentation: https://www.vagrantup.com/docs/other/wsl.html + wsl_rootfs_not_found_error: |- + Vagrant is unable to determine the location of this instance of the Windows + Subsystem for Linux. If this error persists it may be resolved by destroying + this subsystem and installing it again. #------------------------------------------------------------------------------- # Translations for config validation errors #------------------------------------------------------------------------------- diff --git a/test/unit/vagrant/util/platform_test.rb b/test/unit/vagrant/util/platform_test.rb index c71cf0ab1..8b4046188 100644 --- a/test/unit/vagrant/util/platform_test.rb +++ b/test/unit/vagrant/util/platform_test.rb @@ -4,8 +4,7 @@ require "vagrant/util/platform" describe Vagrant::Util::Platform do include_context "unit" - - + after{ described_class.reset! } subject { described_class } describe "#cygwin_path" do @@ -55,11 +54,6 @@ describe Vagrant::Util::Platform do describe "#cygwin?" do before do allow(subject).to receive(:platform).and_return("test") - described_class.reset! - end - - after do - described_class.reset! end around do |example| @@ -99,11 +93,6 @@ describe Vagrant::Util::Platform do describe "#msys?" do before do allow(subject).to receive(:platform).and_return("test") - described_class.reset! - end - - after do - described_class.reset! end around do |example| @@ -162,7 +151,6 @@ describe Vagrant::Util::Platform do describe ".systemd?" do before{ allow(subject).to receive(:windows?).and_return(false) } - after{ subject.reset! } context "on windows" do before{ expect(subject).to receive(:windows?).and_return(true) } @@ -223,10 +211,142 @@ describe Vagrant::Util::Platform do end it "should return false if disabled" do - Vagrant::Util::Platform.reset! allow(Vagrant::Util::PowerShell).to receive(:execute_cmd).and_return('Disabled') expect(Vagrant::Util::Platform.windows_hyperv_enabled?).to be_falsey end end + + context "within the WSL" do + before{ allow(subject).to receive(:wsl?).and_return(true) } + + describe ".wsl_path?" do + it "should return true when path is not within /mnt" do + expect(subject.wsl_path?("/tmp")).to be(true) + end + + it "should return false when path is within /mnt" do + expect(subject.wsl_path?("/mnt/c")).to be false + end + end + + describe ".wsl_rootfs" do + let(:appdata_path){ "C:\\Custom\\Path" } + let(:registry_paths){ nil } + + before do + allow(subject).to receive(:wsl_windows_appdata_local).and_return(appdata_path) + allow(Tempfile).to receive(:new).and_return(double("tempfile", path: "file.path", close!: true)) + allow(Vagrant::Util::PowerShell).to receive(:execute_cmd).and_return(registry_paths) + end + + context "when no instance information is in the registry" do + before do + expect(Dir).to receive(:open).with(/.*Custom.*Path.*/).and_yield(double("path", path: appdata_path)) + expect(File).to receive(:exist?).and_return(true) + end + + it "should only check the lxrun path" do + expect(subject.wsl_rootfs).to include(appdata_path) + end + end + + context "with instance information in the registry" do + let(:registry_paths) { ["C:\\Path1", "C:\\Path2"].join("\r\n") } + + before do + allow(Dir).to receive(:open).and_yield(double("path", path: appdata_path)) + allow(File).to receive(:exist?).and_return(false) + end + + context "when no matches are detected" do + it "should check all paths given" do + expect(Dir).to receive(:open).and_yield(double("path", path: appdata_path)).exactly(3).times + expect(File).to receive(:exist?).and_return(false).exactly(3).times + expect{ subject.wsl_rootfs }.to raise_error(Vagrant::Errors::WSLRootFsNotFoundError) + end + + it "should raise not found error" do + expect{ subject.wsl_rootfs }.to raise_error(Vagrant::Errors::WSLRootFsNotFoundError) + end + end + + context "when file marker match found" do + let(:matching_path){ registry_paths.split("\r\n").last } + let(:matching_part){ matching_path.split("\\").last } + + before do + allow(File).to receive(:exist?).with(/#{matching_part}/).and_return(true) + end + + it "should return the matching path" do + expect(Dir).to receive(:open).with(/#{matching_part}/).and_yield(double("path", path: matching_part)) + expect(subject.wsl_rootfs).to start_with(matching_path) + end + + it "should return matching path when access error encountered" do + expect(Dir).to receive(:open).with(/#{matching_part}/).and_raise(Errno::EACCES) + expect(subject.wsl_rootfs).to start_with(matching_path) + end + end + end + end + + describe ".wsl_to_windows_path" do + let(:path){ "/home/vagrant/test" } + + context "when not within WSL" do + before{ allow(subject).to receive(:wsl?).and_return(false) } + + it "should return the path unmodified" do + expect(subject.wsl_to_windows_path(path)).to eq(path) + end + end + + context "when within WSL" do + before{ allow(subject).to receive(:wsl?).and_return(true) } + + context "when windows access is not enabled" do + before{ allow(subject).to receive(:wsl_windows_access?).and_return(false) } + + it "should return the path unmodified" do + expect(subject.wsl_to_windows_path(path)).to eq(path) + end + end + + context "when windows access is enabled" do + let(:rootfs_path){ "C:\\WSL\\rootfs" } + + before do + allow(subject).to receive(:wsl_windows_access?).and_return(true) + allow(subject).to receive(:wsl_rootfs).and_return(rootfs_path) + end + + it "should generate expanded path when within WSL" do + expect(subject.wsl_to_windows_path(path)).to eq("#{rootfs_path}#{path.gsub("/", "\\")}") + end + + it "should generate direct path when outside the WSL" do + expect(subject.wsl_to_windows_path("/mnt/c/vagrant")).to eq("c:\\vagrant") + end + + it "should not modify path when already in windows format" do + expect(subject.wsl_to_windows_path("C:\\vagrant")).to eq("C:\\vagrant") + end + + context "when within lxrun generated WSL instance" do + let(:rootfs_path){ "C:\\WSL\\lxss" } + + it "should not include rootfs when accessing home" do + expect(subject.wsl_to_windows_path("/home/vagrant")).not_to include("rootfs") + end + + it "should include rootfs when accessing non-home path" do + expect(subject.wsl_to_windows_path("/tmp/test")).to include("rootfs") + end + end + end + end + end + end end diff --git a/vagrant.gemspec b/vagrant.gemspec index d26b32adf..edc12f032 100644 --- a/vagrant.gemspec +++ b/vagrant.gemspec @@ -55,6 +55,7 @@ Gem::Specification.new do |s| all_files = Dir.chdir(root_path) { Dir.glob("**/{*,.*}") } all_files.reject! { |file| [".", ".."].include?(File.basename(file)) } all_files.reject! { |file| file.start_with?("website/") } + all_files.reject! { |file| file.start_with?("test/") } gitignore_path = File.join(root_path, ".gitignore") gitignore = File.readlines(gitignore_path) gitignore.map! { |line| line.chomp.strip }