--[[ This is the workaround for tickets XXXX and XXXX. It requires a 2019.1 or later Helix Core client (p4) running on Linux. This file is a Perforce Helix Core client-side Extension. Perforce Helix Core Extensions are customer-supplied scripts that either the Helix Core Server (p4d) or Client (p4) can execute when certain events occur, allowing users or administrators to extend native product functionality. Extensions on the client can run before and after command execution. The pre-command event allows an Extension to choose to let the command continue, to block it from running, or to rewrite it. The workaround for the XXXX client running unnecessary/expensive 'p4 status' commands is to provide an Extension that rewrites the command as issued by the XXXX CLI into a less-expensive version that does not do the on-disk content checking. For the XXXX CLI's usage of 'p4 revert', the Extension would block it entirely. As far as the XXXX CLI goes, it looks just like the command did not return any results when it is blocked. To enable the Extension on user computers, set the P4EXTENSIONS environmental variable to the absolute path to the Extension file. Make sure that the scope of the variable is limited to the XXXX client, otherwise other Helix Core Clients running in the same environment would see it - and aside from that causing general confusion, this script makes a number of assumptions about how p4 is being called that don't apply generally. To avoid setting the environmental variable on every computer separately, the 'XXXX' shell script wrapper can be modified to set the variable. The Extension file could be placed next to it on the shared installation path. ]]-- --[[ Copyright (c) 2021, Perforce Software, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PERFORCE SOFTWARE, INC. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ]]-- -------------------------------------------------------------------------------- -- Utility functions. function writeFile( name, content ) local f = io.open( name, "wb" ) if f == nil then return nil end f:write( content ) f:close() return 1 end function readFile( name ) local f = io.open( name, "rb" ) if f == nil then return nil end local content = f:read( "*all" ) f:close() return content end -- Return a table of the data in /proc/PID/cmdline. function splitProcCmdLine( str, sep ) return {str:match((str:gsub("[^"..sep.."]*"..sep, "([^"..sep.."]*)"..sep)))} end -- Return a table of the data in /proc/PID/status. function parseProcStatus( str ) local t = {} -- /proc/self/status is a list of key/value pairs: 'Name: p4' for l in str:gmatch( "[^\r\n]+" ) do -- The key is always present, but the value may be empty. local _, _, k, v = l:find "^(%S+):%s+(%S*)" t[ k ] = v end return t end -- Follow the process tree up from the current process to the root, -- running the supplied callback function at each step. function traverseProcessTree( callback, data, pid ) if pid == nil then pid = "self" end local statusStr = readFile( "/proc/" .. pid .. "/status" ) if statusStr == nil then return nil, "Error reading status for PID " .. pid end local status = parseProcStatus( statusStr ) if callback( tonumber( status["Pid"] ), data ) == nil then return cbRet, nil end local ppid = tonumber(status["PPid"]) if ppid == 0 then -- At the top of the tree, nothing more to see. return nil, nil end return traverseProcessTree( callback, data, ppid ) end -------------------------------------------------------------------------------- -- 'p4 status' handling. function getPyScriptBody() return [[ # usage: runp4.py /path/to/args_file import subprocess import sys args = [] # todo: need 'b' mode as well? fh = open(sys.argv[1], mode='r') for line in fh: args.append(line.rstrip()) fh.close() # The args list now looks like this: # ["p4", "-d", "/some/path", "-G", "--no-script", "status", "-a", "-d", "path1", "pathN"] # This is where the rewritten 'p4 status' call happens. The output from here # get sent up through the Extension/p4 and back into XXXX. subprocess.call(args) ]] end -- Conceptually, rewriting 'p4 status -fm path/...' to the cheaper form -- that doesn't look at file content is simple, but since there's -- no API in Extensions to spawn an external program without going through the -- shell (and potentially having issues with filenames containing shell metacharacters), -- and because the Extension doesn't have access to the client's global flags -- (of which XXXX uses in the 'p4 -d /path/to/dir status -fm' form), -- this function has to jump through some hoops in order to safely re-issue -- the command. XXXX reads the stdout/stderr from p4, and the subprocess launched -- here inherits that channel. function runStatus() -- The Extension is running from within the p4 process, so in order to -- get all the command-line arguments, we have to go to the /proc -- filesystem and parse /proc/ppid/cmdline to get the 'p4 -d path' part. local cmdLine = readFile( "/proc/self/cmdline" ) if cmdLine == nil then return nil, "Error reading cmdline." end local p4_args = splitProcCmdLine( cmdLine, "\0" ) -- Write the file telling the Python wrapper the command arguments. local args_file = os.tmpname() local args_fh = io.open( args_file, "wb" ) if args_fh == nil then return nil, "Error opening argument file to '" .. args_file .. "'" end for i, a in ipairs( p4_args ) do -- This is the first global arg that XXXX puts in - we use it to -- know when we're still in the global arguments so we can -- add this one, preventing recursion due to the Extension. if a == "-G" then args_fh:write( "-G\n" ) args_fh:write( "--no-script\n" ) -- Substitute the expensive arguments for ones that give similar data. elseif a == "-fm" then args_fh:write( "-a\n" ) args_fh:write( "-d\n" ) else -- Anything else. args_fh:write( a .. "\n" ) end end args_fh:close() -- Write the Python wrapper itself. local py_file = os.tmpname() if writeFile( py_file, getPyScriptBody() ) == nil then return nil, "Error writing Python wrapper to '" .. py_file .. "'" end -- Now run the wrapper and the 'p4 status'. local rc = os.execute( "python '" .. py_file .. "'" .. " " .. "'" .. args_file .. "'" ) -- Note that the error paths above leak these files. os.execute("rm -f" .. " '" .. py_file .. "'") os.execute("rm -f" .. " '" .. args_file .. "'") return rc, nil end -------------------------------------------------------------------------------- -- 'p4 revert' handling. -- This is a callback for traverseProcessTree(). function findXXXXProcess( pid, data ) local cmdLine = readFile( "/proc/" .. pid .. "/cmdline" ) if cmdLine == nil then return nil, "Error reading cmdline for PID " .. pid end local args = splitProcCmdLine( cmdLine, "\0" ) -- The XXXX invocation will be '/path/to/python -m XXXX $cmd'. -- We don't know what the leading path is, so just match -- on the interpreter name. if args[ 1 ]:match( 'python$' ) then if args[ 2 ] == '-m' and args[ 3 ] == 'XXXX' then data["pid"] = pid data["args"] = args -- Cancel the search. return nil end end -- Continue the search. return true end function revertCanRun() -- The process tree is normally XXXX->sh->p4, where this Extension is running -- in the p4, but there could be a p4 wrapper or things like that in the way, -- so we try to account for that by walking the process tree until we find -- the actual XXXX invocation. local XXXX_data = {} local _, err = traverseProcessTree( findXXXXProcess, XXXX_data ) if err ~= nil then -- Let it continue on error. return true, err end local XXXX_args = XXXX_data["args"] -- Commonly, the arguments will look like this: -- 1 2 3 4 5 6 -- ["python", "-m", "XXXX", "aa", "bb", "--cc"] -- But the --force could be anywhere after the "refer" or IPV argument. if XXXX_args[ 4 ] == "aa" and XXXX_args[ 5 ] == "bb" then for i, arg in ipairs( XXXX_args ) do if i > 5 and arg == "--cc" then -- This is an allowed revert. return true, nil end end end -- Prevent the revert. return false, nil end -------------------------------------------------------------------------------- -- This is the entry point for the Extension. It's called by p4 -- right before it's about to execute a command. function preCommand() local func = Helix.Core.Client.GetVar( "func" ) -- Prevent the IPLM client from running any 'p4 revert' commands, except -- if it's 'XXXX aa bb --cc', because the semantics of that are "toss -- it all out and I mean it". Note that it was originally thought that there -- was a 'p4 revert' run as part of the command, but there currently isn't so this -- check isn't really allowing anything, but we leave it in in case that -- changes. if func == 'revert' then local ok, err = revertCanRun() if err ~= nil then Helix.Core.Client.ClientError( err ) end if not ok then return Helix.Core.Client.Action.REPLACE end return Helix.Core.Client.Action.PASS end -- Change the IPLM client's invocation of 'p4 status -fm', to 'p4 status -a -d' -- to avoid the expensive on-disk content checks. if func == 'status' then local rc, msg = runStatus( Helix.Core.Client.GetVar( "argv" ) ) if rc == nil then Helix.Core.Client.ClientError( msg ) end return Helix.Core.Client.Action.REPLACE end -- Allow everything else to continue as-is. return Helix.Core.Client.Action.PASS end